Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c61d0d716 | ||
|
|
a337dd4bf0 | ||
|
|
980934bf63 | ||
|
|
785823a5da | ||
|
|
b5db730941 | ||
|
|
1f745cc518 | ||
|
|
04210b3092 | ||
|
|
b445167e7d | ||
|
|
8fc3ba3531 | ||
|
|
ba9c11717e | ||
|
|
62b432861b | ||
|
|
2c495c99b4 | ||
|
|
660248f087 | ||
|
|
43dd566939 | ||
|
|
bd098ee6b7 | ||
|
|
f4cb486243 | ||
|
|
8c600087dc | ||
|
|
1b0b072c4b | ||
|
|
cf337851ff | ||
|
|
767634aea5 | ||
|
|
e7fa061e23 | ||
|
|
d114a5d40c | ||
|
|
c6db9d41d6 | ||
|
|
332e46bb9e | ||
|
|
6ad3cb92df | ||
|
|
036276813c | ||
|
|
6bb2583b4a | ||
|
|
1a07475cfc | ||
|
|
d130013155 | ||
|
|
60d162c259 | ||
|
|
ae1602ad7a | ||
|
|
1a3164bf16 | ||
|
|
8bc564b4b2 | ||
|
|
dfc14bcd1f | ||
|
|
29df024a7f | ||
|
|
7a1e12bc7c | ||
|
|
c6c07cc3a3 | ||
|
|
02f99a9ac5 | ||
|
|
c746ab63c9 | ||
|
|
346fb24692 | ||
|
|
a306d6b2a8 | ||
|
|
b2d713e774 | ||
|
|
1bf8ebca96 | ||
|
|
dfd2e41259 | ||
|
|
05a84b217d | ||
|
|
e7e37d485d | ||
|
|
b35dede75f | ||
|
|
5fa9c905c8 | ||
|
|
7a666076bf | ||
|
|
ab960cbebb | ||
|
|
f5467e50c5 | ||
|
|
74989b6f25 | ||
|
|
524dabb9c3 | ||
|
|
d1d8f0bbed | ||
|
|
0edadeebdb | ||
|
|
ab02b1b255 | ||
|
|
93e3137342 | ||
|
|
ab661f2d33 | ||
|
|
15ed1ff3af | ||
|
|
b403039d00 | ||
|
|
7426c03f4e | ||
|
|
959b83c78f | ||
|
|
0b58107097 | ||
|
|
1230ab8a3e | ||
|
|
c5276b1943 | ||
|
|
9118218ac3 | ||
|
|
2bf4c5451a | ||
|
|
5ce3eefdbf | ||
|
|
e0b22108a8 | ||
|
|
af31c8a11d | ||
|
|
2ece6e3ab9 | ||
|
|
ad7feb523a | ||
|
|
e5660b8902 | ||
|
|
53cd096a56 | ||
|
|
24595810a1 | ||
|
|
e7a81637dd | ||
|
|
fef2e0dbce | ||
|
|
35133de650 | ||
|
|
93fd32c684 |
Generated
+6
@@ -2155,10 +2155,16 @@ dependencies = [
|
||||
name = "oa-desktop"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"iced",
|
||||
"iced_runtime",
|
||||
"oa-core",
|
||||
"oa-server",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ The normative [language and editing contract](docs/language.rst) defines support
|
||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
@@ -45,26 +45,34 @@ The example opens in an architecture view with work nested inside its performers
|
||||
|
||||
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.
|
||||
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**. Select the port and choose **Connect boundary exchange**; the picker identifies compatible ports by owner, name and alias. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions. Use **Create relationship** for allocations, functional exchanges, capability involvement, mission links, state transitions and interface relationships; **Data & interfaces** holds reusable payloads and contracts.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. State machines contain regions and states/modes with guarded transitions, availability and state-action links. Conditions are descriptive, not executable simulation code. Creating records in Behavior or Data preserves your architecture layouts; **Show in this diagram** adds an explicit placement when desired.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
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.
|
||||
|
||||
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
|
||||
|
||||
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.
|
||||
|
||||
Drag cards to move on a 10-unit grid, drag empty space to pan, and scroll to zoom. **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.
|
||||
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.
|
||||
|
||||
Connections start with orthogonal routes that avoid cards and container headers. Select a connection and drag a line segment to add a bend, or choose **Add bend**. Drag its circles to adjust the route; **Remove bend** removes the selected point, and **Reset routing** restores automatic routing. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Typed ports remain attached to their owning elements. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment—automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
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.
|
||||
|
||||
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
|
||||
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
|
||||
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
**Textual model** has per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Projects can use relative includes to organize source into files. For example:
|
||||
**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 3;
|
||||
arcadia 4;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
@@ -94,7 +102,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
||||
|
||||
## Save and reopen
|
||||
|
||||
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
|
||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
|
||||
|
||||
@@ -110,14 +118,18 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
|
||||
|
||||
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
|
||||
|
||||
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
|
||||
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
|
||||
|
||||
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
|
||||
|
||||
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
|
||||
|
||||
```sh
|
||||
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
||||
-H 'Content-Type: application/json' --data-binary @system.json
|
||||
```
|
||||
|
||||
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
|
||||
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
|
||||
|
||||
@@ -134,4 +146,4 @@ cargo build --release --workspace --locked
|
||||
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
|
||||
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
|
||||
|
||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
|
||||
@@ -507,6 +507,7 @@ fn workshop() -> Project {
|
||||
let baseline = TraceBaseline {
|
||||
oa: p.properties(t.oa).unwrap(),
|
||||
sa: p.properties(t.sa).unwrap(),
|
||||
semantic: None,
|
||||
};
|
||||
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+25
-11
@@ -34,10 +34,7 @@ fn occurrence(
|
||||
id
|
||||
}
|
||||
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
||||
let items = match p.elements[&interface].shared() {
|
||||
Some(SharedBody::Interface { items }) => items.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let items = crate::data::interface_items(p, interface);
|
||||
let contract = if p.elements[&owner].kind() == Kind::Function {
|
||||
if direction == PortDirection::Out {
|
||||
PortContract::FunctionOutput {
|
||||
@@ -114,6 +111,7 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -168,6 +166,11 @@ pub fn coastal_rescue() -> Project {
|
||||
"Distress report",
|
||||
None,
|
||||
);
|
||||
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&oa_item).unwrap().shared_mut()
|
||||
{
|
||||
*mechanism = ExchangeMechanism::Event;
|
||||
}
|
||||
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
@@ -308,6 +311,11 @@ pub fn coastal_rescue() -> Project {
|
||||
"Digital distress report",
|
||||
None,
|
||||
);
|
||||
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&information).unwrap().shared_mut()
|
||||
{
|
||||
*mechanism = ExchangeMechanism::Event;
|
||||
}
|
||||
let material = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
@@ -317,6 +325,8 @@ pub fn coastal_rescue() -> Project {
|
||||
);
|
||||
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
let energy = add(
|
||||
&mut p,
|
||||
@@ -327,6 +337,8 @@ pub fn coastal_rescue() -> Project {
|
||||
);
|
||||
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
let field = add(
|
||||
&mut p,
|
||||
@@ -340,6 +352,9 @@ pub fn coastal_rescue() -> Project {
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: "degrees north".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
let interface = add(
|
||||
&mut p,
|
||||
@@ -348,13 +363,12 @@ pub fn coastal_rescue() -> Project {
|
||||
"Distress reporting interface",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface {
|
||||
items: vec![information],
|
||||
};
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Distress payload",
|
||||
interface,
|
||||
information,
|
||||
);
|
||||
let vessel_port = port(
|
||||
&mut p,
|
||||
actor,
|
||||
|
||||
+164
-19
@@ -1,4 +1,6 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
||||
#[path = "language/semantic_source.rs"]
|
||||
mod semantic_source;
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
@@ -66,7 +68,14 @@ impl ParseLimits {
|
||||
project
|
||||
.diagrams
|
||||
.values()
|
||||
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
|
||||
.map(|d| {
|
||||
d.nodes.len().saturating_add(d.routes.len()).saturating_add(
|
||||
d.nodes
|
||||
.values()
|
||||
.map(|n| n.port_anchors.len())
|
||||
.sum::<usize>(),
|
||||
)
|
||||
})
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
@@ -333,6 +342,8 @@ fn reference_field(key: &str) -> bool {
|
||||
| "referenced"
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
@@ -349,6 +360,7 @@ fn reference_field(key: &str) -> bool {
|
||||
| "sa"
|
||||
| "diagram"
|
||||
| "edge"
|
||||
| "port"
|
||||
| "machine"
|
||||
)
|
||||
}
|
||||
@@ -369,10 +381,15 @@ fn enum_field(key: &str) -> bool {
|
||||
| "viewpoint"
|
||||
| "message_kind"
|
||||
| "transition_kind"
|
||||
| "side"
|
||||
| "mechanism"
|
||||
| "storage"
|
||||
| "transmission"
|
||||
| "acquisition"
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
|
||||
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
|
||||
/// adopts a model and never overwrites an editor draft.
|
||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_limited(source, ParseLimits::UNLIMITED)
|
||||
@@ -537,7 +554,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
@@ -728,6 +745,10 @@ fn write_value(
|
||||
indent: usize,
|
||||
aliases: &BTreeMap<Id, String>,
|
||||
) {
|
||||
if key == "semantic" {
|
||||
semantic_source::write(out, value, indent);
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::Null => out.push_str("none"),
|
||||
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
|
||||
@@ -771,6 +792,7 @@ enum Ast {
|
||||
Literal(Value, usize),
|
||||
Word(String, usize),
|
||||
Reference(String, usize),
|
||||
HistoricalUuid(Id, usize),
|
||||
Array(Vec<Ast>, usize),
|
||||
Object(BTreeMap<String, Ast>, usize),
|
||||
}
|
||||
@@ -780,12 +802,20 @@ impl Ast {
|
||||
Self::Literal(_, p)
|
||||
| Self::Word(_, p)
|
||||
| Self::Reference(_, p)
|
||||
| Self::HistoricalUuid(_, p)
|
||||
| Self::Array(_, p)
|
||||
| Self::Object(_, p) => *p,
|
||||
}
|
||||
}
|
||||
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
|
||||
match self {
|
||||
Self::HistoricalUuid(_, _) => Err(Error {
|
||||
code: "invalid_source",
|
||||
offset: self.offset(),
|
||||
message:
|
||||
"A uuid literal is allowed only in a typed historical semantic reference slot."
|
||||
.into(),
|
||||
}),
|
||||
Self::Word(word, offset) => {
|
||||
if !enum_field(key) {
|
||||
return Err(Error {
|
||||
@@ -953,6 +983,7 @@ pub(crate) struct EditValue {
|
||||
pub(crate) enum EditKind {
|
||||
Atom,
|
||||
Reference(String),
|
||||
HistoricalUuid(Id),
|
||||
Record(BTreeMap<String, EditField>),
|
||||
List(Vec<EditValue>),
|
||||
}
|
||||
@@ -1065,7 +1096,7 @@ fn edit_positions(
|
||||
let span = Span {
|
||||
start: ast.offset(),
|
||||
end: match ast {
|
||||
Ast::Reference(..) => self.tokens[index + 1].span.end,
|
||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
||||
_ => self.ends[index],
|
||||
},
|
||||
};
|
||||
@@ -1075,6 +1106,7 @@ fn edit_positions(
|
||||
EditKind::List(items.iter().map(|item| self.value(item)).collect())
|
||||
}
|
||||
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
|
||||
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
|
||||
_ => EditKind::Atom,
|
||||
};
|
||||
EditValue { span, kind }
|
||||
@@ -1255,6 +1287,16 @@ impl Parser<'_> {
|
||||
let token = self.current();
|
||||
let offset = token.span.start;
|
||||
match self.text() {
|
||||
"uuid" => {
|
||||
self.cursor += 1;
|
||||
let value = self.quoted()?;
|
||||
let id = Id::parse_str(&value).map_err(|_| Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: "Expected a UUID after uuid.".into(),
|
||||
})?;
|
||||
Ok(Ast::HistoricalUuid(id, offset))
|
||||
}
|
||||
"@" => {
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Reference(self.identifier()?, offset))
|
||||
@@ -1328,12 +1370,12 @@ impl Parser<'_> {
|
||||
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
||||
);
|
||||
}
|
||||
let project =
|
||||
let decoded =
|
||||
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
||||
.map_err(|(_, error)| error)?;
|
||||
Ok(ParsedDocument {
|
||||
project,
|
||||
migration: None,
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
locations,
|
||||
fields,
|
||||
})
|
||||
@@ -1341,9 +1383,11 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if self.text() != "3" {
|
||||
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
|
||||
if !matches!(self.text(), "3" | "4" | "5") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
@@ -1356,7 +1400,7 @@ impl Parser<'_> {
|
||||
self.take("{")?;
|
||||
Some(SourceHeader {
|
||||
project: Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
schema_version,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
@@ -1531,14 +1575,21 @@ pub(crate) fn resolve_declarations(
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
limits: ParseLimits,
|
||||
) -> Result<Project, (usize, Error)> {
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let mut legacy_elements = serde_json::Map::new();
|
||||
let mut nodes = Vec::new();
|
||||
for (index, declaration) in declarations.iter().enumerate() {
|
||||
(|| -> Result<(), Error> {
|
||||
let mut fields = declaration
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
|
||||
.map(|(k, v)| {
|
||||
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
|
||||
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
|
||||
} else { v.resolve(k, aliases)? };
|
||||
Ok((k.clone(), value))
|
||||
})
|
||||
.collect::<Result<Map<_, _>, Error>>()?;
|
||||
let d = declaration;
|
||||
let conversion_error = |message: String| Error {
|
||||
@@ -1552,6 +1603,9 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
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()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let explicit_parent = d.fields.contains_key("parent");
|
||||
@@ -1592,9 +1646,17 @@ pub(crate) fn resolve_declarations(
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let body_fields =
|
||||
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
let defaults = if legacy {
|
||||
crate::persistence::v3::legacy_defaults(kind)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_else(|| payload(&body))
|
||||
} else { payload(&body) };
|
||||
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, body_fields);
|
||||
if legacy {
|
||||
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
|
||||
return Ok(());
|
||||
}
|
||||
let body: ElementBody = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
@@ -1615,6 +1677,9 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
@@ -1664,7 +1729,7 @@ pub(crate) fn resolve_declarations(
|
||||
if !fields.contains_key("element") {
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
@@ -1696,8 +1761,12 @@ pub(crate) fn resolve_declarations(
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
let trace = if project.schema_version < 5 {
|
||||
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
|
||||
Trace::from(old)
|
||||
} else {
|
||||
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
|
||||
};
|
||||
project.traces.insert(d.id, trace);
|
||||
}
|
||||
}
|
||||
@@ -1716,6 +1785,74 @@ pub(crate) fn resolve_declarations(
|
||||
.map_err(|error| (index, error))?;
|
||||
target.nodes.insert(node.id, node);
|
||||
}
|
||||
if legacy {
|
||||
let mut value = serde_json::to_value(&project).expect("project serialization");
|
||||
value["elements"] = Value::Object(legacy_elements);
|
||||
let migrated = serde_json::from_value(value)
|
||||
.map_err(crate::persistence::FileError::Json)
|
||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||
.map_err(|error| {
|
||||
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
||||
issues.first()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let index = issue
|
||||
.and_then(|issue| issue.element)
|
||||
.and_then(|id| declarations.iter().position(|d| d.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
let offset = declarations.get(index).map_or(0, |d| {
|
||||
issue
|
||||
.and_then(|issue| issue.field.as_ref())
|
||||
.and_then(|field| d.field_spans.get(field))
|
||||
.map_or(d.offset, |span| span.start)
|
||||
});
|
||||
(
|
||||
index,
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"migration_error"
|
||||
},
|
||||
offset,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
})?;
|
||||
for (index, d) in declarations.iter().enumerate() {
|
||||
if let DeclKind::Element(kind, layer) = d.kind {
|
||||
let body = &migrated.project.elements[&d.id].body;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
return Err((
|
||||
index,
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: d.offset,
|
||||
message: "Declaration kind disagrees with its typed body.".into(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if project.schema_version == 4 {
|
||||
return crate::persistence::v4::finish(project, limits).map_err(|error| {
|
||||
(
|
||||
usize::MAX,
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"migration_error"
|
||||
},
|
||||
offset: 0,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
});
|
||||
}
|
||||
limits.check_model(&project).map_err(|message| {
|
||||
(
|
||||
usize::MAX,
|
||||
@@ -1726,7 +1863,10 @@ pub(crate) fn resolve_declarations(
|
||||
},
|
||||
)
|
||||
})?;
|
||||
Ok(project)
|
||||
Ok(crate::persistence::DecodeResult {
|
||||
project,
|
||||
migration: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
|
||||
@@ -2507,3 +2647,8 @@ pub(crate) mod legacy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed historical facet identity for the targeted source writer.
|
||||
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
||||
semantic_source::row_key(source, origins)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Typed historical source leaves; live alias resolution remains in the parent.
|
||||
use super::*;
|
||||
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
|
||||
|
||||
fn error(ast: &Ast, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: ast.offset(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(ast: &Ast) -> Result<Value, Error> {
|
||||
Ok(match ast {
|
||||
Ast::Literal(value, _) => value.clone(),
|
||||
Ast::Word(value, _) => Value::String(value.clone()),
|
||||
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
|
||||
Ast::Reference(_, _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"Historical semantic references require uuid \"...\", not @alias.",
|
||||
));
|
||||
}
|
||||
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
|
||||
Ast::Object(fields, _) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
|
||||
.collect::<Result<_, Error>>()?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_tokens(
|
||||
ast: &Ast,
|
||||
path: &mut Vec<String>,
|
||||
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
|
||||
) -> Result<(), Error> {
|
||||
match ast {
|
||||
Ast::Object(fields, _) => {
|
||||
for (key, value) in fields {
|
||||
path.push(key.clone());
|
||||
validate_tokens(value, path, slots)?;
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Ast::Array(values, _) => {
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
path.push(index.to_string());
|
||||
validate_tokens(value, path, slots)?;
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => match (slots.get(path), ast) {
|
||||
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
|
||||
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
|
||||
(Some(HistoricalSourceKind::Reference), _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This historical reference requires uuid \"...\".",
|
||||
));
|
||||
}
|
||||
(Some(HistoricalSourceKind::Enum), _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This typed historical enum requires an unquoted enum word.",
|
||||
));
|
||||
}
|
||||
(None, Ast::HistoricalUuid(..)) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"A uuid literal is allowed only in a typed historical reference slot.",
|
||||
));
|
||||
}
|
||||
(None, Ast::Word(..)) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This historical text value requires a quoted string.",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn resolve_baseline(
|
||||
ast: &Ast,
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
version: u32,
|
||||
) -> Result<Value, Error> {
|
||||
let Ast::Object(fields, _) = ast else {
|
||||
return ast.resolve("baseline", aliases);
|
||||
};
|
||||
let mut resolved = Map::new();
|
||||
for (key, value) in fields {
|
||||
if key != "semantic" {
|
||||
resolved.insert(key.clone(), value.resolve(key, aliases)?);
|
||||
continue;
|
||||
}
|
||||
if version < 5 {
|
||||
return Err(error(
|
||||
value,
|
||||
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
|
||||
));
|
||||
}
|
||||
if matches!(value, Ast::Literal(Value::Null, _)) {
|
||||
resolved.insert(key.clone(), Value::Null);
|
||||
continue;
|
||||
}
|
||||
let json = raw(value)?;
|
||||
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
|
||||
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
|
||||
let slots = historical_source_slots(&baseline).into_iter().collect();
|
||||
validate_tokens(value, &mut vec![], &slots)?;
|
||||
resolved.insert(key.clone(), json);
|
||||
}
|
||||
Ok(Value::Object(resolved))
|
||||
}
|
||||
|
||||
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
|
||||
if value.is_null() {
|
||||
out.push_str("none");
|
||||
return;
|
||||
}
|
||||
let baseline: SemanticBaseline =
|
||||
serde_json::from_value(value.clone()).expect("typed semantic baseline");
|
||||
let slots = historical_source_slots(&baseline).into_iter().collect();
|
||||
write_at(out, value, indent, &mut vec![], &slots);
|
||||
}
|
||||
|
||||
fn write_at(
|
||||
out: &mut String,
|
||||
value: &Value,
|
||||
indent: usize,
|
||||
path: &mut Vec<String>,
|
||||
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
|
||||
) {
|
||||
match value {
|
||||
Value::Null => out.push_str("none"),
|
||||
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => out.push_str(&value.to_string()),
|
||||
Value::String(value) => match slots.get(path) {
|
||||
Some(HistoricalSourceKind::Reference) => {
|
||||
out.push_str("uuid ");
|
||||
out.push_str("e(value));
|
||||
}
|
||||
Some(HistoricalSourceKind::Enum) => out.push_str(value),
|
||||
None => out.push_str("e(value)),
|
||||
},
|
||||
Value::Array(values) => {
|
||||
out.push('[');
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
path.push(index.to_string());
|
||||
write_at(out, value, indent, path, slots);
|
||||
path.pop();
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Value::Object(fields) => {
|
||||
out.push_str("{\n");
|
||||
for (key, value) in fields {
|
||||
out.push_str(&" ".repeat(indent + 1));
|
||||
out.push_str(key);
|
||||
out.push(' ');
|
||||
path.push(key.clone());
|
||||
write_at(out, value, indent + 1, path, slots);
|
||||
path.pop();
|
||||
out.push_str(";\n");
|
||||
}
|
||||
out.push_str(&" ".repeat(indent));
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize only complete typed facet rows. Their closed selector, with real
|
||||
/// identity provenance applied, owns comments when review rows reorder.
|
||||
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
||||
let mut parser = Parser {
|
||||
source,
|
||||
tokens: strict_tokens(source).ok()?,
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits: ParseLimits::UNLIMITED,
|
||||
};
|
||||
let ast = parser.value().ok()?;
|
||||
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
|
||||
let mut baseline = SemanticBaseline {
|
||||
facets: vec![facet],
|
||||
};
|
||||
crate::semantic::visit_semantic_references(&mut baseline, |id| {
|
||||
if let Some(original) = origins.get(id) {
|
||||
*id = *original;
|
||||
}
|
||||
});
|
||||
serde_json::to_string(&baseline.facets[0].selector())
|
||||
.ok()
|
||||
.map(|key| format!("semantic:{key}"))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
|
||||
|
||||
fn project_source(body: &str) -> String {
|
||||
format!(
|
||||
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"children.arcadia".into(),
|
||||
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
|
||||
|
||||
#[test]
|
||||
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
|
||||
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let analysis = analyze(source);
|
||||
let ready = analysis
|
||||
.declarations
|
||||
@@ -406,12 +406,34 @@ fn checked_in_source_project() -> SourceBundle {
|
||||
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
|
||||
let input = checked_in_source_project();
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"every example identity is already explicit"
|
||||
);
|
||||
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
|
||||
for (path, source) in &input.files {
|
||||
let unit = parse_unit(source, path == &input.entry, ParseLimits::UNLIMITED).unwrap();
|
||||
assert!(
|
||||
unit.header
|
||||
.as_ref()
|
||||
.is_none_or(|header| header.id_insertion.is_none())
|
||||
);
|
||||
for declaration in unit.declarations {
|
||||
assert!(
|
||||
declaration.id_insertion.is_none(),
|
||||
"every example identity is already explicit"
|
||||
);
|
||||
assert_eq!(
|
||||
identity(&compiled.decoded.project, &declaration.alias),
|
||||
declaration.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(compiled.diagnostics.len(), 2);
|
||||
assert!(
|
||||
compiled.diagnostics.is_empty(),
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
compiled.diagnostics
|
||||
);
|
||||
@@ -473,7 +495,15 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
|
||||
assert_eq!(result.diagnostics.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
assert_eq!(
|
||||
result.index.declarations[&assess].path,
|
||||
"sa/assurance.arcadia"
|
||||
@@ -500,6 +530,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
"sa/participants.arcadia",
|
||||
"views.arcadia",
|
||||
] {
|
||||
assert_eq!(result.bundle.files[path], input.files[path]);
|
||||
assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod persistence;
|
||||
@@ -6,6 +7,7 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
@@ -13,6 +15,7 @@ pub mod transitions;
|
||||
pub mod validation;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
mod state_validation;
|
||||
|
||||
@@ -11,10 +11,14 @@ but constructs and validates the frozen DTO, preserves located old-contract
|
||||
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
|
||||
generic port/optional FE endpoint contracts therefore remain explicit.
|
||||
|
||||
`adapter.rs` budgets generated data, validates V2, transforms only the changed
|
||||
payloads, and validates schema 3. Serialization between the two typed DTOs copies
|
||||
unchanged fields; original input bytes are never deserialized into the new
|
||||
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
|
||||
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
|
||||
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
|
||||
then performs the data-contract upgrade into the strict schema 4 envelope. The
|
||||
schema 4 review adapter preserves its prose-only trace baselines and returns
|
||||
schema 5 with semantic review absent. Serialization
|
||||
between compatibility DTOs copies unchanged fields; original input bytes are
|
||||
never deserialized into the new `Project`. V1 normalization also constructs this
|
||||
V2 DTO before the shared stages. Combined generation stays within one budget.
|
||||
|
||||
Do not update these frozen shapes when extending the current model. Add another
|
||||
versioned migration when the persistent contract changes again. Golden V2
|
||||
|
||||
@@ -62,16 +62,9 @@ pub(crate) fn migrate(
|
||||
}],
|
||||
});
|
||||
}
|
||||
let mut report = MigrationReport {
|
||||
from_schema: 2,
|
||||
to_schema: 3,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
};
|
||||
let mut report = MigrationReport::data_upgrade(2);
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
value["schema_version"] = Value::from(SCHEMA_VERSION);
|
||||
value["schema_version"] = Value::from(3);
|
||||
for element in old.elements.values() {
|
||||
if let Some(super::model::SharedBody::Port {
|
||||
direction,
|
||||
@@ -204,11 +197,23 @@ pub(crate) fn migrate(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let project: Project = serde_json::from_value(value)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::super::validate_project(&project)?;
|
||||
// Carry the endpoint-generation reference charge into the next migration;
|
||||
// independent per-stage checks would undercount the combined generated graph.
|
||||
let data_limits = ParseLimits {
|
||||
max_step_references: limits
|
||||
.max_step_references
|
||||
.saturating_sub(additional_references),
|
||||
..limits
|
||||
};
|
||||
let migrated = super::super::v3::migrate(serde_json::from_value(value)?, data_limits)?;
|
||||
let data = migrated
|
||||
.migration
|
||||
.expect("the schema3 adapter reports its defaults");
|
||||
report.semantic_defaults = data.semantic_defaults;
|
||||
report.generated_interface_allocations = data.generated_interface_allocations;
|
||||
report.unresolved_message_values = data.unresolved_message_values;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
project: migrated.project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Schema 3 to 4 reader
|
||||
|
||||
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
|
||||
The small DTOs in `mod.rs` own the changed data payloads and old message values;
|
||||
closed element/relation constructor lists prevent new vocabulary from entering
|
||||
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
|
||||
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
|
||||
|
||||
Nature, bounded types, field defaults/direction/storage, and single supertypes
|
||||
receive explicit documented defaults. Interface lists become identified ordered
|
||||
allocations with UUID v5 namespace equal to the project UUID and name
|
||||
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
|
||||
V2 and V3 inputs so the migration route cannot change the generated identity.
|
||||
Existing IDs never move; deterministic collisions fail with a located diagnostic.
|
||||
Aliases use the normal deterministic collision suffix. Unique legacy message
|
||||
paths are qualified; ambiguous annotations retain their expression and warning.
|
||||
|
||||
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
|
||||
an explicit schema3 candidate and then runs this reader before current decoding.
|
||||
Generated object, record and reference budgets cover the combined stages.
|
||||
|
||||
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
|
||||
but chooses frozen source3 payload defaults and rejects new constructors. Both
|
||||
single-file parsing and `source_project::compile_bundle` take this explicit path.
|
||||
The bundle compiler first stabilizes omitted IDs, then performs targeted token
|
||||
edits in `source_project/source_migration.rs`. Original files, includes, lexical
|
||||
ownership and comments survive. New allocation declarations stay in the source
|
||||
interface's module. The returned source and index describe schema4 bytes; loading
|
||||
does not rewrite original disk files. Canonical output and second-save stability
|
||||
are schema4. No whole-project formatting fallback handles authored source3.
|
||||
|
||||
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
|
||||
original V2 golden files remain unchanged.
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
|
||||
//! unchanged typed records are shared after a closed old-constructor check.
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{DecodeResult, FileError, MigrationReport};
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project3 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element3>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation3>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Element3 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parent: Option<Id>,
|
||||
#[serde(deserialize_with = "strict_value")]
|
||||
pub body: Value,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Relation3 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "strict_value")]
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
|
||||
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
|
||||
struct Strict;
|
||||
impl<'de> serde::de::Visitor<'de> for Strict {
|
||||
type Value = Value;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("a value with unique object fields")
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| E::custom("Non-finite number"))
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(v) = a.next_element::<StrictValue>()? {
|
||||
out.push(v.0);
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
|
||||
let mut out = serde_json::Map::new();
|
||||
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
|
||||
if out.insert(k.clone(), v.0).is_some() {
|
||||
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(Strict)
|
||||
}
|
||||
struct StrictValue(Value);
|
||||
impl<'de> Deserialize<'de> for StrictValue {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
strict_value(d).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum ItemKind3 {
|
||||
Information,
|
||||
Material,
|
||||
Energy,
|
||||
Event,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum Definition3 {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration { literals: Vec<String> },
|
||||
Record,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Item3 {
|
||||
kind: ItemKind3,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Type3 {
|
||||
definition: Definition3,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Field3 {
|
||||
type_ref: TypeRef,
|
||||
minimum: u32,
|
||||
maximum: Option<u32>,
|
||||
unit: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Interface3 {
|
||||
items: Vec<Id>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MessageValue3 {
|
||||
field: Id,
|
||||
expression: String,
|
||||
}
|
||||
|
||||
pub(crate) fn relation_allowed(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Allocation"
|
||||
| "PortAllocation"
|
||||
| "Interaction"
|
||||
| "CapabilityInvolvement"
|
||||
| "ChainInvolvement"
|
||||
| "RoleRealization"
|
||||
| "RoleAssignment"
|
||||
| "FunctionalExchange"
|
||||
| "BoundaryExchange"
|
||||
| "MissionCapability"
|
||||
| "CapabilityInclude"
|
||||
| "CapabilityExtend"
|
||||
| "CapabilityGeneralization"
|
||||
| "MissionInvolvement"
|
||||
| "SequenceLink"
|
||||
| "StateTransition"
|
||||
| "Availability"
|
||||
| "ExchangeAllocation"
|
||||
| "InterfaceProvision"
|
||||
| "InterfaceRequirement"
|
||||
| "StateAction"
|
||||
)
|
||||
}
|
||||
pub(crate) fn element_allowed(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Entity"
|
||||
| "Actor"
|
||||
| "Role"
|
||||
| "Activity"
|
||||
| "Capability"
|
||||
| "OperationalMission"
|
||||
| "Process"
|
||||
| "System"
|
||||
| "SystemActor"
|
||||
| "Function"
|
||||
| "SystemCapability"
|
||||
| "Mission"
|
||||
| "FunctionalChain"
|
||||
| "ExchangeItem"
|
||||
| "DataType"
|
||||
| "DataField"
|
||||
| "Interface"
|
||||
| "Port"
|
||||
| "Scenario"
|
||||
| "Lifeline"
|
||||
| "Occurrence"
|
||||
| "StateMachine"
|
||||
| "Region"
|
||||
| "State"
|
||||
| "Mode"
|
||||
| "Initial"
|
||||
| "Final"
|
||||
| "Choice"
|
||||
| "Junction"
|
||||
| "Fork"
|
||||
| "Join"
|
||||
| "ShallowHistory"
|
||||
| "DeepHistory"
|
||||
| "EntryPoint"
|
||||
| "ExitPoint"
|
||||
| "Terminate"
|
||||
| "Event"
|
||||
| "Constraint"
|
||||
)
|
||||
}
|
||||
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
|
||||
Some(match kind {
|
||||
Kind::ExchangeItem => json!({"kind":"Information"}),
|
||||
Kind::DataType => json!({"definition":"String"}),
|
||||
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
|
||||
Kind::Interface => json!({"items":[]}),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
|
||||
let message = message.into();
|
||||
FileError::Model {
|
||||
message: format!("V3 {code} at {id}.{field}: {message}"),
|
||||
issues: vec![validation::Issue {
|
||||
severity: validation::Severity::Error,
|
||||
element: Some(id),
|
||||
code: code.into(),
|
||||
field: Some(field.into()),
|
||||
message,
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn singleton(value: &Value) -> Option<(&str, &Value)> {
|
||||
let object = value.as_object()?;
|
||||
(object.len() == 1)
|
||||
.then(|| object.iter().next().unwrap())
|
||||
.map(|(k, v)| (k.as_str(), v))
|
||||
}
|
||||
fn old_payload<T: serde::de::DeserializeOwned>(
|
||||
id: Id,
|
||||
field: &str,
|
||||
value: Value,
|
||||
) -> Result<T, FileError> {
|
||||
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
if old.schema_version != 3 {
|
||||
return Err(FileError::Invalid("Expected schema_version 3.".into()));
|
||||
}
|
||||
let graph = old.elements.len().saturating_add(old.relations.len());
|
||||
let records = graph
|
||||
.saturating_add(old.diagrams.len())
|
||||
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
|
||||
.saturating_add(old.traces.len())
|
||||
.saturating_add(old.artifact_links.len())
|
||||
.saturating_add(1);
|
||||
limits
|
||||
.check_counts(graph, records, 0)
|
||||
.map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(3);
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
let mut allocations = Vec::new();
|
||||
for (key, e) in &old.elements {
|
||||
if *key != e.id {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"id",
|
||||
"identity",
|
||||
"Element map key differs from its identity.",
|
||||
));
|
||||
}
|
||||
let mut body = e.body.clone();
|
||||
let (layer, inner) = singleton(&body)
|
||||
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
|
||||
.ok_or_else(|| {
|
||||
at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Expected one OA or SA constructor.",
|
||||
)
|
||||
})?;
|
||||
let layer = layer.to_owned();
|
||||
let outer = inner
|
||||
.as_str()
|
||||
.or_else(|| singleton(inner).map(|(name, _)| name));
|
||||
if !outer.is_some_and(|name| {
|
||||
if layer == "Oa" {
|
||||
matches!(
|
||||
name,
|
||||
"Entity"
|
||||
| "Actor"
|
||||
| "Role"
|
||||
| "Activity"
|
||||
| "Capability"
|
||||
| "Mission"
|
||||
| "Process"
|
||||
| "Shared"
|
||||
)
|
||||
} else {
|
||||
matches!(
|
||||
name,
|
||||
"System"
|
||||
| "Actor"
|
||||
| "Function"
|
||||
| "Capability"
|
||||
| "Mission"
|
||||
| "FunctionalChain"
|
||||
| "Shared"
|
||||
)
|
||||
}
|
||||
}) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Unknown schema3 layer constructor.",
|
||||
));
|
||||
}
|
||||
if let Some(("Shared", shared)) = singleton(inner) {
|
||||
let name = shared
|
||||
.as_str()
|
||||
.or_else(|| singleton(shared).map(|(name, _)| name))
|
||||
.ok_or_else(|| {
|
||||
at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Expected one schema3 shared constructor.",
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
if !matches!(
|
||||
name.as_str(),
|
||||
"ExchangeItem"
|
||||
| "DataType"
|
||||
| "DataField"
|
||||
| "Interface"
|
||||
| "Port"
|
||||
| "Scenario"
|
||||
| "Lifeline"
|
||||
| "Occurrence"
|
||||
| "StateMachine"
|
||||
| "Event"
|
||||
| "Region"
|
||||
| "State"
|
||||
| "Constraint"
|
||||
) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
format!("Unknown schema3 shared constructor '{name}'."),
|
||||
));
|
||||
}
|
||||
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
|
||||
let changed = match name.as_str() {
|
||||
"ExchangeItem" => {
|
||||
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
|
||||
let (kind, mechanism) = match old.kind {
|
||||
ItemKind3::Information => ("Information", "Unspecified"),
|
||||
ItemKind3::Material => ("Material", "Unspecified"),
|
||||
ItemKind3::Energy => ("Energy", "Unspecified"),
|
||||
ItemKind3::Event => ("Information", "Event"),
|
||||
};
|
||||
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
|
||||
}
|
||||
"DataType" => {
|
||||
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
|
||||
let definition = match old.definition {
|
||||
Definition3::Boolean => json!("Boolean"),
|
||||
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
Definition3::String => {
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}})
|
||||
}
|
||||
Definition3::Enumeration { literals } => {
|
||||
json!({"Enumeration":{"literals":literals}})
|
||||
}
|
||||
Definition3::Record => json!({"Record":{"supertype":null}}),
|
||||
};
|
||||
Some(json!({"definition":definition,"default":null,"unit":""}))
|
||||
}
|
||||
"DataField" => {
|
||||
let old: Field3 = old_payload(e.id, "body", payload)?;
|
||||
Some(
|
||||
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
|
||||
)
|
||||
}
|
||||
"Interface" => {
|
||||
let old: Interface3 = old_payload(e.id, "items", payload)?;
|
||||
let mut seen = BTreeSet::new();
|
||||
for (order, item) in old.items.into_iter().enumerate() {
|
||||
if !seen.insert(item) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"items",
|
||||
"duplicate_reference",
|
||||
"Interface items must be unique.",
|
||||
));
|
||||
}
|
||||
allocations.push((e.id, item, order));
|
||||
}
|
||||
Some(json!({"supertype":null}))
|
||||
}
|
||||
"Occurrence" => {
|
||||
let mut payload = payload;
|
||||
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
|
||||
let old: Vec<MessageValue3> =
|
||||
old_payload(e.id, "body.values", values.clone())?;
|
||||
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
|
||||
}
|
||||
Some(payload)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(changed) = changed {
|
||||
let description = match name.as_str() {
|
||||
"ExchangeItem" if changed["mechanism"] == "Event" => {
|
||||
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
|
||||
}
|
||||
"ExchangeItem" => {
|
||||
"Nature is retained; mechanism is Unspecified and supertype is absent."
|
||||
}
|
||||
"DataType" => {
|
||||
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
|
||||
}
|
||||
"DataField" => {
|
||||
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
|
||||
}
|
||||
"Interface" => {
|
||||
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
|
||||
}
|
||||
_ => {
|
||||
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
|
||||
}
|
||||
};
|
||||
body[&layer]["Shared"][&name] = changed;
|
||||
report
|
||||
.semantic_defaults
|
||||
.push(crate::persistence::SemanticDefault {
|
||||
object: e.id,
|
||||
field: "body".into(),
|
||||
description: description.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Current deserialization checks the reused, unchanged outer/typed parts.
|
||||
let _: ElementBody = serde_json::from_value(body.clone())
|
||||
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
|
||||
value["elements"][key.to_string()]["body"] = body;
|
||||
}
|
||||
for (key, r) in &old.relations {
|
||||
if *key != r.id {
|
||||
return Err(at(
|
||||
r.id,
|
||||
"id",
|
||||
"identity",
|
||||
"Relation map key differs from its identity.",
|
||||
));
|
||||
}
|
||||
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
|
||||
return Err(at(
|
||||
r.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Relation constructor was not part of schema3.",
|
||||
));
|
||||
}
|
||||
}
|
||||
value["schema_version"] = 4.into();
|
||||
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
|
||||
let mut project = normalized.into_current()?;
|
||||
crate::persistence::v4::review_defaults(&project, &mut report);
|
||||
let mut references = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum::<usize>()
|
||||
.saturating_add(allocations.len().saturating_mul(2));
|
||||
limits
|
||||
.check_counts(
|
||||
project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len())
|
||||
.saturating_add(allocations.len()),
|
||||
project.identities().len().saturating_add(allocations.len()),
|
||||
references,
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
for (source, target, order) in allocations {
|
||||
let id = Id::new_v5(
|
||||
&project.id,
|
||||
format!("v2-interface-item:{source}:{target}").as_bytes(),
|
||||
);
|
||||
if !ids.insert(id) {
|
||||
return Err(at(
|
||||
source,
|
||||
"items",
|
||||
"identity_collision",
|
||||
format!(
|
||||
"Deterministic interface allocation {id} already belongs to another object."
|
||||
),
|
||||
));
|
||||
}
|
||||
let name = format!("{} · {}", project.named(source), project.named(target));
|
||||
let alias = project.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}_allocation",
|
||||
project
|
||||
.elements
|
||||
.get(&source)
|
||||
.map_or("interface", |e| e.alias.as_str()),
|
||||
project
|
||||
.elements
|
||||
.get(&target)
|
||||
.map_or("item", |e| e.alias.as_str())
|
||||
),
|
||||
"interface_item_allocation",
|
||||
id,
|
||||
);
|
||||
project.relations.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: alias.clone(),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order: order.try_into().map_err(|_| {
|
||||
at(source, "items", "model_limit", "Too many interface items.")
|
||||
})?,
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
},
|
||||
},
|
||||
);
|
||||
report.generated_interface_allocations.push(
|
||||
crate::persistence::GeneratedInterfaceAllocation {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
alias,
|
||||
order: order as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
let annotations: Vec<_> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
values,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) => Some((e.id, *exchange, values.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (owner, exchange, values) in annotations {
|
||||
let Some(relation) = project.relations.get(&exchange) else {
|
||||
continue;
|
||||
};
|
||||
for (index, value) in values.into_iter().enumerate() {
|
||||
let resolution =
|
||||
crate::data::resolve_legacy_field_path(&project, relation, value.field)
|
||||
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
|
||||
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
|
||||
if !path.is_empty() {
|
||||
references = references.saturating_add(path.len());
|
||||
limits
|
||||
.check_counts(
|
||||
project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len()),
|
||||
project.identities().len(),
|
||||
references,
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.and_then(Element::shared_mut)
|
||||
{
|
||||
values[index].path = path;
|
||||
}
|
||||
}
|
||||
} else if matches!(
|
||||
resolution,
|
||||
crate::data::LegacyFieldPathResolution::Ambiguous
|
||||
) {
|
||||
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::validate_project(&project)?;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TraceBaseline4 {
|
||||
pub oa: TracedProperties,
|
||||
pub sa: TracedProperties,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Trace4 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub oa: Id,
|
||||
pub sa: Id,
|
||||
pub kind: TraceKind,
|
||||
pub baseline: Option<TraceBaseline4>,
|
||||
}
|
||||
impl From<Trace4> for Trace {
|
||||
fn from(old: Trace4) -> Self {
|
||||
Self {
|
||||
id: old.id,
|
||||
alias: old.alias,
|
||||
name: old.name,
|
||||
description: old.description,
|
||||
oa: old.oa,
|
||||
sa: old.sa,
|
||||
kind: old.kind,
|
||||
baseline: old.baseline.map(|baseline| TraceBaseline {
|
||||
oa: baseline.oa,
|
||||
sa: baseline.sa,
|
||||
semantic: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project4 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace4>,
|
||||
}
|
||||
impl Project4 {
|
||||
pub(crate) fn into_current(self) -> Result<Project, FileError> {
|
||||
if self.schema_version != 4 {
|
||||
return Err(FileError::Invalid("Expected schema_version 4.".into()));
|
||||
}
|
||||
Ok(Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
artifact_links: self.artifact_links,
|
||||
traces: self
|
||||
.traces
|
||||
.into_iter()
|
||||
.map(|(id, trace)| (id, trace.into()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
report.semantic_defaults.extend(
|
||||
project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|trace| trace.baseline.is_some())
|
||||
.map(|trace| SemanticDefault {
|
||||
object: trace.id,
|
||||
field: "baseline.semantic".into(),
|
||||
description: "Original prose review preserved; semantic facets remain unreviewed."
|
||||
.into(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(4);
|
||||
review_defaults(&project, &mut report);
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
finish(old.into_current()?, limits)
|
||||
}
|
||||
+184
-16
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 3;
|
||||
pub const SCHEMA_VERSION: u32 = 5;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -265,17 +265,79 @@ pub enum ExchangeItemKind {
|
||||
Information,
|
||||
Material,
|
||||
Energy,
|
||||
Event,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum ExchangeMechanism {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Flow,
|
||||
Event,
|
||||
Operation,
|
||||
SharedData,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum ParameterDirection {
|
||||
#[default]
|
||||
Unspecified,
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
Return,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum FieldStorage {
|
||||
#[default]
|
||||
Value,
|
||||
Reference,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataValue {
|
||||
Boolean(bool),
|
||||
Integer(i64),
|
||||
Real(f64),
|
||||
String(String),
|
||||
Enumeration(String),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum TransmissionProtocol {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Synchronous,
|
||||
Asynchronous,
|
||||
Unicast,
|
||||
Multicast,
|
||||
Broadcast,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AcquisitionProtocol {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Read,
|
||||
Accept,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataTypeDefinition {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration { literals: Vec<String> },
|
||||
Record,
|
||||
Integer {
|
||||
minimum: Option<i64>,
|
||||
maximum: Option<i64>,
|
||||
},
|
||||
Real {
|
||||
minimum: Option<f64>,
|
||||
maximum: Option<f64>,
|
||||
},
|
||||
String {
|
||||
minimum_length: u32,
|
||||
maximum_length: Option<u32>,
|
||||
},
|
||||
Enumeration {
|
||||
literals: Vec<String>,
|
||||
},
|
||||
Record {
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -453,6 +515,8 @@ pub struct LifelineBinding {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MessageValue {
|
||||
#[serde(default)]
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
pub expression: String,
|
||||
}
|
||||
@@ -502,18 +566,25 @@ pub enum OccurrenceBody {
|
||||
pub enum SharedBody {
|
||||
ExchangeItem {
|
||||
kind: ExchangeItemKind,
|
||||
mechanism: ExchangeMechanism,
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
DataType {
|
||||
definition: DataTypeDefinition,
|
||||
default: Option<DataValue>,
|
||||
unit: String,
|
||||
},
|
||||
DataField {
|
||||
type_ref: TypeRef,
|
||||
minimum: u32,
|
||||
maximum: Option<u32>,
|
||||
unit: String,
|
||||
direction: ParameterDirection,
|
||||
storage: FieldStorage,
|
||||
default: Option<Vec<DataValue>>,
|
||||
},
|
||||
Interface {
|
||||
items: Vec<Id>,
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
Port {
|
||||
contract: PortContract,
|
||||
@@ -672,17 +743,27 @@ impl ElementBody {
|
||||
match k {
|
||||
Kind::ExchangeItem => SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
supertype: None,
|
||||
},
|
||||
Kind::DataType => SharedBody::DataType {
|
||||
definition: DataTypeDefinition::String,
|
||||
definition: DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
Kind::DataField => SharedBody::DataField {
|
||||
type_ref: TypeRef::String,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
},
|
||||
Kind::Interface => SharedBody::Interface { items: vec![] },
|
||||
Kind::Interface => SharedBody::Interface { supertype: None },
|
||||
// Port creation requires an explicit contract and owner.
|
||||
Kind::Port => return None,
|
||||
Kind::Scenario => SharedBody::Scenario {
|
||||
@@ -752,6 +833,8 @@ impl SharedBody {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
InterfaceItemAllocation,
|
||||
DataAssociation,
|
||||
Allocation,
|
||||
PortAllocation,
|
||||
Interaction,
|
||||
@@ -775,7 +858,9 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 21] = [
|
||||
pub const ALL: [Self; 23] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
Self::Allocation,
|
||||
Self::PortAllocation,
|
||||
Self::Interaction,
|
||||
@@ -800,6 +885,8 @@ impl RelationKind {
|
||||
];
|
||||
pub fn permits(self, from: Kind, to: Kind) -> bool {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
|
||||
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
|
||||
Self::Allocation => from.is_performer() && to.is_work(),
|
||||
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
|
||||
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
|
||||
@@ -855,6 +942,8 @@ impl RelationKind {
|
||||
}
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
|
||||
Self::DataAssociation => "Record type → record type",
|
||||
Self::Allocation => "Performer → leaf activity/function",
|
||||
Self::PortAllocation => "Component port → allocated function port",
|
||||
Self::Interaction => "OA activity → OA activity",
|
||||
@@ -886,6 +975,8 @@ impl RelationKind {
|
||||
impl fmt::Display for RelationKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InterfaceItemAllocation => "Interface item allocation",
|
||||
Self::DataAssociation => "Data association",
|
||||
Self::Allocation => "Allocation",
|
||||
Self::PortAllocation => "Port allocation",
|
||||
Self::Interaction => "Interaction",
|
||||
@@ -922,6 +1013,25 @@ pub struct Relation {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum RelationBody {
|
||||
InterfaceItemAllocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
order: u32,
|
||||
transmission: TransmissionProtocol,
|
||||
acquisition: AcquisitionProtocol,
|
||||
},
|
||||
DataAssociation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
source_role: String,
|
||||
target_role: String,
|
||||
source_minimum: u32,
|
||||
source_maximum: Option<u32>,
|
||||
target_minimum: u32,
|
||||
target_maximum: Option<u32>,
|
||||
source_navigable: bool,
|
||||
target_navigable: bool,
|
||||
},
|
||||
Allocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1039,6 +1149,8 @@ impl Relation {
|
||||
impl RelationBody {
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
|
||||
Self::DataAssociation { .. } => RelationKind::DataAssociation,
|
||||
Self::Allocation { .. } => RelationKind::Allocation,
|
||||
Self::PortAllocation { .. } => RelationKind::PortAllocation,
|
||||
Self::Interaction { .. } => RelationKind::Interaction,
|
||||
@@ -1064,7 +1176,9 @@ impl RelationBody {
|
||||
}
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::Allocation { source, target }
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
@@ -1089,7 +1203,9 @@ impl RelationBody {
|
||||
}
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::Allocation { source, target }
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
@@ -1125,6 +1241,25 @@ impl RelationBody {
|
||||
}
|
||||
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
|
||||
match kind {
|
||||
RelationKind::InterfaceItemAllocation => Self::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order: 0,
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
},
|
||||
RelationKind::DataAssociation => Self::DataAssociation {
|
||||
source,
|
||||
target,
|
||||
source_role: String::new(),
|
||||
target_role: String::new(),
|
||||
source_minimum: 0,
|
||||
source_maximum: None,
|
||||
target_minimum: 0,
|
||||
target_maximum: None,
|
||||
source_navigable: false,
|
||||
target_navigable: false,
|
||||
},
|
||||
RelationKind::Allocation => Self::Allocation { source, target },
|
||||
RelationKind::PortAllocation => Self::PortAllocation { source, target },
|
||||
RelationKind::Interaction => Self::Interaction {
|
||||
@@ -1277,6 +1412,34 @@ pub struct EdgeLabel {
|
||||
pub offset: Position,
|
||||
}
|
||||
pub const MAX_ROUTE_POINTS: usize = 128;
|
||||
/// Presentation side; independent of the semantic port direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
impl PortAnchorSide {
|
||||
pub fn normal(self) -> Position {
|
||||
match self {
|
||||
Self::Left => Position { x: -1., y: 0. },
|
||||
Self::Right => Position { x: 1., y: 0. },
|
||||
Self::Top => Position { x: 0., y: -1. },
|
||||
Self::Bottom => Position { x: 0., y: 1. },
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Manual position of a semantic child port on one owner placement.
|
||||
/// Absence from the placement's list selects automatic positioning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortAnchor {
|
||||
pub port: Id,
|
||||
pub side: PortAnchorSide,
|
||||
/// Fraction of the complete edge, from top (vertical) or left (horizontal).
|
||||
pub offset: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NodePlacement {
|
||||
@@ -1286,6 +1449,8 @@ pub struct NodePlacement {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub port_anchors: Vec<PortAnchor>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ArtifactRelation {
|
||||
@@ -1327,13 +1492,15 @@ pub struct TracedProperties {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TraceBaseline {
|
||||
pub oa: TracedProperties,
|
||||
pub sa: TracedProperties,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub semantic: Option<crate::semantic::SemanticBaseline>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Trace {
|
||||
pub id: Id,
|
||||
@@ -1673,6 +1840,7 @@ impl Project {
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
if let Some(d) = self.diagrams.get_mut(&diagram) {
|
||||
d.nodes.insert(id, node);
|
||||
|
||||
@@ -5,6 +5,10 @@ use thiserror::Error;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
#[path = "migration_v3/mod.rs"]
|
||||
pub(crate) mod v3;
|
||||
#[path = "migration_v4.rs"]
|
||||
pub(crate) mod v4;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -15,6 +19,50 @@ pub struct MigrationReport {
|
||||
pub generated_endpoints: Vec<GeneratedEndpoint>,
|
||||
pub orientation_defaults: Vec<OrientationDefault>,
|
||||
pub required_resolutions: Vec<Id>,
|
||||
#[serde(default)]
|
||||
pub semantic_defaults: Vec<SemanticDefault>,
|
||||
#[serde(default)]
|
||||
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
|
||||
#[serde(default)]
|
||||
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
|
||||
}
|
||||
impl MigrationReport {
|
||||
pub(crate) fn data_upgrade(from_schema: u32) -> Self {
|
||||
Self {
|
||||
from_schema,
|
||||
to_schema: SCHEMA_VERSION,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
semantic_defaults: vec![],
|
||||
generated_interface_allocations: vec![],
|
||||
unresolved_message_values: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticDefault {
|
||||
pub object: Id,
|
||||
pub field: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GeneratedInterfaceAllocation {
|
||||
pub id: Id,
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub alias: String,
|
||||
pub order: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UnresolvedMessageValue {
|
||||
pub occurrence: Id,
|
||||
pub field: Id,
|
||||
pub reason: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -101,13 +149,15 @@ pub fn decode_with_report_limited(
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => DecodeResult {
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+55
-45
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
|
||||
pub outgoing_items: Vec<Id>,
|
||||
}
|
||||
|
||||
/// R6 specialization has one explicit seam; current models support identity only.
|
||||
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
|
||||
carried == declared
|
||||
/// A specialized carried item satisfies a declared base item.
|
||||
pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
|
||||
crate::data::item_satisfies(project, carried, declared)
|
||||
}
|
||||
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
|
||||
carried.iter().all(|item| {
|
||||
@@ -130,13 +130,7 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
|
||||
Some(
|
||||
interfaces
|
||||
.into_iter()
|
||||
.filter_map(|id| p.elements.get(&id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.flat_map(|id| crate::data::interface_items(p, id))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -847,6 +841,11 @@ fn legacy_allows(e: &Element, outgoing: bool) -> bool {
|
||||
matches!(e.shared(), Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, .. }) }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out })
|
||||
}
|
||||
fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mut Vec<Issue>) {
|
||||
// Every carried-item constraint is a subset check. An empty exchange cannot
|
||||
// violate one, so endpoint feasibility need not scan all interface roles.
|
||||
if r.body.items().is_empty() {
|
||||
return;
|
||||
}
|
||||
match port.shared() {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
@@ -898,14 +897,10 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
|
||||
..
|
||||
}) => {
|
||||
let mut allowed = declared_interface_items(p, port.id);
|
||||
if let Some(SharedBody::Interface { items }) = legacy
|
||||
.as_ref()
|
||||
.and_then(|l| l.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
{
|
||||
if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
|
||||
let items = crate::data::interface_items(p, interface);
|
||||
if r.kind() == RelationKind::BoundaryExchange
|
||||
&& !items_satisfy(p, r.body.items(), items)
|
||||
&& !items_satisfy(p, r.body.items(), &items)
|
||||
{
|
||||
error(
|
||||
out,
|
||||
@@ -1060,34 +1055,6 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
|
||||
"This boundary exchange retains unresolved generic port semantics.",
|
||||
);
|
||||
}
|
||||
for (port, peer) in [(a, b), (b, a)] {
|
||||
if !matches!(
|
||||
port_contract(port),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for required in p.relations.values().filter(|role| {
|
||||
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
|
||||
}) {
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& role.target() == required.target()
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
required.id,
|
||||
"unsatisfied_interface_requirement",
|
||||
"target",
|
||||
"The connected partner has not declared this required interface as provided.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RelationBody::PortAllocation { source, target } => {
|
||||
let (Some(component), Some(function)) =
|
||||
@@ -1167,6 +1134,49 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Whole-model advisory checks. Missing peer declarations are warnings, so they
|
||||
/// must not make endpoint feasibility scan the relation graph per candidate.
|
||||
pub(crate) fn validate_boundary_interface_requirements(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
let RelationBody::BoundaryExchange { source, target, .. } = &r.body else {
|
||||
return;
|
||||
};
|
||||
let (Some(a), Some(b)) = (p.elements.get(source), p.elements.get(target)) else {
|
||||
return;
|
||||
};
|
||||
for (port, peer) in [(a, b), (b, a)] {
|
||||
if !matches!(
|
||||
port_contract(port),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for required in p.relations.values().filter(|role| {
|
||||
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
|
||||
}) {
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& crate::data::interface_satisfies(p, role.target(), required.target())
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
required.id,
|
||||
"unsatisfied_interface_requirement",
|
||||
"target",
|
||||
"The connected partner has not declared this required interface as provided.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
|
||||
else {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub use crate::model::PortAnchorSide;
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod port_layout;
|
||||
pub use port_layout::{port_anchor_offset, port_anchor_position};
|
||||
#[path = "projection/state.rs"]
|
||||
mod state;
|
||||
pub(crate) use state::node_size as state_node_size;
|
||||
@@ -36,34 +39,31 @@ pub struct ProjectedPort {
|
||||
pub position: Position,
|
||||
pub side: PortAnchorSide,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
/// Semantic fallback for an unconnected port. Neutral ports have no preferred
|
||||
/// side; the projected view resolves their placement using peers and capacity.
|
||||
/// A preferred side never constrains an authored anchor.
|
||||
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
|
||||
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
|
||||
return None;
|
||||
};
|
||||
Some(match contract {
|
||||
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
|
||||
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
|
||||
match contract {
|
||||
PortContract::FunctionInput { .. } => Some(PortAnchorSide::Left),
|
||||
PortContract::FunctionOutput { .. } => Some(PortAnchorSide::Right),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
} => PortAnchorSide::Left,
|
||||
} => Some(PortAnchorSide::Left),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
} => PortAnchorSide::Right,
|
||||
} => Some(PortAnchorSide::Right),
|
||||
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
|
||||
Some(PortDirection::In) => PortAnchorSide::Left,
|
||||
Some(PortDirection::Out) => PortAnchorSide::Right,
|
||||
_ => PortAnchorSide::Bottom,
|
||||
Some(PortDirection::In) => Some(PortAnchorSide::Left),
|
||||
Some(PortDirection::Out) => Some(PortAnchorSide::Right),
|
||||
_ => None,
|
||||
},
|
||||
_ => PortAnchorSide::Bottom,
|
||||
})
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ProjectedView {
|
||||
@@ -71,38 +71,11 @@ pub struct ProjectedView {
|
||||
pub edges: Vec<ProjectedEdge>,
|
||||
pub ports: Vec<ProjectedPort>,
|
||||
}
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
|
||||
.into_iter()
|
||||
.map(|side| {
|
||||
p.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(side))
|
||||
.count()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if count > 0 {
|
||||
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
||||
}
|
||||
let bottom = p
|
||||
.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
|
||||
.count();
|
||||
if bottom > 0 {
|
||||
size.width = size.width.max(28. * (bottom + 1) as f32);
|
||||
}
|
||||
size
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, size: Size) -> Size {
|
||||
port_layout::minimum_size(p, element, size)
|
||||
}
|
||||
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
|
||||
let count = p
|
||||
.children(element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count();
|
||||
if count == 0 {
|
||||
24.
|
||||
} else {
|
||||
28. * count as f32 + 24.
|
||||
}
|
||||
pub(crate) fn port_footer(_p: &Project, _element: Id) -> f32 {
|
||||
24.
|
||||
}
|
||||
|
||||
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
||||
@@ -323,39 +296,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
}
|
||||
nodes.sort_by_key(|n| (n.depth, n.id));
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
// Boundary vertices are pinned to their containing State/Machine after
|
||||
// containment has been measured. They must not expand that boundary.
|
||||
if d.projection.state_machine().is_some()
|
||||
&& p.elements
|
||||
.get(&child.element)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = child
|
||||
.visual_parent
|
||||
.and_then(|id| nodes.iter().position(|n| n.id == id))
|
||||
{
|
||||
let footer = port_footer(p, nodes[parent].element);
|
||||
let header = if d.projection.state_machine().is_some() {
|
||||
state_header_height(p, nodes[parent].element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
let p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
|
||||
p.position.x = p.position.x.min(child.position.x - 24.);
|
||||
// Reserve two readable header lines above the first nested card.
|
||||
p.position.y = p.position.y.min(child.position.y - header);
|
||||
p.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
enclose_with_headers(p, &mut nodes, d.projection.state_machine().is_some());
|
||||
if d.projection.state_machine().is_some() {
|
||||
state::snap_boundaries(p, &mut nodes);
|
||||
}
|
||||
@@ -381,58 +322,24 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
None
|
||||
};
|
||||
let mut ports = Vec::new();
|
||||
for node in &nodes {
|
||||
let mut children: Vec<_> = p
|
||||
.children(node.element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
children.sort_by_key(|e| (&e.alias, e.id));
|
||||
for anchor_side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let side: Vec<_> = children
|
||||
.iter()
|
||||
.filter(|e| port_anchor_side(e) == Some(anchor_side))
|
||||
.collect();
|
||||
for (index, port) in side.iter().enumerate() {
|
||||
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
||||
let y = if p.elements[&node.element].kind().is_performer()
|
||||
|| nodes
|
||||
.iter()
|
||||
.any(|child| child.visual_parent == Some(node.id))
|
||||
{
|
||||
node.position.y + node.size.height - 24. - index as f32 * 28.
|
||||
} else {
|
||||
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
|
||||
};
|
||||
let position = match anchor_side {
|
||||
PortAnchorSide::Left => Position {
|
||||
x: node.position.x,
|
||||
y,
|
||||
},
|
||||
PortAnchorSide::Right => Position {
|
||||
x: node.position.x + node.size.width,
|
||||
y,
|
||||
},
|
||||
PortAnchorSide::Bottom => Position {
|
||||
x: node.position.x + node.size.width * fraction,
|
||||
y: node.position.y + node.size.height,
|
||||
},
|
||||
};
|
||||
ports.push(ProjectedPort {
|
||||
element: port.id,
|
||||
owner: node.id,
|
||||
position,
|
||||
side: anchor_side,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let visible_ports: BTreeSet<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.kind() == Kind::Port
|
||||
&& e.parent
|
||||
.and_then(|id| placed.get(&id))
|
||||
.is_some_and(|id| visible.contains(id))
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let mut edges = Vec::new();
|
||||
for r in p.relations.values() {
|
||||
// Interface allocations are selectable nested dictionary rows. Their
|
||||
// independently identified contracts do not add noisy diagram arrows.
|
||||
if r.kind() == RelationKind::InterfaceItemAllocation {
|
||||
continue;
|
||||
}
|
||||
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
|
||||
continue;
|
||||
}
|
||||
@@ -468,7 +375,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
|
||||
&& (source != target
|
||||
|| (d.projection.state_machine().is_some()
|
||||
|| ((d.projection.state_machine().is_some()
|
||||
|| r.kind() == RelationKind::DataAssociation)
|
||||
&& r.source() == r.target()
|
||||
&& nodes
|
||||
.iter()
|
||||
@@ -494,10 +402,10 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
} | RelationBody::DataAssociation { .. }
|
||||
),
|
||||
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
source_port: source_port.filter(|id| visible_ports.contains(id)),
|
||||
target_port: target_port.filter(|id| visible_ports.contains(id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -518,9 +426,53 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dedicated state views contain state symbols, not semantic component ports.
|
||||
// Port layout would re-enclose already snapped boundary vertices.
|
||||
let ports = if d.projection.state_machine().is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
port_layout::layout(p, d, &mut nodes, &edges)
|
||||
};
|
||||
ProjectedView {
|
||||
nodes,
|
||||
edges,
|
||||
ports,
|
||||
}
|
||||
}
|
||||
|
||||
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
|
||||
enclose_with_headers(p, nodes, false);
|
||||
}
|
||||
fn enclose_with_headers(p: &Project, nodes: &mut [ProjectedNode], state_view: bool) {
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
if state_view
|
||||
&& p.elements
|
||||
.get(&child.element)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = child
|
||||
.visual_parent
|
||||
.and_then(|id| nodes.iter().position(|n| n.id == id))
|
||||
{
|
||||
let footer = port_footer(p, nodes[parent].element);
|
||||
let header = if state_view {
|
||||
state_header_height(p, nodes[parent].element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
let p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
|
||||
p.position.x = p.position.x.min(child.position.x - 24.);
|
||||
// Reserve two readable header lines above the first nested card.
|
||||
p.position.y = p.position.y.min(child.position.y - header);
|
||||
p.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Presentation-only port anchors. Automatic choices depend on owner geometry,
|
||||
//! never on routed elbows, so routing cannot feed back into layout.
|
||||
use super::*;
|
||||
|
||||
const GAP: f32 = 28.;
|
||||
const CORNER: f32 = 18.;
|
||||
const SIDES: [PortAnchorSide; 4] = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
];
|
||||
|
||||
pub fn port_anchor_position(node: &ProjectedNode, side: PortAnchorSide, offset: f32) -> Position {
|
||||
let (x, y) = match side {
|
||||
PortAnchorSide::Left => (0., node.size.height * offset),
|
||||
PortAnchorSide::Right => (node.size.width, node.size.height * offset),
|
||||
PortAnchorSide::Top => (node.size.width * offset, 0.),
|
||||
PortAnchorSide::Bottom => (node.size.width * offset, node.size.height),
|
||||
};
|
||||
Position {
|
||||
x: node.position.x + x,
|
||||
y: node.position.y + y,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn port_anchor_offset(node: &ProjectedNode, side: PortAnchorSide, position: Position) -> f32 {
|
||||
let value = match side {
|
||||
PortAnchorSide::Left | PortAnchorSide::Right => {
|
||||
(position.y - node.position.y) / node.size.height
|
||||
}
|
||||
PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
||||
(position.x - node.position.x) / node.size.width
|
||||
}
|
||||
};
|
||||
value.clamp(0., 1.)
|
||||
}
|
||||
|
||||
fn center(node: &ProjectedNode) -> Position {
|
||||
Position {
|
||||
x: node.position.x + node.size.width / 2.,
|
||||
y: node.position.y + node.size.height / 2.,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Choice {
|
||||
port: Id,
|
||||
side: PortAnchorSide,
|
||||
automatic_side: PortAnchorSide,
|
||||
manual: Option<f32>,
|
||||
peer: Option<Position>,
|
||||
}
|
||||
|
||||
fn facing(node: &ProjectedNode, peer: Position) -> Option<PortAnchorSide> {
|
||||
let c = center(node);
|
||||
let dx = (peer.x - c.x) / node.size.width;
|
||||
let dy = (peer.y - c.y) / node.size.height;
|
||||
if dx.abs().max(dy.abs()) < 0.001 {
|
||||
return None;
|
||||
}
|
||||
if dx.abs() >= dy.abs() {
|
||||
Some(if dx < 0. {
|
||||
PortAnchorSide::Left
|
||||
} else {
|
||||
PortAnchorSide::Right
|
||||
})
|
||||
} else {
|
||||
Some(if dy < 0. {
|
||||
PortAnchorSide::Top
|
||||
} else {
|
||||
PortAnchorSide::Bottom
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_exit(
|
||||
node: &ProjectedNode,
|
||||
side: PortAnchorSide,
|
||||
peers: &BTreeSet<Id>,
|
||||
by_id: &BTreeMap<Id, &ProjectedNode>,
|
||||
) -> bool {
|
||||
let a = port_anchor_position(node, side, 0.5);
|
||||
let normal = side.normal();
|
||||
let b = Position {
|
||||
x: a.x + normal.x * crate::routing::PORT_STUB,
|
||||
y: a.y + normal.y * crate::routing::PORT_STUB,
|
||||
};
|
||||
peers.iter().all(|peer| {
|
||||
let other = by_id[peer];
|
||||
// A containing endpoint's body is traversable, just as in routing.
|
||||
let mut ancestor = node.visual_parent;
|
||||
for _ in 0..by_id.len() {
|
||||
let Some(id) = ancestor else {
|
||||
break;
|
||||
};
|
||||
if id == *peer {
|
||||
return true;
|
||||
}
|
||||
ancestor = by_id.get(&id).and_then(|n| n.visual_parent);
|
||||
}
|
||||
let padding = crate::routing::CLEARANCE;
|
||||
let left = other.position.x - padding;
|
||||
let right = other.position.x + other.size.width + padding;
|
||||
let top = other.position.y - padding;
|
||||
let bottom = other.position.y + other.size.height + padding;
|
||||
if a.x == b.x {
|
||||
!(a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top)
|
||||
} else {
|
||||
!(a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Peers {
|
||||
x: f64,
|
||||
y: f64,
|
||||
count: usize,
|
||||
owners: BTreeSet<Id>,
|
||||
}
|
||||
impl Peers {
|
||||
fn center(&self) -> Position {
|
||||
Position {
|
||||
x: (self.x / self.count as f64) as f32,
|
||||
y: (self.y / self.count as f64) as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn minimum_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
|
||||
let mut neutral = 0;
|
||||
for port in p.children(element).filter(|p| p.kind() == Kind::Port) {
|
||||
if let Some(side) = port_anchor_side(port) {
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
} else {
|
||||
neutral += 1;
|
||||
}
|
||||
}
|
||||
for _ in 0..neutral {
|
||||
let side = *SIDES.iter().min_by_key(|s| counts[s]).unwrap();
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
}
|
||||
let vertical = counts[&PortAnchorSide::Left].max(counts[&PortAnchorSide::Right]);
|
||||
let horizontal = counts[&PortAnchorSide::Top].max(counts[&PortAnchorSide::Bottom]);
|
||||
if vertical > 0 {
|
||||
size.height = size.height.max(76. + GAP * (vertical + 1) as f32);
|
||||
}
|
||||
if horizontal > 0 {
|
||||
size.width = size.width.max(GAP * (horizontal + 1) as f32);
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
pub(super) fn layout(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
nodes: &mut [ProjectedNode],
|
||||
edges: &[ProjectedEdge],
|
||||
) -> Vec<ProjectedPort> {
|
||||
let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new();
|
||||
for edge in edges {
|
||||
// Port allocation is implementation traceability, not a communication
|
||||
// peer. It must not turn an external boundary port toward its own work.
|
||||
if !p.relations.get(&edge.id).is_some_and(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for (owner, port, other) in [
|
||||
(edge.source, edge.source_port, edge.target),
|
||||
(edge.target, edge.target_port, edge.source),
|
||||
] {
|
||||
if let (Some(port), Some(other)) = (port, by_id.get(&other)) {
|
||||
let c = center(other);
|
||||
let entry = peers.entry((owner, port)).or_default();
|
||||
entry.x += f64::from(c.x);
|
||||
entry.y += f64::from(c.y);
|
||||
entry.count += 1;
|
||||
entry.owners.insert(other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<&Element>> = BTreeMap::new();
|
||||
for port in p.elements.values().filter(|e| e.kind() == Kind::Port) {
|
||||
if let Some(parent) = port.parent {
|
||||
children.entry(parent).or_default().push(port);
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::<Id, Vec<Choice>>::new();
|
||||
for node in nodes.iter() {
|
||||
let anchors: BTreeMap<_, _> = d.nodes[&node.id]
|
||||
.port_anchors
|
||||
.iter()
|
||||
.map(|a| (a.port, a))
|
||||
.collect();
|
||||
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
|
||||
// Compute the automatic capacity independently of manual overrides.
|
||||
// Moving a port must not resize its owner or flip unrelated ports.
|
||||
for port in children.get(&node.element).into_iter().flatten() {
|
||||
let connected = peers.get(&(node.id, port.id));
|
||||
let peer = connected.map(Peers::center);
|
||||
let clear =
|
||||
|side| connected.is_none_or(|peers| clear_exit(node, side, &peers.owners, &by_id));
|
||||
let anchor = anchors.get(&port.id);
|
||||
let side = {
|
||||
peer.and_then(|v| facing(node, v))
|
||||
.filter(|side| clear(*side))
|
||||
}
|
||||
.or_else(|| port_anchor_side(port).filter(|side| clear(*side)))
|
||||
.or_else(|| {
|
||||
SIDES
|
||||
.iter()
|
||||
.filter(|side| clear(**side))
|
||||
.min_by_key(|s| counts[s])
|
||||
.copied()
|
||||
})
|
||||
.unwrap_or_else(|| *SIDES.iter().min_by_key(|s| counts[s]).unwrap());
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
choices.entry(node.id).or_default().push(Choice {
|
||||
port: port.id,
|
||||
side: anchor.map_or(side, |a| a.side),
|
||||
automatic_side: side,
|
||||
manual: anchor.map(|a| a.offset),
|
||||
peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
// One capacity pass followed by semantic enclosure. Side choices stay frozen
|
||||
// throughout this pass; expanded parents cannot cause layout oscillation.
|
||||
for node in nodes.iter_mut() {
|
||||
for side in SIDES {
|
||||
let count = choices
|
||||
.get(&node.id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| c.automatic_side == side)
|
||||
.count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32;
|
||||
match side {
|
||||
PortAnchorSide::Left | PortAnchorSide::Right => {
|
||||
node.size.height = node.size.height.max(minimum)
|
||||
}
|
||||
PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
||||
node.size.width = node.size.width.max(minimum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
super::enclose(p, nodes);
|
||||
let mut result = Vec::new();
|
||||
for node in nodes.iter() {
|
||||
let Some(choices) = choices.get(&node.id) else {
|
||||
continue;
|
||||
};
|
||||
for side in SIDES {
|
||||
let mut automatic: Vec<_> = choices
|
||||
.iter()
|
||||
.filter(|c| c.side == side && c.manual.is_none())
|
||||
.collect();
|
||||
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
||||
automatic.sort_by(|a, b| {
|
||||
let axis = |c: &Choice| c.peer.map(|p| if horizontal { p.x } else { p.y });
|
||||
match (axis(a), axis(b)) {
|
||||
(Some(a), Some(b)) => a.total_cmp(&b),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
.then_with(|| a.port.cmp(&b.port))
|
||||
});
|
||||
let length = if horizontal {
|
||||
node.size.width
|
||||
} else {
|
||||
node.size.height
|
||||
};
|
||||
let mut fixed = Vec::new();
|
||||
for choice in choices.iter().filter(|c| c.side == side) {
|
||||
if let Some(offset) = choice.manual {
|
||||
fixed.push(offset * length);
|
||||
result.push(ProjectedPort {
|
||||
element: choice.port,
|
||||
owner: node.id,
|
||||
side,
|
||||
position: port_anchor_position(node, side, offset),
|
||||
});
|
||||
}
|
||||
}
|
||||
let positions = automatic_positions(length, &fixed, automatic.len());
|
||||
for (choice, distance) in automatic.into_iter().zip(positions) {
|
||||
result.push(ProjectedPort {
|
||||
element: choice.port,
|
||||
owner: node.id,
|
||||
side,
|
||||
position: port_anchor_position(node, side, distance / length),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Allocate free intervals around authored anchors. If manual anchors are too
|
||||
/// crowded to satisfy the desired gap, retain them and degrade spacing evenly.
|
||||
fn automatic_positions(length: f32, fixed: &[f32], count: usize) -> Vec<f32> {
|
||||
if count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut fixed = fixed.to_vec();
|
||||
fixed.sort_by(f32::total_cmp);
|
||||
let mut gap = GAP;
|
||||
let intervals = loop {
|
||||
let mut start = CORNER.min(length / 2.);
|
||||
let end = (length - CORNER).max(start);
|
||||
let mut free = Vec::new();
|
||||
for &anchor in &fixed {
|
||||
if anchor - gap >= start {
|
||||
free.push((start, (anchor - gap).min(end)));
|
||||
}
|
||||
start = start.max(anchor + gap);
|
||||
if start > end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if start <= end {
|
||||
free.push((start, end));
|
||||
}
|
||||
free.retain(|(a, b)| b >= a);
|
||||
let capacity: usize = free
|
||||
.iter()
|
||||
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
|
||||
.sum();
|
||||
if capacity >= count {
|
||||
break free;
|
||||
}
|
||||
gap /= 2.;
|
||||
if gap < 0.001 {
|
||||
return (0..count)
|
||||
.map(|i| length * (i + 1) as f32 / (count + 1) as f32)
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
let capacities: Vec<_> = intervals
|
||||
.iter()
|
||||
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
|
||||
.collect();
|
||||
let total: usize = capacities.iter().sum();
|
||||
let mut allocated: Vec<_> = capacities
|
||||
.iter()
|
||||
.map(|capacity| (count as u128 * *capacity as u128 / total as u128) as usize)
|
||||
.collect();
|
||||
let mut remainder: Vec<_> = capacities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, capacity)| (count as u128 * *capacity as u128 % total as u128, i))
|
||||
.collect();
|
||||
remainder.sort_by(|(a, i), (b, j)| b.cmp(a).then_with(|| i.cmp(j)));
|
||||
let unassigned = count - allocated.iter().sum::<usize>();
|
||||
for (_, index) in remainder.into_iter().take(unassigned) {
|
||||
allocated[index] += 1;
|
||||
}
|
||||
intervals
|
||||
.into_iter()
|
||||
.zip(allocated)
|
||||
.flat_map(|((a, b), count)| {
|
||||
(0..count).map(move |i| {
|
||||
if count == 1 {
|
||||
(a + b) / 2.
|
||||
} else {
|
||||
a + (b - a) * i as f32 / (count - 1) as f32
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -33,8 +33,13 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
type_ref: TypeRef::Named(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Interface { items } => {
|
||||
for id in items {
|
||||
SharedBody::Interface { supertype }
|
||||
| SharedBody::ExchangeItem { supertype, .. }
|
||||
| SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
..
|
||||
} => {
|
||||
if let Some(id) = supertype {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +88,9 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(id);
|
||||
}
|
||||
for value in values {
|
||||
for id in &mut value.path {
|
||||
visit(id);
|
||||
}
|
||||
visit(&mut value.field);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +200,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
@@ -209,6 +222,13 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
if t.sa == old {
|
||||
t.sa = new;
|
||||
}
|
||||
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
|
||||
crate::semantic::visit_semantic_references(baseline, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
@@ -356,6 +376,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
node.port_anchors
|
||||
.retain(|anchor| !removed.contains(&anchor.port));
|
||||
}
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
@@ -368,7 +392,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Interface { supertype }
|
||||
| SharedBody::ExchangeItem { supertype, .. }
|
||||
| SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
..
|
||||
} => clear(supertype),
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => items.retain(|id| !removed.contains(id)),
|
||||
@@ -404,7 +433,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
clear(reply_to);
|
||||
values.retain(|value| !removed.contains(&value.field));
|
||||
values.retain(|value| {
|
||||
!removed.contains(&value.field)
|
||||
&& value.path.iter().all(|id| !removed.contains(id))
|
||||
});
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
clear(action);
|
||||
@@ -476,20 +508,37 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
}) => p
|
||||
.relations
|
||||
.get(id)
|
||||
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
|
||||
}) => p.relations.get(id).map(|r| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let retained: Vec<_> = values
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|
||||
|| (value.path.is_empty()
|
||||
&& crate::data::carried_field_paths(p, r)
|
||||
.iter()
|
||||
.any(|f| f.field == value.field))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
(e.id, retained)
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, fields) in allowed {
|
||||
for (id, retained) in allowed {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
|
||||
{
|
||||
values.retain(|value| fields.contains(&value.field));
|
||||
*values = retained;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+260
-69
@@ -1,7 +1,7 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, PortAnchorSide, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry {
|
||||
pub bounds: RoutingBounds,
|
||||
}
|
||||
|
||||
/// Derived labels and navigation markers for the two named association ends.
|
||||
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AssociationEndGeometry {
|
||||
pub source_end: bool,
|
||||
pub label: EdgeLabelGeometry,
|
||||
pub tip: Position,
|
||||
pub inward: Position,
|
||||
pub navigable: bool,
|
||||
}
|
||||
|
||||
pub fn association_end_geometry(
|
||||
relation: &Relation,
|
||||
routed: &RoutedEdge,
|
||||
) -> Vec<AssociationEndGeometry> {
|
||||
let RelationBody::DataAssociation {
|
||||
source_navigable,
|
||||
target_navigable,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
|
||||
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
|
||||
let Some(tip) = (if source_end {
|
||||
routed.points.first()
|
||||
} else {
|
||||
routed.points.last()
|
||||
})
|
||||
.copied() else {
|
||||
continue;
|
||||
};
|
||||
let inward = if source_end {
|
||||
routed.points.iter().find(|p| **p != tip)
|
||||
} else {
|
||||
routed.points.iter().rev().find(|p| **p != tip)
|
||||
};
|
||||
let Some(inward) = inward.copied() else {
|
||||
continue;
|
||||
};
|
||||
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
|
||||
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
|
||||
let role = if role.chars().count() > 32 {
|
||||
role.chars()
|
||||
.take(31)
|
||||
.chain(std::iter::once('…'))
|
||||
.collect::<String>()
|
||||
} else {
|
||||
role.to_owned()
|
||||
};
|
||||
format!("{role} [{multiplicity}")
|
||||
} else {
|
||||
full
|
||||
};
|
||||
let size = Size {
|
||||
width: text.chars().count() as f32 * 7.0 + 12.0,
|
||||
height: 22.0,
|
||||
};
|
||||
let dx = inward.x - tip.x;
|
||||
let dy = inward.y - tip.y;
|
||||
let length = dx.hypot(dy);
|
||||
let direction = pos(dx / length, dy / length);
|
||||
// One normal sign puts opposite endpoints of a straight edge on
|
||||
// opposite sides because their inward tangent directions are reversed.
|
||||
let along =
|
||||
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
|
||||
let normal =
|
||||
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
|
||||
let mut center = pos(
|
||||
tip.x + direction.x * along - direction.y * normal,
|
||||
tip.y + direction.y * along + direction.x * normal,
|
||||
);
|
||||
if let Some(other) = ends.first()
|
||||
&& (center.x - other.label.center.x).abs()
|
||||
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
|
||||
&& (center.y - other.label.center.y).abs()
|
||||
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
|
||||
{
|
||||
// Same-side anchors on a self-loop can still be close. Separate
|
||||
// their complete bounds along this end's normal in one step.
|
||||
let perpendicular = pos(-direction.y, direction.x);
|
||||
let distance = (center.x - other.label.center.x) * perpendicular.x
|
||||
+ (center.y - other.label.center.y) * perpendicular.y;
|
||||
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
|
||||
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
|
||||
/ 2.0
|
||||
+ 8.0;
|
||||
let shift = if distance < 0.0 {
|
||||
-clearance - distance
|
||||
} else {
|
||||
clearance - distance
|
||||
};
|
||||
center.x += perpendicular.x * shift;
|
||||
center.y += perpendicular.y * shift;
|
||||
}
|
||||
ends.push(AssociationEndGeometry {
|
||||
source_end,
|
||||
label: EdgeLabelGeometry {
|
||||
text,
|
||||
fraction: if source_end { 0.0 } else { 1.0 },
|
||||
anchor: tip,
|
||||
center,
|
||||
bounds: RoutingBounds {
|
||||
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
|
||||
size,
|
||||
},
|
||||
},
|
||||
tip,
|
||||
inward,
|
||||
navigable,
|
||||
});
|
||||
}
|
||||
ends
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
@@ -264,11 +380,7 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
.iter()
|
||||
.find(|p| p.element == id && p.owner == node.id)
|
||||
}) {
|
||||
let normal = match port.side {
|
||||
PortAnchorSide::Left => pos(-1.0, 0.0),
|
||||
PortAnchorSide::Right => pos(1.0, 0.0),
|
||||
PortAnchorSide::Bottom => pos(0.0, 1.0),
|
||||
};
|
||||
let normal = port.side.normal();
|
||||
return vec![Endpoint {
|
||||
point: port.position,
|
||||
stub: pos(
|
||||
@@ -319,9 +431,10 @@ pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView)
|
||||
}
|
||||
|
||||
/// Transient route/label editing with unchanged projected nodes and ports.
|
||||
/// Retains other edges from a settled baseline and scores the selected edge
|
||||
/// against preceding stable IDs, exactly as a complete solve does. Recompute
|
||||
/// the complete view after commit, or whenever node/port geometry changes.
|
||||
/// Retains other edges from a settled baseline and scores the selected path
|
||||
/// against preceding stable IDs. Labels avoid all currently displayed paths.
|
||||
/// Recompute the complete view after commit: later paths and automatic labels
|
||||
/// may settle again. Also recompute whenever node/port geometry changes.
|
||||
pub fn route_edge_preview(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
@@ -358,7 +471,6 @@ fn route_diagram_impl(
|
||||
ordered.sort_by_key(|e| e.id);
|
||||
let mut edges = BTreeMap::new();
|
||||
let mut previous = Vec::new();
|
||||
let mut previous_labels = Vec::new();
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let mut bounds = BoundsBuilder::default();
|
||||
for node in &view.nodes {
|
||||
@@ -373,7 +485,7 @@ fn route_diagram_impl(
|
||||
},
|
||||
);
|
||||
}
|
||||
for edge in ordered {
|
||||
for edge in &ordered {
|
||||
if let Some(cached) = preview.and_then(|(baseline, changed)| {
|
||||
(edge.id != changed)
|
||||
.then(|| baseline.edges.get(&edge.id))
|
||||
@@ -382,10 +494,6 @@ fn route_diagram_impl(
|
||||
for &point in &cached.points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !cached.label.text.is_empty() {
|
||||
bounds.rect(cached.label.bounds.position, cached.label.bounds.size);
|
||||
previous_labels.push(cached.label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &cached.points);
|
||||
edges.insert(edge.id, cached.clone());
|
||||
continue;
|
||||
@@ -400,19 +508,8 @@ fn route_diagram_impl(
|
||||
let custom_suspended = promoted;
|
||||
let route = if promoted { None } else { stored };
|
||||
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
|
||||
let mut traversable = BTreeSet::new();
|
||||
ancestors(edge.source, &nodes, &mut traversable);
|
||||
ancestors(edge.target, &nodes, &mut traversable);
|
||||
let obstacles: Vec<_> = rectangles
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut r = *r;
|
||||
if traversable.contains(&r.id) {
|
||||
r.bottom = r.bottom.min(r.top + headers[&r.id]);
|
||||
}
|
||||
r.inflate(CLEARANCE)
|
||||
})
|
||||
.collect();
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
|
||||
let cached = preview.and_then(|(baseline, changed)| {
|
||||
(edge.id == changed)
|
||||
.then(|| baseline.edges.get(&edge.id))
|
||||
@@ -423,24 +520,11 @@ fn route_diagram_impl(
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
let mut updated = cached.clone();
|
||||
updated.label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&updated.points,
|
||||
route.and_then(|r| r.label.as_ref()),
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
);
|
||||
for &point in &updated.points {
|
||||
for &point in &cached.points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !updated.label.text.is_empty() {
|
||||
bounds.rect(updated.label.bounds.position, updated.label.bounds.size);
|
||||
previous_labels.push(updated.label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &updated.points);
|
||||
edges.insert(edge.id, updated);
|
||||
record_segments(&mut previous, &cached.points);
|
||||
edges.insert(edge.id, cached.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -475,28 +559,16 @@ fn route_diagram_impl(
|
||||
.into_iter()
|
||||
.map(|v| if total > 0.0 { v / total } else { 0.0 })
|
||||
.collect();
|
||||
let label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&points,
|
||||
route.and_then(|r| r.label.as_ref()),
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
);
|
||||
for &point in &points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !label.text.is_empty() {
|
||||
bounds.rect(label.bounds.position, label.bounds.size);
|
||||
previous_labels.push(label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &points);
|
||||
edges.insert(
|
||||
edge.id,
|
||||
RoutedEdge {
|
||||
id: edge.id,
|
||||
points,
|
||||
label,
|
||||
label: empty_label(),
|
||||
pins,
|
||||
pin_fractions,
|
||||
warnings,
|
||||
@@ -505,12 +577,76 @@ fn route_diagram_impl(
|
||||
},
|
||||
);
|
||||
}
|
||||
// Settle all paths before placing text: a later connection can cross an
|
||||
// earlier label even when the labels themselves do not overlap. This pass
|
||||
// never changes paths or authored metadata. Previews retain other cached
|
||||
// labels and avoid the paths actually shown during the gesture.
|
||||
let mut previous_labels = Vec::new();
|
||||
for routed in edges.values() {
|
||||
if let Some(relation) = project.relations.get(&routed.id) {
|
||||
for end in association_end_geometry(relation, routed) {
|
||||
bounds.rect(end.label.bounds.position, end.label.bounds.size);
|
||||
previous_labels.push(end.label.bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
for edge in ordered {
|
||||
let Some(routed) = edges.get_mut(&edge.id) else {
|
||||
continue;
|
||||
};
|
||||
if preview.is_none_or(|(baseline, changed)| {
|
||||
edge.id == changed || !baseline.edges.contains_key(&edge.id)
|
||||
}) {
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
let authored_label = (!routed.custom_suspended)
|
||||
.then(|| {
|
||||
authored
|
||||
.get(&edge.id)
|
||||
.and_then(|route| route.label.as_ref())
|
||||
})
|
||||
.flatten();
|
||||
routed.label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&routed.points,
|
||||
authored_label,
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
&previous,
|
||||
);
|
||||
}
|
||||
if !routed.label.text.is_empty() {
|
||||
bounds.rect(routed.label.bounds.position, routed.label.bounds.size);
|
||||
previous_labels.push(routed.label.bounds);
|
||||
}
|
||||
}
|
||||
RoutedView {
|
||||
edges,
|
||||
bounds: bounds.finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_obstacles(
|
||||
edge: &ProjectedEdge,
|
||||
nodes: &BTreeMap<Id, &ProjectedNode>,
|
||||
rectangles: &[Rect],
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
) -> Vec<Rect> {
|
||||
let mut traversable = BTreeSet::new();
|
||||
ancestors(edge.source, nodes, &mut traversable);
|
||||
ancestors(edge.target, nodes, &mut traversable);
|
||||
rectangles
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut r = *r;
|
||||
if traversable.contains(&r.id) {
|
||||
r.bottom = r.bottom.min(r.top + headers[&r.id]);
|
||||
}
|
||||
r.inflate(CLEARANCE)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Segment {
|
||||
a: Position,
|
||||
@@ -567,7 +703,7 @@ fn route_edge(
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
// A real self-transition must leave and return at distinct anchors.
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
@@ -1095,7 +1231,8 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
let text = match relation.kind() {
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange => relation.name.as_str(),
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::DataAssociation => relation.name.as_str(),
|
||||
RelationKind::CapabilityInclude => "«include»",
|
||||
RelationKind::CapabilityExtend => "«extend»",
|
||||
RelationKind::CapabilityGeneralization => "specializes",
|
||||
@@ -1109,6 +1246,22 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
text.to_owned()
|
||||
}
|
||||
}
|
||||
fn empty_label() -> EdgeLabelGeometry {
|
||||
EdgeLabelGeometry {
|
||||
text: String::new(),
|
||||
fraction: 0.5,
|
||||
anchor: pos(0.0, 0.0),
|
||||
center: pos(0.0, 0.0),
|
||||
bounds: RoutingBounds {
|
||||
position: pos(0.0, 0.0),
|
||||
size: Size {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_label(
|
||||
project: &Project,
|
||||
edge: Id,
|
||||
@@ -1116,6 +1269,7 @@ fn make_label(
|
||||
authored: Option<&crate::EdgeLabel>,
|
||||
obstacles: &[Rect],
|
||||
previous_labels: &[RoutingBounds],
|
||||
strokes: &[Segment],
|
||||
) -> EdgeLabelGeometry {
|
||||
let text = label_text(project, edge);
|
||||
let size = Size {
|
||||
@@ -1140,8 +1294,8 @@ fn make_label(
|
||||
)
|
||||
};
|
||||
let mut fraction = authored.map_or(0.5, |l| l.fraction);
|
||||
// Keep authored placement exact. Automatic labels share the stable-ID
|
||||
// prefix used for route scoring, so previews and settled solves agree.
|
||||
// Keep authored placement exact. Automatic labels avoid all visible
|
||||
// strokes, with earlier stable-ID labels taking precedence.
|
||||
if authored.is_none() && !text.is_empty() {
|
||||
let total = total_length(points);
|
||||
let mut traversed = 0.0;
|
||||
@@ -1185,6 +1339,7 @@ fn make_label(
|
||||
candidate,
|
||||
obstacles,
|
||||
previous_labels,
|
||||
strokes,
|
||||
best.as_ref().map(|(score, _, _)| *score),
|
||||
&mut comparisons,
|
||||
) else {
|
||||
@@ -1193,7 +1348,7 @@ fn make_label(
|
||||
if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
|
||||
best = Some((score, candidate_fraction, candidate_offset));
|
||||
}
|
||||
if score == (0, 0) {
|
||||
if score == (0, 0, 0) {
|
||||
break 'candidate;
|
||||
}
|
||||
}
|
||||
@@ -1217,9 +1372,10 @@ fn label_collisions(
|
||||
bounds: RoutingBounds,
|
||||
obstacles: &[Rect],
|
||||
labels: &[RoutingBounds],
|
||||
best: Option<(usize, usize)>,
|
||||
strokes: &[Segment],
|
||||
best: Option<(usize, usize, usize)>,
|
||||
comparisons: &mut usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
let mut nodes = 0;
|
||||
for obstacle in obstacles {
|
||||
if *comparisons == 0 {
|
||||
@@ -1230,7 +1386,7 @@ fn label_collisions(
|
||||
nodes += 1;
|
||||
}
|
||||
if best.is_some_and(|score| nodes > score.0) {
|
||||
return Some((nodes, usize::MAX));
|
||||
return Some((nodes, usize::MAX, usize::MAX));
|
||||
}
|
||||
}
|
||||
// Once a candidate is worse than the current best, additional collisions
|
||||
@@ -1252,11 +1408,35 @@ fn label_collisions(
|
||||
{
|
||||
collisions += 1;
|
||||
if collisions >= label_limit {
|
||||
return Some((nodes, collisions, usize::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = Rect {
|
||||
id: Id::nil(),
|
||||
left: bounds.position.x,
|
||||
top: bounds.position.y,
|
||||
right: bounds.position.x + bounds.size.width,
|
||||
bottom: bounds.position.y + bounds.size.height,
|
||||
}
|
||||
.inflate(3.0);
|
||||
let stroke_limit = best
|
||||
.filter(|score| (nodes, collisions) == (score.0, score.1))
|
||||
.map_or(usize::MAX, |score| score.2.saturating_add(1));
|
||||
let mut crossings = 0;
|
||||
for stroke in strokes {
|
||||
if *comparisons == 0 {
|
||||
return None;
|
||||
}
|
||||
*comparisons -= 1;
|
||||
if text.crosses(stroke.a, stroke.b) {
|
||||
crossings += 1;
|
||||
if crossings >= stroke_limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((nodes, collisions))
|
||||
Some((nodes, collisions, crossings))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -1278,11 +1458,22 @@ impl BoundsBuilder {
|
||||
}
|
||||
fn finish(self) -> RoutingBounds {
|
||||
let (left, top, right, bottom) = self.extents.unwrap_or((0.0, 0.0, 0.0, 0.0));
|
||||
// Subtraction and subsequent addition can round in opposite directions
|
||||
// when an authored label extends far left/up. Bounds must still enclose
|
||||
// every original point, including the far right and bottom edges.
|
||||
let span = |start: f32, end: f32| {
|
||||
let extent = end - start;
|
||||
if start + extent < end {
|
||||
extent.next_up()
|
||||
} else {
|
||||
extent
|
||||
}
|
||||
};
|
||||
RoutingBounds {
|
||||
position: pos(left, top),
|
||||
size: Size {
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
width: span(left, right),
|
||||
height: span(top, bottom),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Closed typed facts for deliberately reviewed OA/SA semantics.
|
||||
//!
|
||||
//! Historical facts are independently acknowledged observations, not a synthetic
|
||||
//! live model. Their UUIDs are not live references and survive ordinary deletion.
|
||||
mod catalog;
|
||||
mod historical;
|
||||
mod linked;
|
||||
mod mutate;
|
||||
mod review;
|
||||
mod types;
|
||||
pub use catalog::*;
|
||||
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
|
||||
pub use historical::{validate_semantic_baseline, visit_semantic_references};
|
||||
pub use linked::*;
|
||||
pub use review::*;
|
||||
pub use types::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
use super::types::*;
|
||||
use crate::model::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum HistoricalSourceKind {
|
||||
Reference,
|
||||
Enum,
|
||||
}
|
||||
|
||||
const MAX_FACTS: usize = 16_384;
|
||||
const MAX_LEAVES: usize = 1_000_000;
|
||||
const MAX_PATH: usize = 128;
|
||||
|
||||
struct Walk<'a> {
|
||||
path: Vec<String>,
|
||||
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
|
||||
errors: Vec<String>,
|
||||
leaves: usize,
|
||||
visitor: &'a mut dyn FnMut(&mut Id),
|
||||
}
|
||||
impl Walk<'_> {
|
||||
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
|
||||
if self.leaves >= MAX_LEAVES {
|
||||
return;
|
||||
}
|
||||
self.leaves += 1;
|
||||
self.path.push(key.to_string());
|
||||
f(self);
|
||||
self.path.pop();
|
||||
}
|
||||
fn slot(&mut self, kind: HistoricalSourceKind) {
|
||||
self.slots.push((self.path.clone(), kind));
|
||||
}
|
||||
fn error(&mut self, message: &str) {
|
||||
self.errors
|
||||
.push(format!("{}: {message}", self.path.join(".")));
|
||||
}
|
||||
}
|
||||
trait Historical {
|
||||
fn walk(&mut self, w: &mut Walk<'_>);
|
||||
}
|
||||
impl Historical for Id {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.slot(HistoricalSourceKind::Reference);
|
||||
(w.visitor)(self);
|
||||
}
|
||||
}
|
||||
macro_rules! plain {
|
||||
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
|
||||
}
|
||||
plain!(bool, u32, i64);
|
||||
impl Historical for String {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if !crate::validation::valid_text(self) {
|
||||
w.error("historical text contains an invalid character");
|
||||
}
|
||||
if self.len() > 1_000_000 {
|
||||
w.error("historical text work limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for f64 {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if !self.is_finite() {
|
||||
w.error("historical real must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! enums {
|
||||
($($ty:ty),*) => { $( impl Historical for $ty {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
|
||||
} )* };
|
||||
}
|
||||
enums!(
|
||||
ExchangeItemKind,
|
||||
ExchangeMechanism,
|
||||
ParameterDirection,
|
||||
FieldStorage,
|
||||
ComponentPortKind,
|
||||
ComponentPortOrientation,
|
||||
PortDirection,
|
||||
ScenarioViewpoint,
|
||||
ControlKind,
|
||||
FragmentOperator,
|
||||
TimeEventKind,
|
||||
StateKind,
|
||||
TransmissionProtocol,
|
||||
AcquisitionProtocol,
|
||||
TransitionKind,
|
||||
StateActionKind,
|
||||
ArtifactRelation,
|
||||
ReferenceCollection,
|
||||
OrderedCollection,
|
||||
IncidentEnd,
|
||||
DataConstructor,
|
||||
PortConstructor,
|
||||
OccurrenceConstructor,
|
||||
EventConstructor,
|
||||
MessageConstructor
|
||||
);
|
||||
impl<T: Historical> Historical for Option<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if let Some(v) = self {
|
||||
v.walk(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Vec<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
|
||||
w.at(i, |w| v.walk(w));
|
||||
}
|
||||
if self.len() > MAX_LEAVES {
|
||||
w.error("historical collection work limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Observed<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
|
||||
Self::Value(v) => w.at("Value", |w| v.walk(w)),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Reviewed<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if matches!(
|
||||
(&self.oa, &self.sa),
|
||||
(Observed::NotApplicable, Observed::NotApplicable)
|
||||
) {
|
||||
w.error("a reviewed facet must apply on at least one side");
|
||||
}
|
||||
w.at("oa", |w| self.oa.walk(w));
|
||||
w.at("sa", |w| self.sa.walk(w));
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for KeyPair<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.oa.is_none() && self.sa.is_none() {
|
||||
w.error("key pair cannot be empty on both sides");
|
||||
}
|
||||
w.at("oa", |w| self.oa.walk(w));
|
||||
w.at("sa", |w| self.sa.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for AnnotationKey {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.path.len() > MAX_PATH {
|
||||
w.error("historical annotation path limit exceeded");
|
||||
}
|
||||
w.at("path", |w| self.path.walk(w));
|
||||
w.at("field", |w| self.field.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for MessageValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.path.len() > MAX_PATH {
|
||||
w.error("historical annotation path limit exceeded");
|
||||
}
|
||||
w.at("path", |w| self.path.walk(w));
|
||||
w.at("field", |w| self.field.walk(w));
|
||||
w.at("expression", |w| self.expression.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for LifelineBinding {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.at("referenced", |w| self.referenced.walk(w));
|
||||
w.at("local", |w| self.local.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for DataValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Real(value) => w.at("Real", |w| value.walk(w)),
|
||||
Self::String(value) => w.at("String", |w| value.walk(w)),
|
||||
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
|
||||
Self::Boolean(_) | Self::Integer(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for TypeRef {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Named(id) => w.at("Named", |w| id.walk(w)),
|
||||
_ => w.slot(HistoricalSourceKind::Enum),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for ArtifactReview {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
|
||||
Self::Name(v) => w.at("Name", |w| v.walk(w)),
|
||||
Self::Description(v) => w.at("Description", |w| v.walk(w)),
|
||||
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
|
||||
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
|
||||
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
|
||||
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
|
||||
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! family_visitor {
|
||||
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
|
||||
impl Historical for FacetReview {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
|
||||
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
|
||||
}), )*
|
||||
Self::Member { collection, keys, review } => w.at("Member", |w| {
|
||||
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Order { collection, review } => w.at("Order", |w| {
|
||||
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
|
||||
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
|
||||
}}
|
||||
}),
|
||||
Self::Literal { keys, review } => w.at("Literal", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Annotation { keys, review } => w.at("Annotation", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
|
||||
if let Observed::Value(Some(row)) = value {
|
||||
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
|
||||
}
|
||||
}
|
||||
}),
|
||||
Self::Binding { keys, review } => w.at("Binding", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
|
||||
if let Observed::Value(Some(row)) = value {
|
||||
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
|
||||
}
|
||||
}
|
||||
}),
|
||||
Self::Child { keys, review } => w.at("Child", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Incident { keys, end, review } => w.at("Incident", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Artifact { keys, review } => w.at("Artifact", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
scalar_families!(family_visitor);
|
||||
|
||||
fn walk_baseline<'a>(
|
||||
baseline: &mut SemanticBaseline,
|
||||
visitor: &'a mut dyn FnMut(&mut Id),
|
||||
) -> Walk<'a> {
|
||||
let mut walk = Walk {
|
||||
path: vec!["facets".into()],
|
||||
slots: vec![],
|
||||
errors: vec![],
|
||||
leaves: 0,
|
||||
visitor,
|
||||
};
|
||||
if baseline.facets.len() > MAX_FACTS {
|
||||
walk.error("historical facet count limit exceeded");
|
||||
}
|
||||
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
|
||||
walk.at(i, |w| facet.walk(w));
|
||||
}
|
||||
if walk.leaves >= MAX_LEAVES {
|
||||
walk.error("historical reference work limit exceeded");
|
||||
}
|
||||
walk
|
||||
}
|
||||
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
|
||||
pub fn visit_semantic_references(
|
||||
baseline: &mut SemanticBaseline,
|
||||
mut visitor: impl FnMut(&mut Id),
|
||||
) {
|
||||
walk_baseline(baseline, &mut visitor);
|
||||
}
|
||||
pub(crate) fn historical_source_slots(
|
||||
baseline: &SemanticBaseline,
|
||||
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
|
||||
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
|
||||
}
|
||||
/// Checks persisted facts without demanding that historical references still exist.
|
||||
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
|
||||
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
|
||||
let mut slots = BTreeSet::new();
|
||||
for selector in baseline
|
||||
.facets
|
||||
.iter()
|
||||
.take(MAX_FACTS)
|
||||
.map(FacetReview::selector)
|
||||
{
|
||||
if selector_slots(&selector)
|
||||
.into_iter()
|
||||
.any(|slot| !slots.insert(slot))
|
||||
{
|
||||
errors.push(format!(
|
||||
"duplicate or overlapping historical facet: {selector:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
|
||||
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
|
||||
let mut result = vec![];
|
||||
if let Some(oa) = &keys.oa {
|
||||
result.push(KeyPair {
|
||||
oa: Some(oa.clone()),
|
||||
sa: None,
|
||||
});
|
||||
}
|
||||
if let Some(sa) = &keys.sa {
|
||||
result.push(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(sa.clone()),
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
match selector {
|
||||
FacetSelector::Member { collection, keys } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Member {
|
||||
collection: *collection,
|
||||
keys,
|
||||
})
|
||||
.collect(),
|
||||
FacetSelector::Literal(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Literal)
|
||||
.collect(),
|
||||
FacetSelector::Annotation(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Annotation)
|
||||
.collect(),
|
||||
FacetSelector::Binding(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Binding)
|
||||
.collect(),
|
||||
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
|
||||
FacetSelector::Incident { keys, end } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Incident { keys, end: *end })
|
||||
.collect(),
|
||||
FacetSelector::Artifact { keys, facet } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Artifact {
|
||||
keys,
|
||||
facet: *facet,
|
||||
})
|
||||
.collect(),
|
||||
value => vec![value.clone()],
|
||||
}
|
||||
}
|
||||
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
|
||||
a.oa.as_ref()
|
||||
.zip(b.oa.as_ref())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
|| a.sa
|
||||
.as_ref()
|
||||
.zip(b.sa.as_ref())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
}
|
||||
/// Slot identity is each actual side's key, not equality of the complete pair.
|
||||
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
|
||||
match (a, b) {
|
||||
(
|
||||
FacetSelector::Member {
|
||||
collection: a,
|
||||
keys: ak,
|
||||
},
|
||||
FacetSelector::Member {
|
||||
collection: b,
|
||||
keys: bk,
|
||||
},
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
|
||||
(
|
||||
FacetSelector::Incident { keys: ak, end: a },
|
||||
FacetSelector::Incident { keys: bk, end: b },
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
(
|
||||
FacetSelector::Artifact { keys: ak, facet: a },
|
||||
FacetSelector::Artifact { keys: bk, facet: b },
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
use crate::transitions::Drift;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LinkedSemanticImpact {
|
||||
pub objects: KeyPair<Id>,
|
||||
pub traces: Vec<Id>,
|
||||
pub drift: Drift,
|
||||
pub unreviewed: bool,
|
||||
}
|
||||
const MAX_LINKED_OBJECTS: usize = 4_096;
|
||||
const MAX_LINKED_EDGES: usize = 32_768;
|
||||
|
||||
/// Derived dependency diagnostics, never another historical baseline. Follow
|
||||
/// direct references/children, then only reusable dictionary/event definitions.
|
||||
/// Association endpoints and ordinary architecture neighborhoods do not expand.
|
||||
pub fn linked_semantic_impacts(
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
) -> Result<Vec<LinkedSemanticImpact>, String> {
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for element in project.elements.values() {
|
||||
if let Some(parent) = element.parent {
|
||||
children.entry(parent).or_default().push(element.id);
|
||||
}
|
||||
}
|
||||
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for relation in project.relations.values() {
|
||||
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
|
||||
contracts.entry(source).or_default().push(relation.id);
|
||||
}
|
||||
}
|
||||
let direct = |id| -> Vec<Id> {
|
||||
let mut out = vec![];
|
||||
if let Some(element) = project.elements.get(&id) {
|
||||
out.extend(
|
||||
crate::references::element_references(element)
|
||||
.into_iter()
|
||||
.filter(|r| Some(*r) != element.parent),
|
||||
);
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
out.extend(crate::references::relation_references(relation));
|
||||
}
|
||||
out.extend(children.get(&id).into_iter().flatten().copied());
|
||||
out.extend(contracts.get(&id).into_iter().flatten().copied());
|
||||
out
|
||||
};
|
||||
let expandable = |id| {
|
||||
project.elements.get(&id).is_some_and(|e| {
|
||||
matches!(
|
||||
e.kind(),
|
||||
Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::ExchangeItem
|
||||
| Kind::Interface
|
||||
| Kind::Event
|
||||
)
|
||||
}) || project
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
|
||||
};
|
||||
let mut edges = 0usize;
|
||||
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let first = direct(root);
|
||||
edges += first.len();
|
||||
if edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
|
||||
);
|
||||
}
|
||||
let mut queue: VecDeque<_> = first.into();
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if expandable(id) {
|
||||
let next = direct(id);
|
||||
edges += next.len();
|
||||
if edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
queue.extend(next);
|
||||
}
|
||||
}
|
||||
seen.remove(&root);
|
||||
Ok(seen)
|
||||
};
|
||||
let oa = traverse(trace.oa)?;
|
||||
let sa = traverse(trace.sa)?;
|
||||
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
|
||||
for t in project.traces.values() {
|
||||
traced.entry((t.oa, t.sa)).or_default().push(t);
|
||||
}
|
||||
let mut pairs = vec![];
|
||||
let mut used = BTreeSet::new();
|
||||
for id in oa {
|
||||
let target = references.get(&id).copied();
|
||||
if let Some(id) = target {
|
||||
used.insert(id);
|
||||
}
|
||||
pairs.push(KeyPair {
|
||||
oa: Some(id),
|
||||
sa: target,
|
||||
});
|
||||
}
|
||||
for id in sa.difference(&used) {
|
||||
pairs.push(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(*id),
|
||||
});
|
||||
}
|
||||
let mut result = vec![];
|
||||
for objects in pairs {
|
||||
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
|
||||
continue;
|
||||
}
|
||||
let links = objects
|
||||
.oa
|
||||
.zip(objects.sa)
|
||||
.and_then(|pair| traced.get(&pair));
|
||||
let mut flags = (false, false, links.is_none());
|
||||
let mut ids = vec![];
|
||||
for linked in links.into_iter().flatten() {
|
||||
ids.push(linked.id);
|
||||
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
|
||||
if facts.is_empty() {
|
||||
flags.2 = true;
|
||||
}
|
||||
for fact in facts {
|
||||
match fact.drift {
|
||||
Drift::OaChanged => flags.0 = true,
|
||||
Drift::SaChanged => flags.1 = true,
|
||||
Drift::BothChanged => {
|
||||
flags.0 = true;
|
||||
flags.1 = true;
|
||||
}
|
||||
Drift::Unreviewed => flags.2 = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let drift = match flags {
|
||||
(true, true, _) => Drift::BothChanged,
|
||||
(true, false, _) => Drift::OaChanged,
|
||||
(false, true, _) => Drift::SaChanged,
|
||||
(false, false, true) => Drift::Unreviewed,
|
||||
_ => Drift::Unchanged,
|
||||
};
|
||||
result.push(LinkedSemanticImpact {
|
||||
objects,
|
||||
traces: ids,
|
||||
drift,
|
||||
unreviewed: flags.2,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
use super::catalog::{collection, order_collection, sides, write_scalar};
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
use crate::transitions::TransitionDirection;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(crate) fn set_collection(
|
||||
p: &mut Project,
|
||||
id: Id,
|
||||
which: ReferenceCollection,
|
||||
list: Vec<Id>,
|
||||
) -> Result<(), String> {
|
||||
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
|
||||
return Err("Collection contains duplicate members".into());
|
||||
}
|
||||
use ReferenceCollection as C;
|
||||
if which == C::Steps {
|
||||
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
|
||||
if let Some(steps) = e.steps_mut() {
|
||||
*steps = list;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { occurrences, .. },
|
||||
..
|
||||
}) = e.shared_mut()
|
||||
{
|
||||
*occurrences = list;
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Target has no ordered steps".into());
|
||||
}
|
||||
let slot = match which {
|
||||
C::Steps => unreachable!(),
|
||||
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => Some(branches),
|
||||
_ => None,
|
||||
},
|
||||
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
}) => Some(incoming_items),
|
||||
_ => None,
|
||||
},
|
||||
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
}) => Some(outgoing_items),
|
||||
_ => None,
|
||||
},
|
||||
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. },
|
||||
) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
|
||||
_ => None,
|
||||
},
|
||||
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
|
||||
_ => None,
|
||||
},
|
||||
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
.ok_or("Collection is inapplicable on target")?;
|
||||
*slot = list;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
|
||||
match layer {
|
||||
Layer::Oa => pair.oa.as_ref(),
|
||||
Layer::Sa => pair.sa.as_ref(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
|
||||
match match layer {
|
||||
Layer::Oa => &review.oa,
|
||||
Layer::Sa => &review.sa,
|
||||
} {
|
||||
Observed::Value(v) => Ok(v),
|
||||
Observed::NotApplicable => {
|
||||
Err("Facet does not exist in the selected constructor branch".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn directional_map(
|
||||
map: &BTreeMap<Id, Id>,
|
||||
direction: TransitionDirection,
|
||||
) -> Result<BTreeMap<Id, Id>, String> {
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
return Ok(map.clone());
|
||||
}
|
||||
let mut inverse = BTreeMap::new();
|
||||
for (oa, sa) in map {
|
||||
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
|
||||
return Err(format!(
|
||||
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(inverse)
|
||||
}
|
||||
pub(crate) fn remap_record(
|
||||
record: &mut SemanticRecord,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
) -> Result<(), String> {
|
||||
let mut missing = BTreeSet::new();
|
||||
let mut remap = |id: &mut Id| {
|
||||
if let Some(new) = map.get(id) {
|
||||
*id = *new;
|
||||
} else {
|
||||
missing.insert(*id);
|
||||
}
|
||||
};
|
||||
match record {
|
||||
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
|
||||
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
|
||||
SemanticRecord::Artifact(a) => remap(&mut a.owner),
|
||||
}
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Missing explicit reference correspondence: {missing:?}"
|
||||
))
|
||||
}
|
||||
}
|
||||
pub(crate) fn remap_fact(
|
||||
fact: &FacetReview,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
source: Layer,
|
||||
) -> Result<FacetReview, String> {
|
||||
// Remap only the selected observation. Keys remain in canonical OA/SA space.
|
||||
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
|
||||
let base = SemanticBaseline {
|
||||
facets: vec![fact.clone()],
|
||||
};
|
||||
let side = match source {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
};
|
||||
let mut missing = BTreeSet::new();
|
||||
for (path, kind) in historical_source_slots(&base) {
|
||||
if kind != HistoricalSourceKind::Reference {
|
||||
continue;
|
||||
}
|
||||
let rest = &path[2..];
|
||||
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
|
||||
continue;
|
||||
}
|
||||
let mut leaf = &mut json;
|
||||
for part in rest {
|
||||
leaf = if leaf.is_array() {
|
||||
leaf.get_mut(
|
||||
part.parse::<usize>()
|
||||
.map_err(|_| "Invalid historical array index")?,
|
||||
)
|
||||
} else {
|
||||
leaf.get_mut(part)
|
||||
}
|
||||
.ok_or("Invalid historical source slot")?;
|
||||
}
|
||||
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
|
||||
if let Some(new) = map.get(&id) {
|
||||
*leaf = serde_json::to_value(new).unwrap();
|
||||
} else {
|
||||
missing.insert(id);
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Missing explicit reference correspondence: {missing:?}"
|
||||
));
|
||||
}
|
||||
serde_json::from_value(json).map_err(|e| e.to_string())
|
||||
}
|
||||
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
|
||||
&& map.get(&oa) != Some(&sa)
|
||||
{
|
||||
return Err("Key pair disagrees with frozen reference correspondence".into());
|
||||
}
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty key pair".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
match selector {
|
||||
FacetSelector::Member { keys, .. }
|
||||
| FacetSelector::Binding(keys)
|
||||
| FacetSelector::Child(keys)
|
||||
| FacetSelector::Incident { keys, .. }
|
||||
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
|
||||
FacetSelector::Annotation(keys) => {
|
||||
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Annotation path pairing disagrees".into());
|
||||
}
|
||||
for (oa, sa) in oa
|
||||
.path
|
||||
.iter()
|
||||
.chain([&oa.field])
|
||||
.zip(sa.path.iter().chain([&sa.field]))
|
||||
{
|
||||
pair(
|
||||
&KeyPair {
|
||||
oa: Some(*oa),
|
||||
sa: Some(*sa),
|
||||
},
|
||||
map,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty annotation pair".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetSelector::Literal(keys) => {
|
||||
if keys
|
||||
.oa
|
||||
.as_ref()
|
||||
.zip(keys.sa.as_ref())
|
||||
.is_some_and(|(oa, sa)| oa != sa)
|
||||
{
|
||||
return Err(
|
||||
"Enum literal identity is exact text; renaming is removal/addition".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply just one selected fact to an in-memory expected project. The caller
|
||||
/// subsequently prepares ordinary Commands and checks every induced difference.
|
||||
pub(crate) fn write_fact(
|
||||
p: &mut Project,
|
||||
target: Id,
|
||||
fact: &FacetReview,
|
||||
from: Layer,
|
||||
target_layer: Layer,
|
||||
preserve_target_only: bool,
|
||||
) -> Result<(), String> {
|
||||
match fact {
|
||||
FacetReview::Member {
|
||||
collection: which,
|
||||
keys,
|
||||
review,
|
||||
} => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let present = *value(review, from)?;
|
||||
let key = chosen(keys, target_layer)
|
||||
.copied()
|
||||
.ok_or("Map the target collection member explicitly")?;
|
||||
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
|
||||
if present {
|
||||
if !list.contains(&key) {
|
||||
list.push(key);
|
||||
}
|
||||
} else {
|
||||
list.retain(|id| *id != key);
|
||||
}
|
||||
set_collection(p, target, *which, list)
|
||||
}
|
||||
FacetReview::Order {
|
||||
collection: which,
|
||||
review,
|
||||
} => {
|
||||
let supplied = value(review, from)?.clone();
|
||||
let current = collection(p, target, order_collection(*which))
|
||||
.ok_or("Inapplicable target order")?;
|
||||
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
|
||||
let current_set: BTreeSet<_> = current.iter().copied().collect();
|
||||
if supplied_set != current_set {
|
||||
if preserve_target_only
|
||||
&& supplied_set.is_subset(¤t_set)
|
||||
&& current
|
||||
.iter()
|
||||
.filter(|id| supplied_set.contains(id))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
== supplied
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
|
||||
}
|
||||
set_collection(p, target, order_collection(*which), supplied)
|
||||
}
|
||||
FacetReview::Literal { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let present = *value(review, from)?;
|
||||
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target is not an enumeration".into());
|
||||
};
|
||||
if present {
|
||||
if !literals.contains(key) {
|
||||
literals.push(key.clone());
|
||||
}
|
||||
} else {
|
||||
literals.retain(|v| v != key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Annotation { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
|
||||
let row = value(review, from)?.clone();
|
||||
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
|
||||
return Err(
|
||||
"Annotation candidate changes its row key; review removal/addition".into(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target has no message annotations".into());
|
||||
};
|
||||
values.retain(|v| AnnotationKey::from(v) != *key);
|
||||
if let Some(row) = row {
|
||||
values.push(row);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Binding { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
|
||||
let row = value(review, from)?.clone();
|
||||
if row.as_ref().is_some_and(|v| v.referenced != key) {
|
||||
return Err(
|
||||
"Binding candidate changes its row key; review removal/addition".into(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { bindings, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target has no scenario bindings".into());
|
||||
};
|
||||
bindings.retain(|v| v.referenced != key);
|
||||
if let Some(row) = row {
|
||||
bindings.push(row);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Child { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
|
||||
let e = p
|
||||
.elements
|
||||
.get_mut(key)
|
||||
.ok_or("Target child does not exist; create it through transition mapping")?;
|
||||
if *value(review, from)? {
|
||||
e.parent = Some(target);
|
||||
} else if e.parent == Some(target) {
|
||||
e.parent = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Incident { keys, end, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
|
||||
if !*value(review, from)? {
|
||||
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
|
||||
}
|
||||
let relation = p
|
||||
.relations
|
||||
.get_mut(key)
|
||||
.ok_or("Mapped incident relation is absent")?;
|
||||
let (a, b) = relation.body.endpoints();
|
||||
match end {
|
||||
IncidentEnd::Source => relation.body.set_endpoints(target, b),
|
||||
IncidentEnd::Target => relation.body.set_endpoints(a, target),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Artifact { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
|
||||
if let ArtifactReview::Presence(review) = review {
|
||||
if !*value(review, from)? {
|
||||
p.artifact_links.remove(key);
|
||||
return Ok(());
|
||||
}
|
||||
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Artifact creation requires a complete typed candidate".into());
|
||||
}
|
||||
let a = p
|
||||
.artifact_links
|
||||
.get_mut(key)
|
||||
.ok_or("Artifact candidate is absent")?;
|
||||
if a.owner != target {
|
||||
return Err(
|
||||
"Artifact metadata can only be reviewed on its currently attached owner trace"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match review {
|
||||
ArtifactReview::Presence(_) => unreachable!(),
|
||||
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
|
||||
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
|
||||
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
|
||||
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
|
||||
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
|
||||
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
|
||||
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
let (oa, sa) = sides(fact);
|
||||
let side = match from {
|
||||
Layer::Oa => oa,
|
||||
Layer::Sa => sa,
|
||||
};
|
||||
let value = side
|
||||
.get("Value")
|
||||
.ok_or("Selected branch is not applicable")?
|
||||
.clone();
|
||||
write_scalar(p, target, &fact.selector(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
|
||||
use FacetSelector as F;
|
||||
match group {
|
||||
F::Data(DataFacet::Constructor) => {
|
||||
matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
|
||||
}
|
||||
F::Port(PortFacet::Constructor) => matches!(
|
||||
facet,
|
||||
F::Port(_)
|
||||
| F::Member {
|
||||
collection: ReferenceCollection::FunctionInputItems
|
||||
| ReferenceCollection::FunctionOutputItems,
|
||||
..
|
||||
}
|
||||
),
|
||||
F::Occurrence(OccurrenceFacet::Constructor) => {
|
||||
matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|
||||
|| matches!(
|
||||
facet,
|
||||
F::Annotation(_)
|
||||
| F::Binding(_)
|
||||
| F::Member {
|
||||
collection: ReferenceCollection::Steps
|
||||
| ReferenceCollection::FragmentBranches,
|
||||
..
|
||||
}
|
||||
| F::Order(_)
|
||||
)
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
|
||||
facet,
|
||||
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
|
||||
),
|
||||
F::Event(EventFacet::Constructor) => matches!(
|
||||
facet,
|
||||
F::Event(_)
|
||||
| F::Member {
|
||||
collection: ReferenceCollection::SignalItems,
|
||||
..
|
||||
}
|
||||
),
|
||||
F::State(StateFacet::Kind) => facet == group,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(crate) fn copy_constructor(
|
||||
p: &mut Project,
|
||||
target: Id,
|
||||
source: &SemanticRecord,
|
||||
group: &FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
let SemanticRecord::Element(source) = source else {
|
||||
return Err("Constructor source must be an element".into());
|
||||
};
|
||||
let target = p
|
||||
.elements
|
||||
.get_mut(&target)
|
||||
.ok_or("Missing target element")?;
|
||||
let a = source.shared().ok_or("Source has no shared constructor")?;
|
||||
let b = target
|
||||
.shared_mut()
|
||||
.ok_or("Target has no shared constructor")?;
|
||||
match (group, a, b) {
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default,
|
||||
..
|
||||
},
|
||||
SharedBody::DataType {
|
||||
definition: d,
|
||||
default: v,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
*d = definition.clone();
|
||||
*v = default.clone();
|
||||
}
|
||||
(
|
||||
FacetSelector::Port(PortFacet::Constructor),
|
||||
SharedBody::Port { contract, legacy },
|
||||
SharedBody::Port {
|
||||
contract: c,
|
||||
legacy: l,
|
||||
},
|
||||
) => {
|
||||
*c = contract.clone();
|
||||
*l = legacy.clone();
|
||||
}
|
||||
(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
SharedBody::Occurrence { body, .. },
|
||||
SharedBody::Occurrence { body: b, .. },
|
||||
) => *b = body.clone(),
|
||||
(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||
..
|
||||
},
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: k, ..
|
||||
},
|
||||
..
|
||||
},
|
||||
) => *k = message_kind.clone(),
|
||||
(
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
SharedBody::Event { body },
|
||||
SharedBody::Event { body: b },
|
||||
) => *b = body.clone(),
|
||||
(
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
SharedBody::State { kind, .. },
|
||||
SharedBody::State { kind: k, .. },
|
||||
) => *k = *kind,
|
||||
_ => return Err("Constructor family mismatch".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
use crate::model::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Observed<T> {
|
||||
NotApplicable,
|
||||
Value(T),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reviewed<T> {
|
||||
pub oa: Observed<T>,
|
||||
pub sa: Observed<T>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyPair<K> {
|
||||
pub oa: Option<K>,
|
||||
pub sa: Option<K>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AnnotationKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
impl From<&MessageValue> for AnnotationKey {
|
||||
fn from(value: &MessageValue) -> Self {
|
||||
Self {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataConstructor {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration,
|
||||
Record,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortConstructor {
|
||||
FunctionInput,
|
||||
FunctionOutput,
|
||||
Component,
|
||||
Legacy,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OccurrenceConstructor {
|
||||
Exchange,
|
||||
Action,
|
||||
Control,
|
||||
SequenceReference,
|
||||
ScenarioUse,
|
||||
State,
|
||||
Fragment,
|
||||
Branch,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EventConstructor {
|
||||
Signal,
|
||||
Time,
|
||||
Change,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MessageConstructor {
|
||||
Asynchronous,
|
||||
Call,
|
||||
Reply,
|
||||
Create,
|
||||
Delete,
|
||||
Timer,
|
||||
}
|
||||
|
||||
// This finite catalog is also consumed by the typed historical leaf visitor.
|
||||
// Each selector fixes its payload type; there is no arbitrary value/path schema.
|
||||
macro_rules! scalar_families {
|
||||
($m:ident) => { $m! {
|
||||
Header, HeaderFacet, HeaderReview {
|
||||
Name: String, Description: String, Parent: Option<Id>
|
||||
}
|
||||
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
|
||||
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
|
||||
}
|
||||
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
|
||||
Data, DataFacet, DataReview {
|
||||
Constructor: DataConstructor,
|
||||
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
|
||||
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
|
||||
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
|
||||
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
|
||||
}
|
||||
Field, FieldFacet, FieldReview {
|
||||
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
|
||||
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
|
||||
}
|
||||
Port, PortFacet, PortReview {
|
||||
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
|
||||
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
|
||||
LegacyInterface: Option<Id>
|
||||
}
|
||||
Scenario, ScenarioFacet, ScenarioReview {
|
||||
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
|
||||
}
|
||||
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
|
||||
Occurrence, OccurrenceFacet, OccurrenceReview {
|
||||
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
|
||||
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
|
||||
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
|
||||
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
|
||||
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
|
||||
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
|
||||
}
|
||||
Event, EventFacet, EventReview {
|
||||
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
|
||||
}
|
||||
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
|
||||
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
|
||||
Relation, RelationFacet, RelationReview {
|
||||
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
|
||||
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
|
||||
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
|
||||
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
|
||||
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
|
||||
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
|
||||
Guard: String, Effect: String, StateActionPhase: StateActionKind
|
||||
}
|
||||
}};
|
||||
}
|
||||
pub(super) use scalar_families;
|
||||
|
||||
macro_rules! define_families {
|
||||
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
|
||||
$(
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum $selector { $( $leaf ),* }
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
|
||||
)*
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetSelector {
|
||||
$( $family($selector), )*
|
||||
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
|
||||
Order(OrderedCollection),
|
||||
Literal(KeyPair<String>),
|
||||
Annotation(KeyPair<AnnotationKey>),
|
||||
Binding(KeyPair<Id>),
|
||||
Child(KeyPair<Id>),
|
||||
Incident { keys: KeyPair<Id>, end: IncidentEnd },
|
||||
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetReview {
|
||||
$( $family($review), )*
|
||||
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
|
||||
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
|
||||
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
|
||||
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
|
||||
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
|
||||
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
|
||||
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
|
||||
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
|
||||
}
|
||||
impl FacetReview {
|
||||
pub fn selector(&self) -> FacetSelector {
|
||||
match self {
|
||||
$( Self::$family(value) => FacetSelector::$family(match value {
|
||||
$( $review::$leaf(_) => $selector::$leaf, )*
|
||||
}), )*
|
||||
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
|
||||
Self::Order { collection, .. } => FacetSelector::Order(*collection),
|
||||
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
|
||||
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
|
||||
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
|
||||
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
|
||||
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
|
||||
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
scalar_families!(define_families);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ReferenceCollection {
|
||||
Steps,
|
||||
FragmentBranches,
|
||||
FunctionInputItems,
|
||||
FunctionOutputItems,
|
||||
SignalItems,
|
||||
ExchangeItems,
|
||||
TransitionTriggers,
|
||||
TransitionEffects,
|
||||
Constrained,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum OrderedCollection {
|
||||
Steps,
|
||||
FragmentBranches,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum IncidentEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ArtifactFacet {
|
||||
Presence,
|
||||
Name,
|
||||
Description,
|
||||
Owner,
|
||||
Relation,
|
||||
Uri,
|
||||
Revision,
|
||||
Fragment,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ArtifactReview {
|
||||
Presence(Reviewed<bool>),
|
||||
Name(Reviewed<String>),
|
||||
Description(Reviewed<String>),
|
||||
Owner(Reviewed<Id>),
|
||||
Relation(Reviewed<ArtifactRelation>),
|
||||
Uri(Reviewed<String>),
|
||||
Revision(Reviewed<Option<String>>),
|
||||
Fragment(Reviewed<Option<String>>),
|
||||
}
|
||||
impl ArtifactReview {
|
||||
pub fn facet(&self) -> ArtifactFacet {
|
||||
match self {
|
||||
Self::Presence(_) => ArtifactFacet::Presence,
|
||||
Self::Name(_) => ArtifactFacet::Name,
|
||||
Self::Description(_) => ArtifactFacet::Description,
|
||||
Self::Owner(_) => ArtifactFacet::Owner,
|
||||
Self::Relation(_) => ArtifactFacet::Relation,
|
||||
Self::Uri(_) => ArtifactFacet::Uri,
|
||||
Self::Revision(_) => ArtifactFacet::Revision,
|
||||
Self::Fragment(_) => ArtifactFacet::Fragment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticBaseline {
|
||||
pub facets: Vec<FacetReview>,
|
||||
}
|
||||
@@ -44,6 +44,16 @@ pub enum Command {
|
||||
diagram: Id,
|
||||
edge: Id,
|
||||
},
|
||||
SetPortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
anchor: PortAnchor,
|
||||
},
|
||||
RemovePortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
port: Id,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
@@ -104,6 +114,7 @@ pub struct PreparedCommand {
|
||||
struct IdentityProvenance {
|
||||
original: BTreeSet<Id>,
|
||||
current_to_original: BTreeMap<Id, Id>,
|
||||
explicit_replacement: bool,
|
||||
}
|
||||
|
||||
impl IdentityProvenance {
|
||||
@@ -113,10 +124,12 @@ impl IdentityProvenance {
|
||||
Self {
|
||||
original,
|
||||
current_to_original,
|
||||
explicit_replacement: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, project: &Project) {
|
||||
self.explicit_replacement = true;
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original = self
|
||||
.original
|
||||
@@ -170,6 +183,13 @@ pub fn prepare_command(
|
||||
)?;
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
provenance.retain_present(&candidate);
|
||||
if !provenance.explicit_replacement {
|
||||
crate::data::prune_removed_inheritance(
|
||||
project,
|
||||
&mut candidate,
|
||||
&provenance.current_to_original,
|
||||
);
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(PreparedCommand {
|
||||
project: candidate,
|
||||
@@ -277,6 +297,13 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
|
||||
)?;
|
||||
}
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
if !provenance.explicit_replacement {
|
||||
crate::data::prune_removed_inheritance(
|
||||
project,
|
||||
&mut candidate,
|
||||
&provenance.current_to_original,
|
||||
);
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(candidate)
|
||||
}
|
||||
@@ -679,6 +706,45 @@ fn apply_command(
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if let Some(existing) = n.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
|
||||
*existing = anchor.clone();
|
||||
} else {
|
||||
n.port_anchors.push(anchor.clone());
|
||||
}
|
||||
Some(anchor.port)
|
||||
}
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if !p
|
||||
.elements
|
||||
.get(port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Port positioning requires a direct child port of the owner placement.".into(),
|
||||
));
|
||||
}
|
||||
n.port_anchors.retain(|anchor| anchor.port != *port);
|
||||
Some(*port)
|
||||
}
|
||||
Command::SetEdgeRoute { diagram, route } => {
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
|
||||
@@ -999,7 +999,7 @@ mod transaction_tests {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8
|
||||
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
|
||||
//! documents; this module never reads a filesystem, URL, or process directory.
|
||||
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
|
||||
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
|
||||
use language::{Diagnostic, ParseLimits, Span};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "source_project/source_migration.rs"]
|
||||
mod source_migration;
|
||||
#[path = "source_project/source_structure.rs"]
|
||||
mod source_structure;
|
||||
|
||||
@@ -278,21 +280,24 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
|
||||
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
pub fn compile_bundle(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let (compiled, insertions) = compile_once(bundle, limits)?;
|
||||
if insertions.is_empty() {
|
||||
return Ok(compiled);
|
||||
let (mut compiled, insertions) = compile_once(bundle, limits)?;
|
||||
if !insertions.is_empty() {
|
||||
let mut stable = bundle.clone();
|
||||
apply_edits(&mut stable, insertions)?;
|
||||
// Re-index actual returned bytes before performing the schema upgrade.
|
||||
compiled = compile_once(&stable, limits)?.0;
|
||||
}
|
||||
let mut stable = bundle.clone();
|
||||
apply_edits(&mut stable, insertions)?;
|
||||
// Re-index the actual returned bytes. Never expose offsets into pre-ID text.
|
||||
compile_once(&stable, limits).map(|(compiled, _)| compiled)
|
||||
if compiled.decoded.migration.is_some() {
|
||||
return source_migration::upgrade(&compiled, limits);
|
||||
}
|
||||
Ok(compiled)
|
||||
}
|
||||
|
||||
fn compile_once(
|
||||
@@ -568,7 +573,7 @@ fn compile_once(
|
||||
span,
|
||||
}),
|
||||
};
|
||||
let project =
|
||||
let decoded =
|
||||
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
@@ -578,7 +583,7 @@ fn compile_once(
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let diagnostics = model_diagnostics(bundle, &index, &project);
|
||||
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
|
||||
let errors: Vec<_> = diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
|
||||
@@ -590,10 +595,7 @@ fn compile_once(
|
||||
Ok((
|
||||
CompiledSourceProject {
|
||||
bundle: bundle.clone(),
|
||||
decoded: DecodeResult {
|
||||
project,
|
||||
migration: None,
|
||||
},
|
||||
decoded,
|
||||
index,
|
||||
diagnostics,
|
||||
},
|
||||
@@ -718,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical, readable source3 export. Like `language::format`, the caller is
|
||||
/// Canonical, readable current-version export. Like `language::format`, the caller is
|
||||
/// responsible for supplying a valid model; compilation checks the result.
|
||||
/// Existing source layout is preserved by `update_bundle`, not this formatter.
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
@@ -734,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia 3;\n{}{{\n description {};\n",
|
||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
@@ -923,6 +925,8 @@ pub fn update_bundle_with_identity_map(
|
||||
after: new_text,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
identity_origins: Some(&origins),
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
@@ -951,6 +955,8 @@ pub fn update_bundle_with_identity_map(
|
||||
after: &after,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
identity_origins: Some(&origins),
|
||||
};
|
||||
let mut local = Vec::new();
|
||||
update.header(
|
||||
@@ -1119,6 +1125,10 @@ struct SyntaxUpdate<'a> {
|
||||
after: &'a str,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
// must keep its authored argument trivia during that one-time transformation.
|
||||
legacy_value_paths: bool,
|
||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1214,6 +1224,18 @@ impl SyntaxUpdate<'_> {
|
||||
alias.clone(),
|
||||
));
|
||||
}
|
||||
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
.pop()
|
||||
.expect("historical UUID string");
|
||||
edits.push((
|
||||
Span {
|
||||
start: actual.span.start + token.span.start,
|
||||
end: actual.span.start + token.span.end,
|
||||
},
|
||||
quote(&id.to_string()),
|
||||
));
|
||||
}
|
||||
(EditKind::Atom, EditKind::Atom) => edits.push((
|
||||
actual.span,
|
||||
self.after[after.span.start..after.span.end].into(),
|
||||
@@ -1338,11 +1360,27 @@ impl SyntaxUpdate<'_> {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
let old_keys: Option<Vec<_>> = previous
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.before, self.old_aliases))
|
||||
.map(|item| {
|
||||
item_key(
|
||||
item,
|
||||
self.before,
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let new_keys: Option<Vec<_>> = after
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.after, self.new_aliases))
|
||||
.map(|item| {
|
||||
item_key(
|
||||
item,
|
||||
self.after,
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
|
||||
old.iter().collect::<BTreeSet<_>>().len() == old.len()
|
||||
@@ -1449,23 +1487,63 @@ fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
origins.unwrap_or(&BTreeMap::new()),
|
||||
)
|
||||
{
|
||||
return Some(key);
|
||||
}
|
||||
match &value.kind {
|
||||
EditKind::HistoricalUuid(id) => Some(format!(
|
||||
"uuid:{}",
|
||||
origins.and_then(|map| map.get(id)).unwrap_or(id)
|
||||
)),
|
||||
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
|
||||
EditKind::Atom => Some(format!(
|
||||
"literal:{}",
|
||||
&source[value.span.start..value.span.end]
|
||||
)),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge", "port"]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| {
|
||||
if key == "field" && !legacy_value_paths {
|
||||
let path = fields
|
||||
.get("path")
|
||||
.and_then(|field| {
|
||||
if let EditKind::List(path) = &field.value.kind {
|
||||
path.iter()
|
||||
.map(|hop| {
|
||||
if let EditKind::Reference(alias) = &hop.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("field:{}:{id}", path.join("/"))
|
||||
} else {
|
||||
format!("{key}:{id}")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::List(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn upgrade(
|
||||
current: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let report = current
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.expect("an explicit source migration report");
|
||||
let project = ¤t.decoded.project;
|
||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
.filter(|default| default.field != "baseline.semantic")
|
||||
.map(|default| default.object)
|
||||
.collect();
|
||||
let aliases: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id.to_string()))
|
||||
.collect();
|
||||
let mut edits: Edits = BTreeMap::new();
|
||||
for (path, source) in ¤t.bundle.files {
|
||||
let declarations =
|
||||
language::edit_declarations(source, path == ¤t.bundle.entry, limits.model)
|
||||
.map_err(|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *diagnostic,
|
||||
related: vec![],
|
||||
}]
|
||||
})?;
|
||||
for (id, actual) in declarations {
|
||||
if !changed.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = canonical.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let mut next = language::edit_declaration(after).map_err(|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *diagnostic,
|
||||
related: vec![],
|
||||
}]
|
||||
})?;
|
||||
if actual.lexical_owner.is_some()
|
||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||
&& !fields.contains_key("parent")
|
||||
&& let language::EditKind::Record(fields) = &mut next.fields.kind
|
||||
{
|
||||
fields.remove("parent");
|
||||
}
|
||||
let update = SyntaxUpdate {
|
||||
source,
|
||||
before: source,
|
||||
after,
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
identity_origins: None,
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let source = ¤t.bundle.files[¤t.bundle.entry];
|
||||
let version = significant(source)
|
||||
.get(1)
|
||||
.map(|token| token.span)
|
||||
.expect("a parsed version token");
|
||||
edits
|
||||
.entry(current.bundle.entry.clone())
|
||||
.or_default()
|
||||
.push((version, crate::SCHEMA_VERSION.to_string()));
|
||||
// New allocation relations are emitted into the allocating interface's
|
||||
// existing module, outside its lexical element body. Existing source never moves.
|
||||
let mut added: BTreeMap<String, String> = BTreeMap::new();
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
let path = current
|
||||
.index
|
||||
.declarations
|
||||
.get(&allocation.source)
|
||||
.map(|location| location.path.clone())
|
||||
.unwrap_or_else(|| current.bundle.entry.clone());
|
||||
let ending = newline(¤t.bundle.files[&path]);
|
||||
let text = added.entry(path).or_default();
|
||||
text.push_str(ending);
|
||||
text.push_str(&canonical[&allocation.id].replace('\n', ending));
|
||||
}
|
||||
for (path, text) in added {
|
||||
let offset = if path == current.bundle.entry {
|
||||
current.index.project_body.span.end
|
||||
} else {
|
||||
current.bundle.files[&path].len()
|
||||
};
|
||||
edits.entry(path).or_default().push((
|
||||
Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
},
|
||||
text,
|
||||
));
|
||||
}
|
||||
let mut bundle = current.bundle.clone();
|
||||
apply_edits(&mut bundle, edits)?;
|
||||
let (mut result, insertions) = compile_once(&bundle, limits)?;
|
||||
if !insertions.is_empty() || result.decoded.project != *project {
|
||||
return Err(vec![problem(
|
||||
&bundle,
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
"migration_source_mismatch",
|
||||
"Targeted source upgrade did not reproduce the migrated project.",
|
||||
)]);
|
||||
}
|
||||
result.decoded.migration = Some(report.clone());
|
||||
Ok(result)
|
||||
}
|
||||
@@ -202,9 +202,15 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
||||
return None;
|
||||
}
|
||||
if let Some(shared) = e.shared()
|
||||
&& kind == e.kind()
|
||||
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
|
||||
{
|
||||
let mut shared = shared.clone();
|
||||
if let SharedBody::State {
|
||||
kind: state_kind, ..
|
||||
} = &mut shared
|
||||
{
|
||||
*state_kind = kind.state_kind()?;
|
||||
}
|
||||
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
|
||||
*viewpoint = match (*viewpoint, layer) {
|
||||
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
|
||||
@@ -251,6 +257,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
}
|
||||
(
|
||||
RelationBody::Allocation { .. }
|
||||
| RelationBody::InterfaceItemAllocation { .. }
|
||||
| RelationBody::DataAssociation { .. }
|
||||
| RelationBody::ExchangeAllocation { .. }
|
||||
| RelationBody::CapabilityInvolvement { .. }
|
||||
| RelationBody::CapabilityInclude { .. }
|
||||
@@ -387,11 +395,30 @@ pub fn plan_transition(
|
||||
commands: vec![],
|
||||
};
|
||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||
let omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
let mut omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
sa_port_omissions(project)
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
// An explicit existing Legacy counterpart can be compared and reviewed
|
||||
// without inventing an OA typed port. Only this port is retained: dependent
|
||||
// SA implementation records and new counterpart requests stay omitted.
|
||||
for mapping in &request.mappings {
|
||||
if project
|
||||
.elements
|
||||
.get(&mapping.source)
|
||||
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
|
||||
&& mapping
|
||||
.target
|
||||
.and_then(|target| project.elements.get(&target))
|
||||
.is_some_and(|e| {
|
||||
e.layer() == Layer::Oa
|
||||
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
|
||||
})
|
||||
{
|
||||
omitted.remove(&mapping.source);
|
||||
}
|
||||
}
|
||||
let mut reported_omissions = BTreeSet::new();
|
||||
if selected.len() != request.sources.len() {
|
||||
conflict(
|
||||
@@ -421,6 +448,24 @@ pub fn plan_transition(
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
// Allocations are owned by interfaces. Independent data
|
||||
// associations travel only when both endpoint types are
|
||||
// already required; they do not expand the dictionary.
|
||||
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
|
||||
let contracts: Vec<_> = project
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|r| {
|
||||
((r.kind() == RelationKind::InterfaceItemAllocation
|
||||
&& r.source() == id)
|
||||
|| (r.kind() == RelationKind::DataAssociation
|
||||
&& selected.contains(&r.source())
|
||||
&& selected.contains(&r.target())))
|
||||
.then_some(r.id)
|
||||
})
|
||||
.collect();
|
||||
selected.extend(contracts);
|
||||
}
|
||||
// Contextual links are owned by the process/chain shared by
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
@@ -907,6 +952,7 @@ pub fn plan_transition(
|
||||
.unwrap_or(TraceBaseline {
|
||||
oa: current_oa.clone(),
|
||||
sa: current_sa.clone(),
|
||||
semantic: None,
|
||||
});
|
||||
for field in &reviewed_fields {
|
||||
set(&mut baseline.oa, *field, value(¤t_oa, *field).clone());
|
||||
@@ -981,6 +1027,7 @@ pub fn plan_transition(
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
working
|
||||
.diagrams
|
||||
@@ -1060,6 +1107,11 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
|
||||
}
|
||||
|
||||
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
|
||||
// State kind is a reviewed structural facet of one state family. An existing
|
||||
// counterpart remains selectable after either side changes its vertex kind.
|
||||
if source.is_state() && target.is_state() {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
(direction, source, target),
|
||||
(
|
||||
|
||||
@@ -388,68 +388,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
if let Some(shared) = e.shared() {
|
||||
match shared {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
} => {
|
||||
let mut seen = BTreeSet::new();
|
||||
for literal in literals {
|
||||
text(&mut out, e.id, "literals", literal);
|
||||
if !seen.insert(literal) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"duplicate_literal",
|
||||
"literals",
|
||||
"Enumeration literals must be unique.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SharedBody::DataField {
|
||||
type_ref,
|
||||
minimum,
|
||||
maximum,
|
||||
unit,
|
||||
} => {
|
||||
if maximum.is_some_and(|max| max < *minimum) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"invalid_multiplicity",
|
||||
"maximum",
|
||||
"Maximum multiplicity must be at least minimum, or unbounded.",
|
||||
);
|
||||
}
|
||||
text(&mut out, e.id, "unit", unit);
|
||||
if let TypeRef::Named(id) = type_ref {
|
||||
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
|
||||
}
|
||||
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
|
||||
&& parent.kind() == Kind::DataType
|
||||
&& !matches!(
|
||||
parent.shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record
|
||||
})
|
||||
)
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"field_owner",
|
||||
"parent",
|
||||
"Fields under a data type require a Record type.",
|
||||
);
|
||||
}
|
||||
}
|
||||
SharedBody::Interface { items } => {
|
||||
check_ids(&mut out, e.id, "items", items);
|
||||
for id in items {
|
||||
expect_element(p, &mut out, e.id, *id, "items", |k| {
|
||||
k == Kind::ExchangeItem
|
||||
});
|
||||
}
|
||||
}
|
||||
SharedBody::DataType { .. }
|
||||
| SharedBody::DataField { .. }
|
||||
| SharedBody::Interface { .. }
|
||||
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
|
||||
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
|
||||
SharedBody::Scenario {
|
||||
viewpoint,
|
||||
@@ -643,8 +585,11 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
crate::data::validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
}
|
||||
crate::data::validate_record_cycles(p, &mut out);
|
||||
for (key, d) in &p.diagrams {
|
||||
if *key != d.id {
|
||||
error(
|
||||
@@ -760,6 +705,40 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut elements = BTreeSet::new();
|
||||
for (key, n) in &d.nodes {
|
||||
let mut ports = BTreeSet::new();
|
||||
for anchor in &n.port_anchors {
|
||||
if !ports.insert(anchor.port) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"duplicate_port_anchor",
|
||||
"port_anchors",
|
||||
"Each port has at most one manual anchor per owner placement.",
|
||||
);
|
||||
}
|
||||
if !p
|
||||
.elements
|
||||
.get(&anchor.port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"port_anchor_owner",
|
||||
"port_anchors",
|
||||
"A manual anchor must reference a direct child port of its owner placement; repair anchors in the same transaction when reparenting.",
|
||||
);
|
||||
}
|
||||
if !anchor.offset.is_finite() || !(0. ..=1.).contains(&anchor.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"invalid_port_offset",
|
||||
"port_anchors",
|
||||
"Port offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if *key != n.id {
|
||||
error(
|
||||
&mut out,
|
||||
@@ -897,6 +876,19 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, t.id, "baseline.name", &props.name);
|
||||
text(&mut out, t.id, "baseline.description", &props.description);
|
||||
}
|
||||
if let Some(semantic) = &b.semantic
|
||||
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
|
||||
{
|
||||
for message in errors {
|
||||
error(
|
||||
&mut out,
|
||||
t.id,
|
||||
"semantic_baseline",
|
||||
"baseline.semantic",
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match crate::transitions::trace_drift(p, t) {
|
||||
crate::transitions::Drift::Unchanged => {}
|
||||
@@ -1401,23 +1393,10 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
|
||||
}
|
||||
/// Fields carried by an exchange, including fields of nested named records.
|
||||
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
|
||||
let mut pending = relation.body.items().to_vec();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut fields = BTreeSet::new();
|
||||
while let Some(owner) = pending.pop() {
|
||||
if !seen.insert(owner) {
|
||||
continue;
|
||||
}
|
||||
for field in p.children(owner) {
|
||||
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
|
||||
fields.insert(field.id);
|
||||
if let TypeRef::Named(id) = type_ref {
|
||||
pending.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fields
|
||||
crate::data::carried_field_paths(p, relation)
|
||||
.into_iter()
|
||||
.map(|f| f.field)
|
||||
.collect()
|
||||
}
|
||||
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
|
||||
match body {
|
||||
@@ -1648,36 +1627,125 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
}
|
||||
let allowed = exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.map(|r| carried_fields(p, r));
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
|
||||
})
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"call_without_operation",
|
||||
"exchange",
|
||||
"This generic call carries no Operation item; define an operation contract when appropriate.",
|
||||
);
|
||||
}
|
||||
let mut fields = BTreeSet::new();
|
||||
for value in values {
|
||||
expect_element(p, out, e.id, value.field, "values.field", |kind| {
|
||||
kind == Kind::DataField
|
||||
});
|
||||
for hop in &value.path {
|
||||
expect_element(p, out, e.id, *hop, "values.path", |kind| {
|
||||
kind == Kind::DataField
|
||||
});
|
||||
}
|
||||
text(out, e.id, "values.expression", &value.expression);
|
||||
if !fields.insert(value.field) {
|
||||
if !fields.insert((&value.path, value.field)) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"duplicate_message_value",
|
||||
"values",
|
||||
"Each carried field has at most one contextual value annotation in a message.",
|
||||
"Each qualified carried field has at most one contextual value annotation.",
|
||||
);
|
||||
}
|
||||
if allowed
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(&value.field))
|
||||
{
|
||||
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_field",
|
||||
"values.field",
|
||||
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
|
||||
"message_value_path_limit",
|
||||
"values.path",
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
};
|
||||
if !permitted {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_parameter_phase",
|
||||
"values.path",
|
||||
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
|
||||
);
|
||||
}
|
||||
if path.direction == ParameterDirection::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"message_parameter_direction",
|
||||
"values.path",
|
||||
"The containing operation parameter has no direction yet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let legacy = value
|
||||
.path
|
||||
.is_empty()
|
||||
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
|
||||
.flatten();
|
||||
if matches!(
|
||||
legacy,
|
||||
Some(
|
||||
crate::data::LegacyFieldPathResolution::Unique(_)
|
||||
| crate::data::LegacyFieldPathResolution::Ambiguous
|
||||
)
|
||||
) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
|
||||
"message_value_ambiguous_path"
|
||||
} else {
|
||||
"message_value_missing_path"
|
||||
},
|
||||
"values.path",
|
||||
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
|
||||
);
|
||||
} else {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_field",
|
||||
"values.path",
|
||||
"The qualified field must be carried by the exchange, following Value record fields only.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.expression.trim().is_empty() {
|
||||
warning(
|
||||
out,
|
||||
@@ -2050,7 +2118,10 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
if !p.contains(id) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
// identities per endpoint while retaining the general lookup semantics
|
||||
// for other record kinds and malformed map keys.
|
||||
if !p.elements.get(&id).is_some_and(|element| element.id == id) && !p.contains(id) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
@@ -2092,7 +2163,11 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
};
|
||||
if a.layer() != b.layer()
|
||||
|| !r.kind().permits(a.kind(), b.kind())
|
||||
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
|
||||
|| (a.id == b.id
|
||||
&& !matches!(
|
||||
r.kind(),
|
||||
RelationKind::StateTransition | RelationKind::DataAssociation
|
||||
))
|
||||
{
|
||||
error(
|
||||
out,
|
||||
@@ -2343,8 +2418,10 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
|
||||
owner.layer() == layer
|
||||
&& parent_permits(owner.kind(), child_kind)
|
||||
&& match owner.shared() {
|
||||
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
|
||||
*definition == DataTypeDefinition::Record
|
||||
Some(SharedBody::DataType { definition, .. })
|
||||
if child_kind == Kind::DataField =>
|
||||
{
|
||||
matches!(definition, DataTypeDefinition::Record { .. })
|
||||
}
|
||||
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
|
||||
matches!(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Frozen authored schema3 compatibility input. Do not rewrite as current syntax.
|
||||
arcadia 3;
|
||||
project legacy_data "Legacy data contract" id "00000000-0000-0000-0000-000000000001" {
|
||||
sa_function produce "Produce" id "00000000-0000-0000-0000-000000000002" {}
|
||||
sa_function consume "Consume" id "00000000-0000-0000-0000-000000000003" {}
|
||||
sa_exchange_item packet "Position event" id "00000000-0000-0000-0000-000000000004" {
|
||||
kind Event;
|
||||
// Reused record parameters retain their own identities.
|
||||
sa_data_field origin "Origin" id "00000000-0000-0000-0000-000000000007" { type_ref { Named @coordinate; }; }
|
||||
sa_data_field destination "Destination" id "00000000-0000-0000-0000-000000000008" { type_ref { Named @coordinate; }; }
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" id "00000000-0000-0000-0000-000000000005" {
|
||||
definition Record;
|
||||
sa_data_field latitude "Latitude" id "00000000-0000-0000-0000-000000000006" { type_ref Real; unit "deg"; }
|
||||
}
|
||||
sa_interface sender_contract "Sender contract" id "00000000-0000-0000-0000-000000000009" { items [@packet]; }
|
||||
sa_interface receiver_contract "Receiver contract" id "00000000-0000-0000-0000-00000000000a" { items [@packet]; }
|
||||
sa_port output "Output" id "00000000-0000-0000-0000-00000000000b" {
|
||||
parent @produce; contract { FunctionOutput { outgoing_items [@packet]; }; }; legacy none;
|
||||
}
|
||||
sa_port input "Input" id "00000000-0000-0000-0000-00000000000c" {
|
||||
parent @consume; contract { FunctionInput { incoming_items [@packet]; }; }; legacy none;
|
||||
}
|
||||
functional_exchange position_exchange "Position exchange" id "00000000-0000-0000-0000-00000000000d" {
|
||||
source @produce; target @consume; source_port @output; target_port @input; items [@packet];
|
||||
}
|
||||
sa_scenario scenario "Position scenario" id "00000000-0000-0000-0000-00000000000e" {
|
||||
viewpoint Functional; steps [@message];
|
||||
sa_lifeline sender "Sender" id "00000000-0000-0000-0000-00000000000f" { represented @produce; }
|
||||
sa_lifeline receiver "Receiver" id "00000000-0000-0000-0000-000000000010" { represented @consume; }
|
||||
sa_occurrence message "Position" id "00000000-0000-0000-0000-000000000011" {
|
||||
body { Exchange {
|
||||
exchange @position_exchange; sender @sender; receiver @receiver;
|
||||
values [{ field @latitude; expression "navigation.latitude"; }];
|
||||
}; };
|
||||
}
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"alias": "legacy_data",
|
||||
"name": "Legacy data contract",
|
||||
"description": "",
|
||||
"elements": {
|
||||
"00000000-0000-0000-0000-000000000002": {
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"alias": "produce",
|
||||
"name": "Produce",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000003": {
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"alias": "consume",
|
||||
"name": "Consume",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000004": {
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"alias": "packet",
|
||||
"name": "Position event",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"ExchangeItem": {
|
||||
"kind": "Event"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000005": {
|
||||
"id": "00000000-0000-0000-0000-000000000005",
|
||||
"alias": "coordinate",
|
||||
"name": "Coordinate",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataType": {
|
||||
"definition": "Record"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000006": {
|
||||
"id": "00000000-0000-0000-0000-000000000006",
|
||||
"alias": "latitude",
|
||||
"name": "Latitude",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000005",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": "Real",
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": "deg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000007": {
|
||||
"id": "00000000-0000-0000-0000-000000000007",
|
||||
"alias": "origin",
|
||||
"name": "Origin",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000004",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": {
|
||||
"Named": "00000000-0000-0000-0000-000000000005"
|
||||
},
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000008": {
|
||||
"id": "00000000-0000-0000-0000-000000000008",
|
||||
"alias": "destination",
|
||||
"name": "Destination",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000004",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": {
|
||||
"Named": "00000000-0000-0000-0000-000000000005"
|
||||
},
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000009": {
|
||||
"id": "00000000-0000-0000-0000-000000000009",
|
||||
"alias": "sender_contract",
|
||||
"name": "Sender contract",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Interface": {
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000a": {
|
||||
"id": "00000000-0000-0000-0000-00000000000a",
|
||||
"alias": "receiver_contract",
|
||||
"name": "Receiver contract",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Interface": {
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000b": {
|
||||
"id": "00000000-0000-0000-0000-00000000000b",
|
||||
"alias": "output",
|
||||
"name": "Output",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000002",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionOutput": {
|
||||
"outgoing_items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000c": {
|
||||
"id": "00000000-0000-0000-0000-00000000000c",
|
||||
"alias": "input",
|
||||
"name": "Input",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000003",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionInput": {
|
||||
"incoming_items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000e": {
|
||||
"id": "00000000-0000-0000-0000-00000000000e",
|
||||
"alias": "scenario",
|
||||
"name": "Position scenario",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Scenario": {
|
||||
"viewpoint": "Functional",
|
||||
"context": null,
|
||||
"precondition": "",
|
||||
"postcondition": "",
|
||||
"steps": [
|
||||
"00000000-0000-0000-0000-000000000011"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000f": {
|
||||
"id": "00000000-0000-0000-0000-00000000000f",
|
||||
"alias": "sender",
|
||||
"name": "Sender",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Lifeline": {
|
||||
"represented": "00000000-0000-0000-0000-000000000002"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000010": {
|
||||
"id": "00000000-0000-0000-0000-000000000010",
|
||||
"alias": "receiver",
|
||||
"name": "Receiver",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Lifeline": {
|
||||
"represented": "00000000-0000-0000-0000-000000000003"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000011": {
|
||||
"id": "00000000-0000-0000-0000-000000000011",
|
||||
"alias": "message",
|
||||
"name": "Position",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Occurrence": {
|
||||
"body": {
|
||||
"Exchange": {
|
||||
"exchange": "00000000-0000-0000-0000-00000000000d",
|
||||
"sender": "00000000-0000-0000-0000-00000000000f",
|
||||
"receiver": "00000000-0000-0000-0000-000000000010",
|
||||
"message_kind": "Asynchronous",
|
||||
"reply_to": null,
|
||||
"values": [
|
||||
{
|
||||
"field": "00000000-0000-0000-0000-000000000006",
|
||||
"expression": "navigation.latitude"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"precondition": "",
|
||||
"postcondition": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"00000000-0000-0000-0000-00000000000d": {
|
||||
"id": "00000000-0000-0000-0000-00000000000d",
|
||||
"alias": "position_exchange",
|
||||
"name": "Position exchange",
|
||||
"description": "",
|
||||
"body": {
|
||||
"FunctionalExchange": {
|
||||
"source": "00000000-0000-0000-0000-000000000002",
|
||||
"target": "00000000-0000-0000-0000-000000000003",
|
||||
"source_port": "00000000-0000-0000-0000-00000000000b",
|
||||
"target_port": "00000000-0000-0000-0000-00000000000c",
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagrams": {},
|
||||
"artifact_links": {},
|
||||
"traces": {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
|
||||
arcadia 4;
|
||||
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
|
||||
description "Preserve the original review without inventing semantic history.";
|
||||
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
|
||||
description "Unreviewed OA description";
|
||||
definition { Integer { minimum 0; maximum 100; }; };
|
||||
default { Integer 20; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
|
||||
description "Unreviewed SA description";
|
||||
definition { Real { minimum 0.0; maximum 100.0; }; };
|
||||
default { Real 20.0; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
|
||||
description "The target unit and representation are intentional.";
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description "Approved OA\nprose"; };
|
||||
sa { name "Approved SA name"; description "Approved SA prose"; };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schema_version": 4,
|
||||
"id": "40000000-0000-4000-8000-000000000001",
|
||||
"alias": "reviewed_prose",
|
||||
"name": "Reviewed dimensions",
|
||||
"description": "Preserve the original review without inventing semantic history.",
|
||||
"elements": {
|
||||
"40000000-0000-4000-8000-000000000002": {
|
||||
"id": "40000000-0000-4000-8000-000000000002",
|
||||
"alias": "oa_dimension",
|
||||
"name": "OA dimension now",
|
||||
"description": "Unreviewed OA description",
|
||||
"parent": null,
|
||||
"body": {"Oa": {"Shared": {"DataType": {
|
||||
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
|
||||
"default": {"Integer": 20},
|
||||
"unit": "m"
|
||||
}}}}
|
||||
},
|
||||
"40000000-0000-4000-8000-000000000003": {
|
||||
"id": "40000000-0000-4000-8000-000000000003",
|
||||
"alias": "sa_dimension",
|
||||
"name": "SA dimension now",
|
||||
"description": "Unreviewed SA description",
|
||||
"parent": null,
|
||||
"body": {"Sa": {"Shared": {"DataType": {
|
||||
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
|
||||
"default": {"Real": 20.0},
|
||||
"unit": "ft"
|
||||
}}}}
|
||||
}
|
||||
},
|
||||
"relations": {},
|
||||
"diagrams": {},
|
||||
"artifact_links": {},
|
||||
"traces": {
|
||||
"40000000-0000-4000-8000-000000000004": {
|
||||
"id": "40000000-0000-4000-8000-000000000004",
|
||||
"alias": "dimension_review",
|
||||
"name": "Dimension rationale",
|
||||
"description": "The target unit and representation are intentional.",
|
||||
"oa": "40000000-0000-4000-8000-000000000002",
|
||||
"sa": "40000000-0000-4000-8000-000000000003",
|
||||
"kind": "Item",
|
||||
"baseline": {
|
||||
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
|
||||
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,20 @@ project mission "Harbor safety — 港 🌊" {
|
||||
.find(|e| e.alias == "event_record")
|
||||
.unwrap()
|
||||
.id;
|
||||
let associated = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
p.elements.get_mut(&associated).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::DataAssociation,
|
||||
"Related record",
|
||||
record,
|
||||
associated,
|
||||
);
|
||||
for (index, type_ref) in [
|
||||
TypeRef::Boolean,
|
||||
TypeRef::Integer,
|
||||
@@ -190,18 +204,30 @@ project mission "Harbor safety — 港 🌊" {
|
||||
minimum: 1,
|
||||
maximum: Some(5),
|
||||
unit: "unit".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
for definition in [
|
||||
DataTypeDefinition::Boolean,
|
||||
DataTypeDefinition::Integer,
|
||||
DataTypeDefinition::String,
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
},
|
||||
DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
] {
|
||||
let id = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::DataType { definition };
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition,
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
for kind in [
|
||||
ExchangeItemKind::Material,
|
||||
@@ -216,7 +242,11 @@ project mission "Harbor safety — 港 🌊" {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind };
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
supertype: None,
|
||||
};
|
||||
}
|
||||
let scenario = p
|
||||
.elements
|
||||
@@ -461,7 +491,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 4; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -765,9 +795,27 @@ 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"], 3);
|
||||
assert_eq!(retained["schema_version"], 5);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|relation| {
|
||||
if let RelationBody::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order,
|
||||
..
|
||||
} = relation.body
|
||||
{
|
||||
Some((source, target, order))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
|
||||
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
|
||||
for layer in ["Oa", "Sa"] {
|
||||
let Some(shared) = element["body"]
|
||||
.get_mut(layer)
|
||||
@@ -779,6 +827,46 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(port["contract"], "Legacy");
|
||||
*port = port["legacy"].clone();
|
||||
}
|
||||
if let Some(item) = shared
|
||||
.get_mut("ExchangeItem")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
if item["mechanism"] == "Event" {
|
||||
item.insert("kind".into(), "Event".into());
|
||||
}
|
||||
item.remove("mechanism");
|
||||
item.remove("supertype");
|
||||
}
|
||||
if let Some(kind) = shared
|
||||
.get_mut("DataType")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
kind.remove("default");
|
||||
kind.remove("unit");
|
||||
let definition = &kind["definition"];
|
||||
if let Some(name) = ["Integer", "Real", "String", "Record"]
|
||||
.into_iter()
|
||||
.find(|name| definition.get(name).is_some())
|
||||
{
|
||||
kind.insert("definition".into(), name.into());
|
||||
}
|
||||
}
|
||||
if let Some(field) = shared
|
||||
.get_mut("DataField")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
for key in ["direction", "storage", "default"] {
|
||||
field.remove(key);
|
||||
}
|
||||
}
|
||||
if let Some(interface) = shared.get_mut("Interface") {
|
||||
let mut entries: Vec<_> = allocations
|
||||
.iter()
|
||||
.filter(|(source, _, _)| *source == element_id)
|
||||
.collect();
|
||||
entries.sort_by_key(|(_, _, order)| *order);
|
||||
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
|
||||
}
|
||||
if let Some(scenario) = shared
|
||||
.get_mut("Scenario")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
@@ -797,6 +885,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
}
|
||||
retained["relations"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
|
||||
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
|
||||
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
|
||||
assert_eq!(boundary["oriented"], true);
|
||||
@@ -826,7 +918,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
);
|
||||
assert_eq!(
|
||||
from_json.relations.len(),
|
||||
original["relations"].as_object().unwrap().len()
|
||||
original["relations"].as_object().unwrap().len() + allocations.len()
|
||||
);
|
||||
assert_eq!(
|
||||
from_json.diagrams.len(),
|
||||
@@ -899,7 +991,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, 3);
|
||||
assert_eq!(from_text.schema_version, 5);
|
||||
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() {
|
||||
@@ -1100,6 +1192,8 @@ 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;")
|
||||
{
|
||||
read(&code);
|
||||
examples += 1;
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
use oa_core::{language, persistence, projection::*, routing::*, source_project::*, *};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Information", a, b);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, pos(100., 100.));
|
||||
let nb = p.place(d, b, pos(900., 100.));
|
||||
(p, d, na, nb, source_port, target_port, edge)
|
||||
}
|
||||
fn manual(p: &mut Project, d: Id, n: Id, port: Id, side: PortAnchorSide, offset: f32) {
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.selected, Some(port));
|
||||
*p = prepared.project;
|
||||
}
|
||||
fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
|
||||
view.ports.iter().find(|p| p.element == id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_side_uses_exact_final_border_fraction_and_outward_routing_normal() {
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let (mut p, d, na, _, a, _, edge) = fixture();
|
||||
let before = project_diagram(&p, &p.diagrams[&d]);
|
||||
manual(&mut p, d, na, a, side, 0.3);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(
|
||||
view.nodes, before.nodes,
|
||||
"A port drag must not resize or move its owner"
|
||||
);
|
||||
let node = view.nodes.iter().find(|n| n.id == na).unwrap();
|
||||
let anchor = port(&view, a);
|
||||
assert_eq!(anchor.side, side);
|
||||
assert_eq!(anchor.position, port_anchor_position(node, side, 0.3));
|
||||
assert!((port_anchor_offset(node, side, anchor.position) - 0.3).abs() < 0.00001);
|
||||
let route = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
let points = &route.edges[&edge].points;
|
||||
assert_eq!(points[0], anchor.position);
|
||||
let normal = side.normal();
|
||||
let delta = pos(points[1].x - points[0].x, points[1].y - points[0].y);
|
||||
assert!(
|
||||
delta.x * normal.x + delta.y * normal.y >= PORT_STUB - 0.001,
|
||||
"{side:?}: {points:?}"
|
||||
);
|
||||
assert!(
|
||||
points
|
||||
.windows(2)
|
||||
.all(|w| w[0].x == w[1].x || w[0].y == w[1].y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_connected_ports_face_peers_and_unconnected_ports_use_four_sides() {
|
||||
for (peer, expected) in [
|
||||
(pos(-700., 100.), PortAnchorSide::Left),
|
||||
(pos(900., 100.), PortAnchorSide::Right),
|
||||
(pos(100., -700.), PortAnchorSide::Top),
|
||||
(pos(100., 900.), PortAnchorSide::Bottom),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for owner in [a, b] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Boundary",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
ports.push(id);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Connection",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, peer);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, ports[0]).side, expected);
|
||||
let before = view.ports.clone();
|
||||
for element in p.elements.values_mut() {
|
||||
element.alias = format!("renamed_{}", element.id.simple());
|
||||
}
|
||||
assert_eq!(project_diagram(&p, &p.diagrams[&d]).ports, before);
|
||||
p.relations.clear();
|
||||
for i in 0..3 {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
a,
|
||||
&format!("Spare {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let sides: std::collections::BTreeSet<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| p.elements[&port.element].parent == Some(a))
|
||||
.map(|port| port.side)
|
||||
.collect();
|
||||
assert_eq!(sides.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_upsert_reset_and_undo_preserve_unrelated_node_fields_and_list_order() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let extra = Id::new_v4();
|
||||
let spare = make_port(
|
||||
&p,
|
||||
extra,
|
||||
owner,
|
||||
"Spare",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(extra, spare);
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.2);
|
||||
manual(&mut p, d, n, extra, PortAnchorSide::Bottom, 0.7);
|
||||
let before = p.clone();
|
||||
let mut session = Session::new(p);
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.45,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
let anchors = &changed.diagrams[&d].nodes[&n].port_anchors;
|
||||
assert_eq!(
|
||||
anchors.iter().map(|a| a.port).collect::<Vec<_>>(),
|
||||
[a, extra]
|
||||
);
|
||||
assert_eq!(anchors[1], before.diagrams[&d].nodes[&n].port_anchors[1]);
|
||||
assert_eq!(
|
||||
changed.diagrams[&d].nodes[&n].position,
|
||||
before.diagrams[&d].nodes[&n].position
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&d].nodes[&n].port_anchors.len(),
|
||||
1
|
||||
);
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_anchors_and_payloads_fail_atomically_including_duplicate_owner_placements() {
|
||||
let (p, d, n, _, port, other_port, _) = fixture();
|
||||
for (port, offset) in [
|
||||
(port, f32::NAN),
|
||||
(port, f32::INFINITY),
|
||||
(port, -0.1),
|
||||
(port, 1.1),
|
||||
(other_port, 0.5),
|
||||
(Id::new_v4(), 0.5),
|
||||
(p.diagrams[&d].nodes[&n].element, 0.5),
|
||||
] {
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
let anchor = json!({"port": port, "side": "Top", "offset": 0.5});
|
||||
for bad in [
|
||||
json!({"port":port,"side":"Top"}),
|
||||
json!({"port":port,"side":"Diagonal","offset":0.5}),
|
||||
json!({"port":port,"side":"Top","offset":0.5,"owner":n}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<PortAnchor>(bad).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<Command>(
|
||||
json!({"SetPortAnchor":{"diagram":d,"node":n,"anchor":anchor,"unknown":true}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut duplicate = p.clone();
|
||||
let value = PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.5,
|
||||
};
|
||||
duplicate
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&n)
|
||||
.unwrap()
|
||||
.port_anchors = vec![value.clone(), value];
|
||||
assert!(prepare_command(&p, &Command::Replace(duplicate)).is_err());
|
||||
let mut duplicate = p.diagrams[&d].nodes[&n].clone();
|
||||
duplicate.id = Id::new_v4();
|
||||
duplicate.alias = "duplicate_view".into();
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::CreateNode {
|
||||
diagram: d,
|
||||
node: duplicate
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentification_deletion_reuse_and_owner_repair_preserve_correct_identity() {
|
||||
let (mut p, d, n, target, _, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let id = Id::new_v4();
|
||||
let value = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Spare output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, value.clone());
|
||||
manual(&mut p, d, n, id, PortAnchorSide::Top, 0.25);
|
||||
let new_id = Id::new_v4();
|
||||
let renamed = prepare_command(&p, &Command::Reidentify(id, new_id))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert_eq!(renamed.diagrams[&d].nodes[&n].port_anchors[0].port, new_id);
|
||||
let reused = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![Command::Delete(id), Command::CreateElement(value)]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(reused.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
let parent = Some(p.diagrams[&d].nodes[&target].element);
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::Reparent {
|
||||
element: id,
|
||||
parent
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut edited = p.elements[&id].clone();
|
||||
edited.parent = parent;
|
||||
assert!(prepare_command(&p, &Command::EditElement(id, edited.clone())).is_err());
|
||||
let repaired = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: id,
|
||||
},
|
||||
Command::EditElement(id, edited),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(repaired.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
assert_eq!(repaired.elements[&id].parent, parent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_views_resize_layout_and_hidden_owners_keep_manual_metadata() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.35);
|
||||
let mut second = p.diagrams[&d].clone();
|
||||
second.id = Id::new_v4();
|
||||
second.alias = "review_view".into();
|
||||
second.nodes.clear();
|
||||
let other_d = second.id;
|
||||
p.diagrams.insert(second.id, second);
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let other_n = p.place(other_d, owner, pos(50., 50.));
|
||||
manual(&mut p, other_d, other_n, a, PortAnchorSide::Bottom, 0.8);
|
||||
let original = p.diagrams[&d].nodes[&n].port_anchors.clone();
|
||||
let mut edited = p.diagrams[&d].nodes[&n].clone();
|
||||
edited.size = Size {
|
||||
width: 600.,
|
||||
height: 450.,
|
||||
};
|
||||
edited.position = pos(200., 250.);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node: edited,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, a).position, pos(410., 250.));
|
||||
p = prepare_command(&p, &Command::Layout(d)).unwrap().project;
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
assert_eq!(
|
||||
p.diagrams[&other_d].nodes[&other_n].port_anchors[0].side,
|
||||
PortAnchorSide::Bottom
|
||||
);
|
||||
let mut diagram = p.diagrams[&d].clone();
|
||||
diagram.projection = ProjectionKind::Capabilities;
|
||||
p = prepare_command(&p, &Command::EditDiagram(d, diagram))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(project_diagram(&p, &p.diagrams[&d]).ports.is_empty());
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometry() {
|
||||
let (mut p, d, na, nb, a, b, edge) = fixture();
|
||||
let parent = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Group", None)
|
||||
.unwrap();
|
||||
for node in [na, nb] {
|
||||
let element = p.diagrams[&d].nodes[&node].element;
|
||||
p.elements.get_mut(&element).unwrap().parent = Some(parent);
|
||||
}
|
||||
let group = p.place(d, parent, pos(50., 0.));
|
||||
let obstacle = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Sibling obstacle", Some(parent))
|
||||
.unwrap();
|
||||
let obstacle_node = p.place(d, obstacle, pos(440., 160.));
|
||||
let group_port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
group_port,
|
||||
parent,
|
||||
"Group input",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(group_port, element);
|
||||
manual(&mut p, d, group, group_port, PortAnchorSide::Top, 0.25);
|
||||
manual(&mut p, d, na, a, PortAnchorSide::Bottom, 0.5);
|
||||
manual(&mut p, d, nb, b, PortAnchorSide::Bottom, 0.5);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::SetEdgeRoute {
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let parent_body = view.nodes.iter().find(|n| n.id == group).unwrap();
|
||||
assert!(parent_body.size.width > p.diagrams[&d].nodes[&group].size.width);
|
||||
assert_eq!(
|
||||
port(&view, group_port).position,
|
||||
port_anchor_position(parent_body, PortAnchorSide::Top, 0.25)
|
||||
);
|
||||
let routes = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routes.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routes.edges[&edge]
|
||||
);
|
||||
let obstacle = view.nodes.iter().find(|n| n.id == obstacle_node).unwrap();
|
||||
for pair in routes.edges[&edge].points.windows(2) {
|
||||
let [a, b] = [pair[0], pair[1]];
|
||||
let crosses = if a.x == b.x {
|
||||
a.x > obstacle.position.x
|
||||
&& a.x < obstacle.position.x + obstacle.size.width
|
||||
&& a.y.min(b.y) < obstacle.position.y + obstacle.size.height
|
||||
&& a.y.max(b.y) > obstacle.position.y
|
||||
} else {
|
||||
a.y > obstacle.position.y
|
||||
&& a.y < obstacle.position.y + obstacle.size.height
|
||||
&& a.x.min(b.x) < obstacle.position.x + obstacle.size.width
|
||||
&& a.x.max(b.x) > obstacle.position.x
|
||||
};
|
||||
assert!(!crosses);
|
||||
}
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::MoveNode {
|
||||
diagram: d,
|
||||
node: group,
|
||||
position: pos(190., 80.),
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
let moved = session.project();
|
||||
let after = project_diagram(moved, &moved.diagrams[&d]);
|
||||
for before in &view.ports {
|
||||
assert_eq!(
|
||||
port(&after, before.element).position,
|
||||
pos(before.position.x + 140., before.position.y + 80.)
|
||||
);
|
||||
}
|
||||
for (id, node) in &p.diagrams[&d].nodes {
|
||||
assert_eq!(moved.diagrams[&d].nodes[id].port_anchors, node.port_anchors);
|
||||
}
|
||||
assert_eq!(moved.diagrams[&d].routes[0].waypoints, [pos(640., 500.)]);
|
||||
assert!(
|
||||
route_diagram(moved, &moved.diagrams[&d], &after).edges[&edge]
|
||||
.warnings
|
||||
.is_empty()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_peer_gap_uses_clear_side_instead_of_a_blocked_facing_stub() {
|
||||
let (mut p, d, a, b, output, input, edge) = fixture();
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&b)
|
||||
.unwrap()
|
||||
.position = pos(100., 260.);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let target = view.nodes.iter().find(|n| n.id == b).unwrap();
|
||||
assert!(target.position.y - source.position.y - source.size.height < PORT_STUB + CLEARANCE);
|
||||
assert_eq!(port(&view, output).side, PortAnchorSide::Right);
|
||||
assert_eq!(port(&view, input).side, PortAnchorSide::Left);
|
||||
let routed = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routed.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routed.edges[&edge]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_neutral_ports_balance_capacity_without_a_giant_bottom_footer() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "Many interfaces", None)
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
let id = Id::from_u128(i + 1);
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
&format!("Interface {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(d, owner, pos(0., 0.));
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(view.ports.len(), 100);
|
||||
assert!(view.nodes[0].size.width < 1000. && view.nodes[0].size.height < 1000.);
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let mut offsets: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|p| p.side == side)
|
||||
.map(|p| port_anchor_offset(&view.nodes[0], side, p.position))
|
||||
.collect();
|
||||
assert_eq!(offsets.len(), 25);
|
||||
offsets.sort_by(f32::total_cmp);
|
||||
let length = if matches!(side, PortAnchorSide::Left | PortAnchorSide::Right) {
|
||||
view.nodes[0].size.height
|
||||
} else {
|
||||
view.nodes[0].size.width
|
||||
};
|
||||
assert!(offsets.windows(2).all(|w| (w[1] - w[0]) * length >= 27.99));
|
||||
}
|
||||
let id = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.side == PortAnchorSide::Left)
|
||||
.unwrap()
|
||||
.element;
|
||||
manual(&mut p, d, node, id, PortAnchorSide::Top, 0.5);
|
||||
let after = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(after.nodes, view.nodes);
|
||||
let placed = port(&after, id);
|
||||
for other in after.ports.iter().filter(|p| p.element != id) {
|
||||
assert_eq!(other.side, port(&view, other.element).side);
|
||||
let distance = (other.position.x - placed.position.x).abs()
|
||||
+ (other.position.y - placed.position.y).abs();
|
||||
assert!(
|
||||
distance >= 14.,
|
||||
"An automatic port overlaps the authored anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project ports "Ports" {
|
||||
sa_function owner "Owner" {}
|
||||
sa_port first "First" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
sa_port second "Second" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
diagram view "View" { layer Sa; }
|
||||
node owner_view { diagram @view; element @owner; port_anchors [
|
||||
// first rationale
|
||||
{ port @first; side Top; offset 0.25; },
|
||||
// second rationale
|
||||
{ port @second; side Bottom; offset 0.75; }
|
||||
]; }
|
||||
}
|
||||
"#;
|
||||
fn find(p: &Project, alias: &str) -> Id {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_json_roundtrip_defaults_and_admission_include_anchor_records() {
|
||||
let p = language::parse(SOURCE).unwrap();
|
||||
let formatted = language::format(&p);
|
||||
assert!(formatted.contains("port @first;") && formatted.contains("side Top;"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
|
||||
p
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&p), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
p
|
||||
);
|
||||
// project + function + two ports + diagram + node + two anchors = 8.
|
||||
let limits = language::ParseLimits {
|
||||
max_records: 7,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
};
|
||||
assert!(limits.check_model(&p).is_err());
|
||||
assert!(persistence::decode_limited(&serde_json::to_vec(&p).unwrap(), limits).is_err());
|
||||
assert!(language::parse_limited(SOURCE, limits).is_err());
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&format_bundle(&p),
|
||||
ProjectLimits {
|
||||
model: limits,
|
||||
..ProjectLimits::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut value = serde_json::to_value(&p).unwrap();
|
||||
let d = find(&p, "view");
|
||||
let n = find(&p, "owner_view");
|
||||
value["diagrams"][d.to_string()]["nodes"][n.to_string()]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port_anchors");
|
||||
let read = persistence::decode(&serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
assert!(read.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_updater_keeps_comment_attachment_through_identity_swap_and_anchor_reordering() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.replace('\n', "\r\n"))]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let d = find(p, "view");
|
||||
let n = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let temp = Id::new_v4();
|
||||
let mut commands = vec![
|
||||
Command::Reidentify(first, temp),
|
||||
Command::Reidentify(second, first),
|
||||
Command::Reidentify(temp, second),
|
||||
Command::SetAlias(second, "revised_first".into()),
|
||||
];
|
||||
let candidate = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.unwrap()
|
||||
.project;
|
||||
let mut node = candidate.diagrams[&d].nodes[&n].clone();
|
||||
node.port_anchors.reverse();
|
||||
node.port_anchors[1].offset = 0.5;
|
||||
commands.push(Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node,
|
||||
});
|
||||
let prepared = prepare_command(p, &Command::Batch(commands)).unwrap();
|
||||
let result = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &result.bundle.files["project.arcadia"];
|
||||
assert!(
|
||||
text.contains("// first rationale\r\n { port @revised_first; side Top; offset 0.5; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("// second rationale\r\n { port @second; side Bottom; offset 0.75; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(result.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
update_bundle(&result, &prepared.project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_record() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let diagram = find(p, "view");
|
||||
let node = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: first,
|
||||
},
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: second,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let cleared = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cleared.decoded.project, prepared.project);
|
||||
let text = &cleared.bundle.files["project.arcadia"];
|
||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||
assert!(!text.contains("port_anchors"));
|
||||
let prepared = prepare_command(
|
||||
&cleared.decoded.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor: PortAnchor {
|
||||
port: second,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let added = update_bundle_with_identity_map(
|
||||
&cleared,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(added.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
added.bundle.files["project.arcadia"]
|
||||
.matches("// second rationale")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_source_anchor_records_have_located_errors() {
|
||||
for bad in [
|
||||
"{ port @first; side Top; }",
|
||||
"{ port @first; side Top; offset -0.1; }",
|
||||
"{ port @first; side Corner; offset 0.5; }",
|
||||
"{ port @owner; side Top; offset 0.5; }",
|
||||
"{ port @first; side Top; offset 0.5; unexpected true; }",
|
||||
"{ port @first; side Top; offset 0.1; }, { port @first; side Right; offset 0.2; }",
|
||||
] {
|
||||
let start = SOURCE.find("port_anchors [").unwrap() + "port_anchors [".len();
|
||||
let end = SOURCE[start..].find("]; }").unwrap() + start;
|
||||
let source = format!("{}\n{}\n{}", &SOURCE[..start], bad, &SOURCE[end..]);
|
||||
let errors = language::parse(&source).expect_err(bad);
|
||||
assert!(errors.iter().any(|e| e.line >= 7), "{bad}: {errors:?}");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ 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, 3));
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 5));
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
@@ -56,6 +56,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
+ old["traces"].as_object().unwrap().len()
|
||||
+ old["artifact_links"].as_object().unwrap().len()
|
||||
+ 1
|
||||
+ report.generated_interface_allocations.len()
|
||||
);
|
||||
let mut current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
@@ -143,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 3;"));
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
@@ -223,7 +224,13 @@ fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
_ => 0,
|
||||
})
|
||||
})
|
||||
.sum::<usize>();
|
||||
.sum::<usize>()
|
||||
+ decode(&old)
|
||||
.migration
|
||||
.unwrap()
|
||||
.generated_interface_allocations
|
||||
.len()
|
||||
* 2;
|
||||
for limits in [
|
||||
ParseLimits {
|
||||
max_graph_objects: graph + 1,
|
||||
@@ -244,7 +251,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
));
|
||||
}
|
||||
let accepted = ParseLimits {
|
||||
max_graph_objects: graph + 2,
|
||||
max_graph_objects: migrated.elements.len() + migrated.relations.len(),
|
||||
max_records: records,
|
||||
max_step_references: reference_count,
|
||||
..ParseLimits::UNLIMITED
|
||||
@@ -267,7 +274,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, 3);
|
||||
assert_eq!(result.project.schema_version, 5);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
const SOURCE: &str = r#"arcadia 4;
|
||||
project typed_ports "Typed ports" {
|
||||
sa_system producer "Producer" {}
|
||||
sa_actor consumer "Consumer" {}
|
||||
@@ -9,7 +9,8 @@ project typed_ports "Typed ports" {
|
||||
allocation sends "Produces" { source @producer; target @send; }
|
||||
allocation receives "Consumes" { source @consumer; target @receive; }
|
||||
sa_exchange_item data "Data" {}
|
||||
sa_interface service "Service" { items [@data]; }
|
||||
sa_interface service "Service" {}
|
||||
interface_item_allocation service_data "Service data" { source @service; target @data; }
|
||||
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
|
||||
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
|
||||
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||
@@ -26,9 +27,10 @@ project typed_ports "Typed ports" {
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
assert!(decoded.migration.is_none());
|
||||
let migration = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
|
||||
let canonical = language::format(&decoded.project);
|
||||
for field in [
|
||||
"incoming_items [@data]",
|
||||
@@ -69,7 +71,7 @@ fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
for (remove, expected) in [
|
||||
("source_port @output; ", "source_port"),
|
||||
("target_port @input; ", "target_port"),
|
||||
@@ -90,7 +92,7 @@ fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
let mixed = SOURCE.replace(
|
||||
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
|
||||
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Family {
|
||||
Data,
|
||||
Port,
|
||||
Occurrence,
|
||||
Message,
|
||||
Event,
|
||||
State,
|
||||
}
|
||||
const FAMILIES: [Family; 6] = [
|
||||
Family::Data,
|
||||
Family::Port,
|
||||
Family::Occurrence,
|
||||
Family::Message,
|
||||
Family::Event,
|
||||
Family::State,
|
||||
];
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, "reviewed", parent).unwrap()
|
||||
}
|
||||
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("review_{}", id.simple()),
|
||||
name: "Review".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn set(p: &mut Project, id: Id, body: SharedBody) {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
|
||||
}
|
||||
fn integer(p: &mut Project, id: Id) {
|
||||
set(
|
||||
p,
|
||||
id,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(0),
|
||||
maximum: Some(10),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let id = match family {
|
||||
Family::Data => {
|
||||
let id = add(&mut p, Kind::DataType, layer, None);
|
||||
integer(&mut p, id);
|
||||
id
|
||||
}
|
||||
Family::Port => {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
};
|
||||
let owner = add(&mut p, kind, layer, None);
|
||||
let id = Id::new_v4();
|
||||
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
|
||||
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
});
|
||||
}
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
Family::Occurrence | Family::Message => {
|
||||
let owner = add(&mut p, Kind::Scenario, layer, None);
|
||||
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
set(
|
||||
&mut p,
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Timer {
|
||||
expression: "10s".into(),
|
||||
},
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
Family::Event => {
|
||||
let id = add(&mut p, Kind::Event, layer, None);
|
||||
set(
|
||||
&mut p,
|
||||
id,
|
||||
SharedBody::Event {
|
||||
body: EventBody::Time {
|
||||
kind: TimeEventKind::After,
|
||||
expression: "10s".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
Family::State => {
|
||||
let machine = add(&mut p, Kind::StateMachine, layer, None);
|
||||
let region = add(&mut p, Kind::Region, layer, Some(machine));
|
||||
add(&mut p, Kind::State, layer, Some(region))
|
||||
}
|
||||
};
|
||||
pair.push(id);
|
||||
}
|
||||
let (oa, sa) = (pair[0], pair[1]);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
let (constructor, descendant) = match family {
|
||||
Family::Data => (
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
FacetSelector::Data(DataFacet::IntegerMaximum),
|
||||
),
|
||||
Family::Port => (
|
||||
FacetSelector::Port(PortFacet::Constructor),
|
||||
FacetSelector::Port(PortFacet::LegacyDirection),
|
||||
),
|
||||
Family::Occurrence => (
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
|
||||
),
|
||||
Family::Message => (
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
|
||||
),
|
||||
Family::Event => (
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
FacetSelector::Event(EventFacet::TimeExpression),
|
||||
),
|
||||
Family::State => (
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
FacetSelector::State(StateFacet::Entry),
|
||||
),
|
||||
};
|
||||
(p, oa, sa, t, constructor, descendant)
|
||||
}
|
||||
fn change_descendant(p: &mut Project, id: Id) {
|
||||
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { maximum, .. },
|
||||
..
|
||||
} => *maximum = Some(20),
|
||||
SharedBody::Port {
|
||||
legacy: Some(LegacyPortContract { direction, .. }),
|
||||
..
|
||||
} => *direction = PortDirection::Out,
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Timer { expression },
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
| SharedBody::Event {
|
||||
body: EventBody::Time { expression, .. },
|
||||
} => *expression = "20s".into(),
|
||||
SharedBody::State { entry, .. } => *entry = "initialize".into(),
|
||||
_ => panic!("Unexpected test family"),
|
||||
}
|
||||
}
|
||||
fn request(
|
||||
oa: Id,
|
||||
sa: Id,
|
||||
reverse: bool,
|
||||
resolutions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
) -> SemanticTransitionRequest {
|
||||
let (direction, source, target) = if reverse {
|
||||
(TransitionDirection::SaToOa, sa, oa)
|
||||
} else {
|
||||
(TransitionDirection::OaToSa, oa, sa)
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut r = SemanticTransitionRequest::new(transition);
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: resolutions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution { facet, decision })
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
|
||||
let plan = plan_semantic_transition(p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
(apply_semantic_transition(p, &plan).unwrap(), plan)
|
||||
}
|
||||
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
|
||||
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|f| f.selector == *selector)
|
||||
.unwrap()
|
||||
.drift
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
|
||||
for family in FAMILIES {
|
||||
for reverse in [false, true] {
|
||||
for decision in [
|
||||
SemanticDecision::KeepTarget,
|
||||
SemanticDecision::TakeSource,
|
||||
SemanticDecision::Candidate,
|
||||
] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
change_descendant(&mut p, target);
|
||||
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
r.candidates
|
||||
.push(SemanticRecord::Element(p.elements[&target].clone()));
|
||||
}
|
||||
let (q, plan) = apply(&p, r.clone());
|
||||
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert!(facts.iter().all(|f| f.drift
|
||||
== if f.selector == constructor {
|
||||
Drift::Unchanged
|
||||
} else {
|
||||
Drift::Unreviewed
|
||||
}));
|
||||
let (mut q, _) = apply(
|
||||
&q,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
|
||||
),
|
||||
);
|
||||
change_descendant(&mut q, source);
|
||||
assert_eq!(
|
||||
drift(&q, t, &descendant),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
let repeated = plan_semantic_transition(&q, r).unwrap();
|
||||
assert!(
|
||||
repeated
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&q, &repeated).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
|
||||
for family in FAMILIES {
|
||||
for reverse in [false, true] {
|
||||
for history in 0..3 {
|
||||
let (mut p, oa, sa, t, _, _) = fixture(family);
|
||||
if history == 0 {
|
||||
p.traces.get_mut(&t).unwrap().baseline = None;
|
||||
} else if history == 2 {
|
||||
p.traces
|
||||
.get_mut(&t)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline::default());
|
||||
}
|
||||
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.selector)
|
||||
.collect();
|
||||
let (q, plan) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
selectors
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|s| (s, SemanticDecision::KeepTarget))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
|
||||
assert!(!status.own_unreviewed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
|
||||
let json = include_str!("fixtures/v4-reviewed-prose.json")
|
||||
.replace("\"Real\"", "\"Integer\"")
|
||||
.replace("0.0", "0")
|
||||
.replace("100.0", "100")
|
||||
.replace("20.0", "20");
|
||||
for reverse in [false, true] {
|
||||
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
let p = decoded.project;
|
||||
let t = p.traces.values().next().unwrap();
|
||||
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
|
||||
let original_prose = t.baseline.clone().unwrap();
|
||||
let (oa, sa, id) = (t.oa, t.sa, t.id);
|
||||
let selections = semantic_facets(&p, t, &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| (f.selector, SemanticDecision::KeepTarget))
|
||||
.collect();
|
||||
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
|
||||
assert_eq!(q.elements, p.elements);
|
||||
let baseline = q.traces[&id].baseline.as_ref().unwrap();
|
||||
assert_eq!(baseline.oa, original_prose.oa);
|
||||
assert_eq!(baseline.sa, original_prose.sa);
|
||||
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(status.own, Drift::Unchanged);
|
||||
assert!(!status.own_unreviewed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let target = if reverse { oa } else { sa };
|
||||
let mut changed = p.clone();
|
||||
change_descendant(&mut changed, target);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::Candidate)],
|
||||
);
|
||||
r.candidates
|
||||
.push(SemanticRecord::Element(changed.elements[&target].clone()));
|
||||
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
|
||||
assert!(
|
||||
invalid
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("changes an unselected facet"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&p, &invalid).is_err());
|
||||
r.reviews[0].resolutions.push(FacetResolution {
|
||||
facet: descendant.clone(),
|
||||
decision: SemanticDecision::Candidate,
|
||||
});
|
||||
let (q, plan) = apply(&p, r);
|
||||
assert_eq!(q.elements, changed.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
|
||||
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
|
||||
assert_eq!(
|
||||
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
|
||||
Drift::Unreviewed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let (mut p, _) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![
|
||||
(constructor.clone(), SemanticDecision::KeepTarget),
|
||||
(descendant.clone(), SemanticDecision::KeepTarget),
|
||||
],
|
||||
),
|
||||
);
|
||||
let original = p.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
change_descendant(&mut p, if reverse { sa } else { oa });
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.reviews[0].reset.push(constructor.clone());
|
||||
let (q, plan) = apply(&p, r);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
assert_eq!(
|
||||
q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&original
|
||||
);
|
||||
assert_eq!(
|
||||
drift(&q, t, &descendant),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
|
||||
for switch in [false, true] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
|
||||
integer(&mut p, second);
|
||||
if switch {
|
||||
set(
|
||||
&mut p,
|
||||
second,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.),
|
||||
maximum: Some(30.),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
change_descendant(&mut p, second);
|
||||
}
|
||||
trace(&mut p, second, sa);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
false,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.transition.sources.push(second);
|
||||
r.transition.mappings.push(TransitionMapping {
|
||||
source: second,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
});
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: second,
|
||||
reset: vec![],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: if switch {
|
||||
constructor.clone()
|
||||
} else {
|
||||
descendant.clone()
|
||||
},
|
||||
decision: SemanticDecision::TakeSource,
|
||||
}],
|
||||
});
|
||||
for reversed in [false, true] {
|
||||
let mut r = r.clone();
|
||||
if reversed {
|
||||
r.reviews.reverse();
|
||||
}
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
if switch {
|
||||
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
|
||||
assert!(apply_semantic_transition(&p, &plan).is_err());
|
||||
} else {
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let q = apply_semantic_transition(&p, &plan).unwrap();
|
||||
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
|
||||
assert!(matches!(
|
||||
q.elements[&sa].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
maximum: Some(20),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
|
||||
for reverse in [false, true] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
|
||||
let source = if reverse { sa } else { oa };
|
||||
let target = if reverse { oa } else { sa };
|
||||
let mut action = p.elements[&source].shared().unwrap().clone();
|
||||
if let SharedBody::Occurrence { body, .. } = &mut action {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
set(&mut p, source, action.clone());
|
||||
let (q, plan) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
),
|
||||
);
|
||||
// One-sided applicability still has a constructor-group scope and records its timer leaf.
|
||||
assert_eq!(
|
||||
plan.reviews[0].acknowledged,
|
||||
vec![constructor.clone(), descendant.clone()]
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
|
||||
let mut p = q;
|
||||
set(&mut p, target, action);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.reviews[0].reset.push(constructor);
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
assert!(!plan.conflicts.is_empty());
|
||||
assert!(apply_semantic_transition(&p, &plan).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
let keep = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
let (mut p, _) = apply(&p, keep.clone());
|
||||
set(
|
||||
&mut p,
|
||||
source,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.),
|
||||
maximum: Some(20.),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
let (mut q, plan) = apply(&p, keep.clone());
|
||||
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
|
||||
assert!(plan.reviews[0].acknowledged.contains(&maximum));
|
||||
assert!(
|
||||
plan.reviews[0]
|
||||
.acknowledged
|
||||
.contains(&FacetSelector::Data(DataFacet::Default))
|
||||
);
|
||||
assert!(
|
||||
!plan.reviews[0]
|
||||
.acknowledged
|
||||
.contains(&FacetSelector::Data(DataFacet::Unit))
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
if let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real { maximum, .. },
|
||||
..
|
||||
}) = q.elements.get_mut(&source).unwrap().shared_mut()
|
||||
{
|
||||
*maximum = Some(30.);
|
||||
}
|
||||
let repeated = plan_semantic_transition(&q, keep).unwrap();
|
||||
assert!(
|
||||
repeated
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&q, &repeated).is_err());
|
||||
assert_eq!(
|
||||
drift(&q, t, &maximum),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
let take = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor, SemanticDecision::TakeSource)],
|
||||
);
|
||||
let (p, _) = apply(&q, take.clone());
|
||||
assert!(matches!(
|
||||
p.elements[&target].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
maximum: Some(30.),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let completed = plan_semantic_transition(&p, take).unwrap();
|
||||
assert!(
|
||||
completed
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&p, &completed).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
|
||||
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
|
||||
|
||||
#[test]
|
||||
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
|
||||
let json = persistence::decode_with_report(JSON4).unwrap();
|
||||
let source = language::parse_with_report(SOURCE4).unwrap();
|
||||
assert_eq!(json.project, source.project);
|
||||
for decoded in [json, source] {
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
let trace = decoded.project.traces.values().next().unwrap();
|
||||
let baseline = trace.baseline.as_ref().unwrap();
|
||||
assert!(baseline.semantic.is_none());
|
||||
assert_eq!(baseline.oa.name, "Approved OA name");
|
||||
assert_eq!(baseline.oa.description, "Approved OA\nprose");
|
||||
assert_eq!(baseline.sa.name, "Approved SA name");
|
||||
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
|
||||
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
expected["schema_version"] = 5.into();
|
||||
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
|
||||
let reopened =
|
||||
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
|
||||
.unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, decoded.project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_headers_cannot_admit_new_semantic_baselines() {
|
||||
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
let trace = value["traces"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap();
|
||||
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
|
||||
for version in [3, 4] {
|
||||
value["schema_version"] = version.into();
|
||||
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("semantic"), "{error}");
|
||||
}
|
||||
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
|
||||
assert!(
|
||||
language::parse(&source)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.message.contains("require schema/source5"))
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.message.contains("require schema/source5"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
|
||||
let json = std::str::from_utf8(JSON4).unwrap();
|
||||
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("duplicate field")
|
||||
);
|
||||
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
|
||||
assert!(
|
||||
persistence::decode(unknown.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
|
||||
let start = SOURCE4.find(" oa_data_type").unwrap();
|
||||
let end = SOURCE4.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} include \"contracts.arcadia\";\n}}\n",
|
||||
&SOURCE4[..start]
|
||||
);
|
||||
let mut module =
|
||||
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
|
||||
module.push_str(
|
||||
r#"
|
||||
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
|
||||
definition { Record { supertype none; }; };
|
||||
// This field remains inside its authored record.
|
||||
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry.clone()),
|
||||
("contracts.arcadia".into(), module.clone()),
|
||||
]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
|
||||
assert_eq!(
|
||||
compiled.bundle.files["project.arcadia"],
|
||||
entry.replacen("arcadia 4;", "arcadia 5;", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.index.lexical_owners
|
||||
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
|
||||
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
|
||||
);
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert_eq!(reopened.decoded.project, compiled.decoded.project);
|
||||
assert_eq!(
|
||||
bundle.files["project.arcadia"], entry,
|
||||
"caller draft is unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
|
||||
for (version, bytes) in [
|
||||
(
|
||||
1,
|
||||
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
|
||||
),
|
||||
(
|
||||
2,
|
||||
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
|
||||
),
|
||||
(
|
||||
3,
|
||||
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
|
||||
),
|
||||
] {
|
||||
let decoded = persistence::decode_with_report(bytes).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (version, 5));
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
assert!(
|
||||
decoded
|
||||
.project
|
||||
.traces
|
||||
.values()
|
||||
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&decoded.project)).unwrap(),
|
||||
decoded.project
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,431 @@
|
||||
use oa_core::{language, persistence, semantic::*, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
|
||||
}
|
||||
|
||||
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
|
||||
Reviewed {
|
||||
oa: Observed::Value(oa),
|
||||
sa: Observed::Value(sa),
|
||||
}
|
||||
}
|
||||
|
||||
fn project(facets: Vec<FacetReview>) -> Project {
|
||||
let mut project =
|
||||
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
|
||||
project
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline { facets });
|
||||
project
|
||||
}
|
||||
|
||||
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
|
||||
&mut project
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.facets
|
||||
}
|
||||
|
||||
fn compile(source: String) -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
|
||||
let row = MessageValue {
|
||||
path: vec![id(91), id(92)],
|
||||
field: id(93),
|
||||
expression: "x + 1".into(),
|
||||
};
|
||||
let model = project(vec![
|
||||
FacetReview::Data(DataReview::Constructor(reviewed(
|
||||
DataConstructor::Integer,
|
||||
DataConstructor::Real,
|
||||
))),
|
||||
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(None),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Data(DataReview::RealMaximum(Reviewed {
|
||||
oa: Observed::NotApplicable,
|
||||
sa: Observed::Value(Some(1.5)),
|
||||
})),
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
FacetReview::Field(FieldReview::Default(reviewed(
|
||||
Some(vec![DataValue::String(id(90).to_string())]),
|
||||
None,
|
||||
))),
|
||||
FacetReview::Member {
|
||||
collection: ReferenceCollection::Steps,
|
||||
keys: KeyPair {
|
||||
oa: Some(id(91)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Order {
|
||||
collection: OrderedCollection::Steps,
|
||||
review: reviewed(vec![id(91), id(92)], vec![]),
|
||||
},
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey::from(&row)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(Some(row), None),
|
||||
},
|
||||
FacetReview::Binding {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(94)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(
|
||||
Some(LifelineBinding {
|
||||
referenced: id(94),
|
||||
local: id(95),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(90).to_string()),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Artifact {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(96)),
|
||||
sa: None,
|
||||
},
|
||||
review: ArtifactReview::Relation(reviewed(
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::References,
|
||||
)),
|
||||
},
|
||||
]);
|
||||
assert!(!model.contains(id(90)));
|
||||
let source = language::format(&model);
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert!(
|
||||
source.contains(&format!("Named uuid \"{}\"", id(90))),
|
||||
"{source}"
|
||||
);
|
||||
assert!(source.contains("sa NotApplicable;"));
|
||||
assert!(source.contains("Value none;"));
|
||||
assert!(source.contains(&format!("String \"{}\"", id(90))));
|
||||
assert_eq!(language::parse(&source).unwrap(), model);
|
||||
assert_eq!(compile(source).decoded.project, model);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
|
||||
model
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
|
||||
let model = project(vec![
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(2)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
|
||||
]);
|
||||
let source = language::format(&model);
|
||||
let reference = format!("Named uuid \"{}\"", id(2));
|
||||
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
|
||||
let invalid = source.replace(&reference, &replacement);
|
||||
assert!(language::parse(&invalid).is_err(), "{invalid}");
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), invalid)])
|
||||
},
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
|
||||
assert!(language::parse(&invalid_text).is_err());
|
||||
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
|
||||
assert!(language::parse(&invalid_enum).is_err());
|
||||
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
|
||||
assert!(language::parse(&invalid_live).is_err());
|
||||
let invalid_prose = source.replace(
|
||||
"name \"Approved OA name\";",
|
||||
&format!("name uuid \"{}\";", id(2)),
|
||||
);
|
||||
assert!(language::parse(&invalid_prose).is_err());
|
||||
// `uuid` remains an ordinary legal declaration alias after @.
|
||||
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_values_remain_closed_typed_records_in_both_formats() {
|
||||
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
|
||||
reviewed(Some(0), None),
|
||||
))]);
|
||||
let source = language::format(&model);
|
||||
for invalid in [
|
||||
source.replace("IntegerMinimum", "UnknownFacet"),
|
||||
source.replace("Value 0;", "Value \"0\";"),
|
||||
source.replace("facets [", "unexpected true; facets ["),
|
||||
] {
|
||||
assert!(language::parse(&invalid).is_err(), "{invalid}");
|
||||
}
|
||||
let mut json = serde_json::to_value(&model).unwrap();
|
||||
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
|
||||
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
|
||||
let row = MessageValue {
|
||||
path: vec![id(2)],
|
||||
field: id(90),
|
||||
expression: id(2).to_string(),
|
||||
};
|
||||
let model = project(vec![
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey::from(&row)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(Some(row), None),
|
||||
},
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(2).to_string()),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(2)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
]);
|
||||
let source = language::format(&model)
|
||||
.replace("Annotation {", "Annotation { // annotation rationale")
|
||||
.replace("Literal {", "Literal { // literal rationale")
|
||||
.replace("Named uuid ", "Named uuid // historical type rationale\n");
|
||||
let current = compile(source);
|
||||
let mut prepared = prepare_command(
|
||||
¤t.decoded.project,
|
||||
&Command::Reidentify(id(2), id(20)),
|
||||
)
|
||||
.unwrap();
|
||||
facets(&mut prepared.project).reverse();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &updated.bundle.files["project.arcadia"];
|
||||
assert_eq!(updated.decoded.project, prepared.project);
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
"Named uuid // historical type rationale\n\"{}\"",
|
||||
id(20)
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
let type_note = text.find("// historical type rationale").unwrap();
|
||||
let literal_note = text.find("// literal rationale").unwrap();
|
||||
let annotation_note = text.find("// annotation rationale").unwrap();
|
||||
assert!(
|
||||
type_note < literal_note && literal_note < annotation_note,
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains(&format!("expression \"{}\"", id(2))));
|
||||
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
keys.oa,
|
||||
Some(id(2).to_string()),
|
||||
"UUID-shaped literal keys are text"
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
updated.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
|
||||
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
)))]);
|
||||
let mut former_type = model.elements[&id(2)].clone();
|
||||
former_type.id = id(90);
|
||||
former_type.alias = "former_type".into();
|
||||
model.elements.insert(id(90), former_type);
|
||||
let current = compile(language::format(&model));
|
||||
let prepared = prepare_command(¤t.decoded.project, &Command::Delete(id(90))).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = &updated.bundle.files["project.arcadia"];
|
||||
assert!(!updated.decoded.project.contains(id(90)));
|
||||
assert!(!source.contains("@former_type"));
|
||||
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
|
||||
assert_eq!(updated.decoded.project.traces, model.traces);
|
||||
assert_eq!(language::parse(source).unwrap(), prepared.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
|
||||
use oa_core::source_files::{load_project, save_project};
|
||||
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
)))]);
|
||||
let source = language::format(&model);
|
||||
let trace_at = source.find(" trace ").unwrap();
|
||||
let end = source.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} include \"reviews.arcadia\";\n}}\n",
|
||||
&source[..trace_at]
|
||||
);
|
||||
let review =
|
||||
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let entry_path = directory.path().join("project.arcadia");
|
||||
std::fs::write(&entry_path, &entry).unwrap();
|
||||
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
|
||||
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
|
||||
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
|
||||
let mut changed = compiled.decoded.project.clone();
|
||||
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
|
||||
panic!()
|
||||
};
|
||||
value.sa = Observed::Value(TypeRef::String);
|
||||
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.bundle.files["project.arcadia"], entry);
|
||||
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
|
||||
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(saved.bundle, reopened.bundle);
|
||||
assert_eq!(
|
||||
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
|
||||
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
|
||||
assert!(!changed.contains(id(90)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
|
||||
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
|
||||
let model = project(vec![]);
|
||||
let current = compile(language::format(&model).replace(
|
||||
"baseline {",
|
||||
"baseline { // prior prose review is independent",
|
||||
));
|
||||
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: id(2),
|
||||
target: Some(id(3)),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: id(2),
|
||||
resolutions: vec![
|
||||
FacetResolution {
|
||||
facet: FacetSelector::Data(DataFacet::Unit),
|
||||
decision: SemanticDecision::KeepTarget,
|
||||
},
|
||||
FacetResolution {
|
||||
facet: FacetSelector::Header(HeaderFacet::Name),
|
||||
decision: SemanticDecision::TakeSource,
|
||||
},
|
||||
],
|
||||
reset: vec![],
|
||||
});
|
||||
let plan = plan_semantic_transition(¤t.decoded.project, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let prepared = prepare_semantic_transition(¤t.decoded.project, &plan).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project.elements[&id(3)].name,
|
||||
"OA dimension now"
|
||||
);
|
||||
assert!(
|
||||
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
|
||||
);
|
||||
assert!(
|
||||
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
|
||||
);
|
||||
let baseline = updated.decoded.project.traces[&id(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(baseline.oa.name, "Approved OA name");
|
||||
assert!(
|
||||
baseline
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
|
||||
"m".into(),
|
||||
"ft".into()
|
||||
))))
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&updated.decoded.project)).unwrap(),
|
||||
prepared.project
|
||||
);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
|
||||
prepared.project
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SOURCE3: &str = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
|
||||
// Frozen JSON uses the same authored schema3 contract as the source fixture;
|
||||
// this helper never serializes current defaults under version3.
|
||||
fn original3() -> Value {
|
||||
serde_json::from_str(include_str!("fixtures/v3-data-contract.json")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() {
|
||||
let old = original3();
|
||||
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
let source = language::parse_with_report(SOURCE3).unwrap();
|
||||
assert_eq!(decoded.project, source.project);
|
||||
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!(project.identities().len(), 19);
|
||||
assert!(report.preserved_ports.is_empty());
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.generated_interface_allocations.len(), 2);
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
assert_eq!(
|
||||
allocation.id,
|
||||
Id::new_v5(
|
||||
&id(1),
|
||||
format!(
|
||||
"v2-interface-item:{}:{}",
|
||||
allocation.source, allocation.target
|
||||
)
|
||||
.as_bytes()
|
||||
)
|
||||
);
|
||||
assert_eq!(allocation.target, id(4));
|
||||
assert_eq!(allocation.order, 0);
|
||||
assert!(matches!(
|
||||
project.relations[&allocation.id].body,
|
||||
RelationBody::InterfaceItemAllocation {
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
let now = serde_json::to_value(project).unwrap();
|
||||
for port in [id(11), id(12)] {
|
||||
assert_eq!(
|
||||
now["elements"][port.to_string()],
|
||||
old["elements"][port.to_string()]
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
project.elements[&id(4)].shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Event,
|
||||
supertype: None
|
||||
})
|
||||
));
|
||||
assert_eq!(report.unresolved_message_values.len(), 1);
|
||||
assert!(
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(canonical, language::format(&reopened.project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_changed_payloads_reject_schema4_fields_and_new_constructor_names() {
|
||||
let mut old = original3();
|
||||
old["elements"][id(4).to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["mechanism"] =
|
||||
json!("Flow");
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let mut old = original3();
|
||||
old["elements"][id(5).to_string()]["body"]["Sa"]["Shared"]["DataType"]["definition"] =
|
||||
json!({"Record":{"supertype":null}});
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let mut old = original3();
|
||||
old["elements"][id(17).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]["body"]["Exchange"]
|
||||
["values"][0]["path"] = json!([]);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let source = SOURCE3.replace("kind Event;", "kind Information; mechanism Flow;");
|
||||
assert!(language::parse(&source).is_err());
|
||||
let source = SOURCE3.replace(
|
||||
"functional_exchange position_exchange",
|
||||
"interface_item_allocation position_exchange",
|
||||
);
|
||||
assert!(language::parse(&source).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_allocation_id_collision_is_located_without_overwriting_any_object() {
|
||||
let mut old = original3();
|
||||
let collision = Id::new_v5(
|
||||
&id(1),
|
||||
format!("v2-interface-item:{}:{}", id(9), id(4)).as_bytes(),
|
||||
);
|
||||
old["artifact_links"][collision.to_string()] = json!({"id":collision,"alias":"retained_artifact","name":"Retain","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
|
||||
let error = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap_err();
|
||||
match error {
|
||||
persistence::FileError::Model { issues, .. } => {
|
||||
assert_eq!(issues[0].code, "identity_collision");
|
||||
assert_eq!(issues[0].element, Some(id(9)));
|
||||
assert_eq!(issues[0].field.as_deref(), Some("items"));
|
||||
}
|
||||
other => panic!("unexpected error {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_legacy_nested_value_is_qualified_and_physical_nature_is_not_guessed() {
|
||||
let source = SOURCE3.replace("kind Event;", "kind Material;").replace(
|
||||
"type_ref { Named @coordinate; }; }\n }",
|
||||
"type_ref String; }\n }",
|
||||
);
|
||||
let decoded = language::parse_with_report(&source).unwrap();
|
||||
assert!(matches!(
|
||||
decoded.project.elements[&id(4)].shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
..
|
||||
})
|
||||
));
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = decoded.project.elements[&id(17)].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(values[0].path, vec![id(7)]);
|
||||
assert_eq!(values[0].expression, "navigation.latitude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
|
||||
let v2 =
|
||||
persistence::decode_with_report(include_bytes!("fixtures/v2-coastal-rescue.json")).unwrap();
|
||||
let text =
|
||||
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);
|
||||
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.migration.unwrap().from_schema, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_alias_collisions_are_stable_and_duplicate_legacy_payload_fields_reject() {
|
||||
let mut old = original3();
|
||||
let first = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
let alias = first.migration.unwrap().generated_interface_allocations[0]
|
||||
.alias
|
||||
.clone();
|
||||
old["artifact_links"][id(100).to_string()] = json!({"id":id(100),"alias":alias,"name":"Existing alias","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let migrated = persistence::decode_with_report(&bytes).unwrap();
|
||||
let generated = &migrated
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.generated_interface_allocations[0];
|
||||
assert_ne!(generated.alias, alias);
|
||||
assert!(generated.alias.starts_with(&alias));
|
||||
assert_eq!(
|
||||
migrated.project,
|
||||
persistence::decode_with_report(&bytes).unwrap().project
|
||||
);
|
||||
let duplicate = include_str!("fixtures/v3-data-contract.json").replace(
|
||||
"\"kind\": \"Event\"",
|
||||
"\"kind\": \"Event\", \"kind\": \"Material\"",
|
||||
);
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Duplicate field 'kind'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_legacy_record_paths_remain_ambiguous_instead_of_becoming_one_argument() {
|
||||
let mut old = original3();
|
||||
old["elements"][id(8).to_string()]["body"]["Sa"]["Shared"]["DataField"]["type_ref"] =
|
||||
json!("String");
|
||||
old["elements"][id(100).to_string()] = json!({"id":id(100),"alias":"next_coordinate","name":"Next coordinate","description":"","parent":id(5),"body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":id(5)},"minimum":0,"maximum":1,"unit":""}}}}});
|
||||
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.unresolved_message_values
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = decoded.project.elements[&id(17)].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(values[0].path.is_empty());
|
||||
assert!(
|
||||
validation::validate(&decoded.project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_identity() {
|
||||
let original = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
let header = original.find(" sa_function").unwrap();
|
||||
let close = original.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} // Contracts stay in their original module.\n include \"contracts.arcadia\";\n}}\n",
|
||||
&original[..header]
|
||||
);
|
||||
let fragment = original[header..close].replace(
|
||||
"items [@packet];",
|
||||
"items [@ // interface contract decision\npacket];",
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry),
|
||||
("contracts.arcadia".into(), fragment),
|
||||
]),
|
||||
};
|
||||
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("include \"contracts.arcadia\";"));
|
||||
for text in [
|
||||
"// Contracts stay in their original module.",
|
||||
"// Reused record parameters retain their own identities.",
|
||||
"// interface contract decision",
|
||||
] {
|
||||
assert!(
|
||||
compiled
|
||||
.bundle
|
||||
.files
|
||||
.values()
|
||||
.any(|source| source.contains(text)),
|
||||
"Lost {text}"
|
||||
);
|
||||
}
|
||||
assert_eq!(compiled.index.lexical_owners[&id(6)], id(5));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(7)], id(4));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(8)], id(4));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
|
||||
let report = compiled.decoded.migration.as_ref().unwrap();
|
||||
assert_eq!(report.from_schema, 3);
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
assert_eq!(
|
||||
compiled.index.declarations[&allocation.id].path,
|
||||
"contracts.arcadia"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let second = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(second.bundle, compiled.bundle);
|
||||
assert_eq!(second.decoded.project, compiled.decoded.project);
|
||||
assert!(second.decoded.migration.is_none());
|
||||
let mut renamed = compiled.decoded.project.clone();
|
||||
renamed.elements.get_mut(&id(6)).unwrap().name = "Latitude degrees".into();
|
||||
let updated = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.index.lexical_owners, compiled.index.lexical_owners);
|
||||
assert!(updated.bundle.files["contracts.arcadia"].contains("Latitude degrees"));
|
||||
assert!(
|
||||
bundle.files["project.arcadia"].contains("arcadia 3;"),
|
||||
"Migration does not mutate the caller's draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema4_typed_values_supertype_paths_and_protocols_roundtrip() {
|
||||
let source = r#"arcadia 4; project data_contract "Data contract" {
|
||||
sa_data_type bounded "Bounded" { definition { Integer { minimum -9223372036854775808; maximum 9223372036854775807; }; }; default { Integer -9223372036854775808; }; unit "m"; }
|
||||
sa_data_type label_type "Label" { definition { String { minimum_length 1; maximum_length 4; }; }; default { String "航海"; }; }
|
||||
sa_data_type base "Base" { definition { Record { supertype none; }; }; }
|
||||
sa_data_type derived "Derived" { definition { Record { supertype @base; }; }; }
|
||||
sa_exchange_item operation "Operation" { mechanism Operation;
|
||||
sa_data_field value "Value" { type_ref { Named @bounded; }; direction In; storage Value; default [{ Integer 42; }]; }
|
||||
}
|
||||
sa_interface contract "Contract" { supertype none; }
|
||||
interface_item_allocation member "Operation member" { source @contract; target @operation; transmission Synchronous; acquisition Accept; order 3; }
|
||||
data_association association "Related records" { source @base; target @derived; source_role "parent"; target_role "children"; source_minimum 0; source_maximum 1; target_minimum 0; target_maximum none; source_navigable true; target_navigable true; }
|
||||
}"#;
|
||||
let parsed = language::parse(source).unwrap();
|
||||
let formatted = language::format(&parsed);
|
||||
assert!(formatted.contains("supertype @base"));
|
||||
assert!(formatted.contains("transmission Synchronous"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), parsed);
|
||||
let compiled = compile_bundle(&format_bundle(&parsed), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, parsed);
|
||||
assert!(compiled.decoded.migration.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_ids_are_stabilized_before_allocation_identity_generation() {
|
||||
let bundle=SourceBundle{entry:"project.arcadia".into(),files:BTreeMap::from([("project.arcadia".into(),"arcadia 3; project old_model \"Old\" { sa_exchange_item event_item \"Event\" { kind Event; } sa_interface contract \"Contract\" { items [@event_item]; } }".into())])};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let report = compiled.decoded.migration.as_ref().unwrap();
|
||||
let allocation = &report.generated_interface_allocations[0];
|
||||
assert_eq!(
|
||||
allocation.id,
|
||||
Id::new_v5(
|
||||
&compiled.decoded.project.id,
|
||||
format!(
|
||||
"v2-interface-item:{}:{}",
|
||||
allocation.source, allocation.target
|
||||
)
|
||||
.as_bytes()
|
||||
)
|
||||
);
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.decoded.project, compiled.decoded.project);
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferring_a_legacy_path_keeps_rationale_inside_its_argument() {
|
||||
let source = include_str!("fixtures/v3-data-contract.arcadia")
|
||||
.replace(
|
||||
"type_ref { Named @coordinate; }; }\n }",
|
||||
"type_ref String; }\n }",
|
||||
)
|
||||
.replace(
|
||||
"field @latitude; expression",
|
||||
"field @latitude; // original argument rationale\nexpression",
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let source = &compiled.bundle.files["project.arcadia"];
|
||||
let argument = &source[source.find("values [{").unwrap()..];
|
||||
let argument = &argument[..argument.find('}').unwrap()];
|
||||
assert!(
|
||||
argument.contains("// original argument rationale"),
|
||||
"{source}"
|
||||
);
|
||||
assert!(argument.contains("path [@origin]"), "{source}");
|
||||
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_value_reordering_and_path_reidentification_keep_each_arguments_comments() {
|
||||
let mut project = language::parse(include_str!("fixtures/v3-data-contract.arcadia")).unwrap();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*values = vec![
|
||||
MessageValue {
|
||||
path: vec![id(7)],
|
||||
field: id(6),
|
||||
expression: "origin value".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![id(8)],
|
||||
field: id(6),
|
||||
expression: "destination value".into(),
|
||||
},
|
||||
];
|
||||
let mut bundle = format_bundle(&project);
|
||||
for source in bundle.files.values_mut() {
|
||||
*source = source
|
||||
.replace(
|
||||
"expression \"origin value\";",
|
||||
"expression \"origin value\"; // origin argument rationale",
|
||||
)
|
||||
.replace(
|
||||
"expression \"destination value\";",
|
||||
"expression \"destination value\"; // destination argument rationale",
|
||||
);
|
||||
}
|
||||
let current = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
values.reverse();
|
||||
let renamed = Id::from_u128(100);
|
||||
let mut identities: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
identities.insert(id(7), renamed);
|
||||
reidentify(&mut project, id(7), renamed).unwrap();
|
||||
project.elements.get_mut(&renamed).unwrap().alias = "revised_origin".into();
|
||||
let result =
|
||||
update_bundle_with_identity_map(¤t, &project, &identities, ProjectLimits::default())
|
||||
.unwrap();
|
||||
let source = &result.bundle.files[&result.index.declarations[&id(17)].path];
|
||||
let destination = source.find("// destination argument rationale").unwrap();
|
||||
let origin = source.find("// origin argument rationale").unwrap();
|
||||
assert!(
|
||||
destination < origin,
|
||||
"Distinct uses of the same terminal field must reorder as complete argument annotations: {source}"
|
||||
);
|
||||
assert!(source[origin..].contains("path [@revised_origin]"));
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
@@ -447,11 +447,12 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Caller requires service",
|
||||
@@ -499,11 +500,12 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
|
||||
let (mut p, a, b, item, forward) = fixture();
|
||||
let (old, _) = endpoints(&p, forward);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
|
||||
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
@@ -659,11 +661,12 @@ fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity()
|
||||
let (mut p, _, _, item, fe) = fixture();
|
||||
let output = endpoints(&p, fe).0;
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
@@ -811,16 +814,19 @@ fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_i
|
||||
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
|
||||
assert!(!edge.oriented);
|
||||
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
|
||||
assert!(
|
||||
view.ports
|
||||
.iter()
|
||||
.filter(|port| [
|
||||
p.relations[&boundary].source(),
|
||||
p.relations[&boundary].target()
|
||||
]
|
||||
.contains(&port.element))
|
||||
.all(|port| port.side == PortAnchorSide::Bottom)
|
||||
);
|
||||
for (element, side) in [
|
||||
(p.relations[&boundary].source(), PortAnchorSide::Right),
|
||||
(p.relations[&boundary].target(), PortAnchorSide::Left),
|
||||
] {
|
||||
assert_eq!(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|port| port.element == element)
|
||||
.unwrap()
|
||||
.side,
|
||||
side
|
||||
);
|
||||
}
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
assert_eq!(
|
||||
view.ports
|
||||
|
||||
@@ -94,7 +94,9 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
.add_element(Kind::DataType, layer, "Request record", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record,
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let field = p
|
||||
.add_element(Kind::DataField, layer, "Requested level", Some(record))
|
||||
@@ -160,6 +162,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}) = p.elements.get_mut(&call).unwrap().shared_mut()
|
||||
{
|
||||
values.push(MessageValue {
|
||||
path: vec![payload],
|
||||
field,
|
||||
expression: "requested_level".into(),
|
||||
});
|
||||
|
||||
@@ -357,9 +357,12 @@ fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
|
||||
size
|
||||
}
|
||||
),
|
||||
"{} overlaps {}",
|
||||
"{} overlaps {}: label {:?}, node {:?}, path {:?}",
|
||||
label.text,
|
||||
p.named(node.element)
|
||||
p.named(node.element),
|
||||
label.bounds,
|
||||
node,
|
||||
routed.edges[&edge.id].points
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use oa_core::{projection::project_diagram, routing::*, *};
|
||||
|
||||
fn crosses(bounds: RoutingBounds, a: Position, b: Position) -> bool {
|
||||
let left = bounds.position.x;
|
||||
let top = bounds.position.y;
|
||||
let right = left + bounds.size.width;
|
||||
let bottom = top + bounds.size.height;
|
||||
if a.x == b.x {
|
||||
a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top
|
||||
} else {
|
||||
a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(functional_first: bool) -> (Project, Id, Id, Id) {
|
||||
let mut project = Project::demo();
|
||||
let diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
// Freeze the observed default arrangement independently of random demo
|
||||
// identities. The two parallel border connections are only 20 units apart.
|
||||
for node in project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
{
|
||||
let (x, y, width, height) = match project.elements[&node.element].alias.as_str() {
|
||||
"vessel_communication_service" => (60., 60., 272., 220.),
|
||||
"rescue_coordination_system" => (572., 60., 272., 220.),
|
||||
"transmit_distress_report" => (84., 124., 224., 132.),
|
||||
"classify_incident_and_alert_operator" => (596., 124., 224., 132.),
|
||||
alias => panic!("Unexpected demo placement {alias}"),
|
||||
};
|
||||
node.position = Position { x, y };
|
||||
node.size = Size { width, height };
|
||||
}
|
||||
let old_fe = project
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "validated_distress_report")
|
||||
.unwrap()
|
||||
.id;
|
||||
let old_boundary = project
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "distress_reporting_connection")
|
||||
.unwrap()
|
||||
.id;
|
||||
let fe = Id::from_u128(if functional_first { 1 } else { 2 });
|
||||
let boundary = Id::from_u128(if functional_first { 2 } else { 1 });
|
||||
let project = execute_batch(
|
||||
&project,
|
||||
&[
|
||||
Command::Reidentify(old_fe, fe),
|
||||
Command::Reidentify(old_boundary, boundary),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
(project, diagram, fe, boundary)
|
||||
}
|
||||
|
||||
fn stroke_collisions(project: &Project, routed: &RoutedView) -> Vec<String> {
|
||||
let mut collisions = Vec::new();
|
||||
for edge in routed.edges.values().filter(|e| !e.label.text.is_empty()) {
|
||||
for other in routed.edges.values().filter(|e| e.id != edge.id) {
|
||||
for segment in other.points.windows(2) {
|
||||
if crosses(edge.label.bounds, segment[0], segment[1]) {
|
||||
collisions.push(format!(
|
||||
"{} ({}) label {:?} crosses {} ({}) segment {:?}",
|
||||
edge.label.text,
|
||||
edge.id,
|
||||
edge.label.bounds,
|
||||
project.named(other.id),
|
||||
other.id,
|
||||
segment
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collisions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_architecture_labels_do_not_cover_earlier_or_later_connection_strokes() {
|
||||
for functional_first in [true, false] {
|
||||
let (project, diagram, _, _) = fixture(functional_first);
|
||||
let before = project.clone();
|
||||
let d = &project.diagrams[&diagram];
|
||||
let view = project_diagram(&project, d);
|
||||
let routed = route_diagram(&project, d, &view);
|
||||
let collisions = stroke_collisions(&project, &routed);
|
||||
assert!(collisions.is_empty(), "{collisions:#?}");
|
||||
assert_eq!(project, before);
|
||||
assert_eq!(routed, route_diagram(&project, d, &view));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
let (mut project, diagram, fe, _) = fixture(true);
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let automatic = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let authored = EdgeLabel {
|
||||
fraction: 0.5,
|
||||
offset: Position { x: 0., y: -18. },
|
||||
};
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
let authored_snapshot = project.clone();
|
||||
let explicit = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let label = &explicit.edges[&fe].label;
|
||||
assert_eq!(label.fraction, authored.fraction);
|
||||
assert_eq!(
|
||||
label.center,
|
||||
Position {
|
||||
x: label.anchor.x + authored.offset.x,
|
||||
y: label.anchor.y + authored.offset.y
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
!stroke_collisions(&project, &explicit).is_empty(),
|
||||
"The authored label deliberately retains the native overlap"
|
||||
);
|
||||
assert_eq!(project, authored_snapshot);
|
||||
for (&id, edge) in &automatic.edges {
|
||||
assert_eq!(edge.points, explicit.edges[&id].points);
|
||||
assert_eq!(edge.pins, explicit.edges[&id].pins);
|
||||
assert_eq!(edge.warnings, explicit.edges[&id].warnings);
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes[0].label = None;
|
||||
let reset = route_edge_preview(&project, &project.diagrams[&diagram], &view, &explicit, fe);
|
||||
assert_eq!(reset.edges[&fe], automatic.edges[&fe]);
|
||||
for (&id, edge) in &explicit.edges {
|
||||
if id != fe {
|
||||
assert_eq!(reset.edges[&id], *edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(directory.path().join("parts")).unwrap();
|
||||
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
|
||||
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
|
||||
let directory = setup();
|
||||
let entry = directory.path().join("project.arcadia");
|
||||
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(
|
||||
directory.path().join("parts/a.arcadia"),
|
||||
"oa_activity a \"A\" { invalid",
|
||||
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
|
||||
let overlays = BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"parts/a.arcadia".into(),
|
||||
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
),
|
||||
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
|
||||
]),
|
||||
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
|
||||
.unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
|
||||
std::os::unix::fs::symlink(&destination, &link).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = load_project(
|
||||
|
||||
@@ -6,10 +6,10 @@ fn source() -> CompiledSourceProject {
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
|
||||
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
|
||||
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
|
||||
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
@@ -91,8 +91,9 @@ fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origi
|
||||
reidentify(&mut candidate, second, first).unwrap();
|
||||
reidentify(&mut candidate, temporary, second).unwrap();
|
||||
let contract = find(&candidate, "contract");
|
||||
let Some(SharedBody::Interface { items }) =
|
||||
candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("interface")
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap()
|
||||
|
||||
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
|
||||
#[test]
|
||||
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -95,7 +95,7 @@ diagram review "Review" {
|
||||
#[test]
|
||||
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
let mut input = bundle(
|
||||
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
|
||||
fn typed() -> SourceBundle {
|
||||
bundle(
|
||||
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
&[
|
||||
(
|
||||
"exchanges.arcadia",
|
||||
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
#[test]
|
||||
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
|
||||
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
#[test]
|
||||
fn source_errors_keep_exact_fragment_unicode_positions() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"// Пример\nsa_function child \"航海\" { parent @missing; }",
|
||||
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
),
|
||||
] {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[("a.arcadia", left), ("b.arcadia", right)],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
assert_eq!(error.related[0].path, "a.arcadia");
|
||||
}
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
#[test]
|
||||
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
|
||||
let mut input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
&[
|
||||
("sub/a.arcadia", "include \"../shared.arcadia\";"),
|
||||
("sub/b.arcadia", "include \"../shared.arcadia\";"),
|
||||
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
);
|
||||
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" {}",
|
||||
"arcadia 5; project p \"P\" {}",
|
||||
&[("A.arcadia", ""), ("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
#[test]
|
||||
fn project_admission_is_aggregate_and_precedes_global_resolution() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
|
||||
("b.arcadia", "oa_activity two \"Two\" {}"),
|
||||
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
|
||||
|
||||
#[test]
|
||||
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
|
||||
let source = "arcadia 3; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
assert!(
|
||||
language::parse(source).unwrap_err()[0]
|
||||
.message
|
||||
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
|
||||
#[test]
|
||||
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
|
||||
let input = bundle(
|
||||
"arcadia 3;project p\"P\"{include \"child.arcadia\";}",
|
||||
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
|
||||
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
|
||||
#[test]
|
||||
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
&[("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
|
||||
"project_limit"
|
||||
);
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"a.arcadia",
|
||||
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
|
||||
&source[span.start..span.end],
|
||||
"include \"../shared.arcadia\";"
|
||||
);
|
||||
let entry = "arcadia 3; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
assert_eq!(
|
||||
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
|
||||
.target,
|
||||
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
|
||||
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
|
||||
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
|
||||
#[test]
|
||||
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
|
||||
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
|
||||
#[test]
|
||||
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
|
||||
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
|
||||
#[test]
|
||||
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
|
||||
&[(
|
||||
"item.arcadia",
|
||||
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
|
||||
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
|
||||
#[test]
|
||||
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
|
||||
&[(
|
||||
"event.arcadia",
|
||||
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
|
||||
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
|
||||
#[test]
|
||||
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
&[(
|
||||
"ports.arcadia",
|
||||
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
|
||||
@@ -1017,10 +1017,10 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
#[test]
|
||||
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
@@ -1029,8 +1029,9 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
if let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b, a];
|
||||
} else {
|
||||
@@ -1051,10 +1052,10 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
#[test]
|
||||
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
@@ -1063,8 +1064,9 @@ fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations()
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.remove(&a);
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
if let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b];
|
||||
} else {
|
||||
@@ -1102,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
}; }; }
|
||||
"#;
|
||||
let current = compile(&bundle(
|
||||
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
&[("conversation.arcadia", source)],
|
||||
));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -1151,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
#[test]
|
||||
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
|
||||
@@ -1160,7 +1162,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
|
||||
let empty = compile(&input);
|
||||
let mut project = empty.decoded.project.clone();
|
||||
let values = id(&project, "values");
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
@@ -1185,7 +1187,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
|
||||
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
|
||||
);
|
||||
let annotated = compile(&annotated);
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
|
||||
@@ -42,6 +42,7 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
};
|
||||
p.traces.insert(id, t);
|
||||
@@ -328,11 +329,12 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||
let id = Id::new_v4();
|
||||
let contract = if p.elements[&parent].kind() == Kind::Function {
|
||||
@@ -740,6 +742,9 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: "items".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||
@@ -804,8 +809,40 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
|
||||
"Reidentification must preserve all values and order"
|
||||
);
|
||||
if id != p.id {
|
||||
let deleted = execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
|
||||
let deleted = if p
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
{
|
||||
// An explicitly empty interface cannot silently accept the
|
||||
// still-carried payload. Removing its definition needs a
|
||||
// reviewed edit of that dependent contract in the same batch.
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Delete(id))
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("port_interface_items")
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
let item = p.relations[&id].target();
|
||||
let mut commands = vec![Command::Delete(id)];
|
||||
commands.extend(
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::BoundaryExchange
|
||||
&& r.body.items().contains(&item)
|
||||
})
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
execute_batch(&p, &commands).unwrap()
|
||||
} else {
|
||||
execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
|
||||
};
|
||||
accepted(&deleted);
|
||||
}
|
||||
}
|
||||
@@ -890,13 +927,15 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material
|
||||
kind: ExchangeItemKind::Material,
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy
|
||||
kind: ExchangeItemKind::Energy,
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -13,5 +13,14 @@ path = "src/main.rs"
|
||||
oa-core = { path = "../oa-core" }
|
||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
||||
rfd = "0.15"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
oa-server = { path = "../oa-server" }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
|
||||
+1155
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
use super::*;
|
||||
use crate::behavior::{self, states};
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
machine: Id,
|
||||
source: Id,
|
||||
target: Id,
|
||||
work: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(
|
||||
Kind::StateMachine,
|
||||
Layer::Sa,
|
||||
"Operating modes",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Operation", Some(machine))
|
||||
.unwrap();
|
||||
let source = project
|
||||
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor connection", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(source));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
Fixture {
|
||||
app,
|
||||
machine,
|
||||
source,
|
||||
target,
|
||||
work,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
// Do not poll returned I/O tasks: these tests exercise the App transaction
|
||||
// boundary without displaying a file dialog or writing to the filesystem.
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn state_event(app: &mut App, event: states::Event) {
|
||||
send(app, Message::Behavior(behavior::Event::States(event)));
|
||||
}
|
||||
|
||||
fn choice(app: &App, id: Id) -> Choice {
|
||||
Choice {
|
||||
id,
|
||||
label: app.project().named(id).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_transition(fixture: &mut Fixture, complete: bool) {
|
||||
let Fixture {
|
||||
app,
|
||||
machine,
|
||||
source,
|
||||
target,
|
||||
work,
|
||||
} = fixture;
|
||||
state_event(
|
||||
app,
|
||||
states::Event::Start(*machine, states::Tool::Transition, *source),
|
||||
);
|
||||
for event in [
|
||||
states::Event::Name("Wait for recovery".into()),
|
||||
states::Event::Trigger(choice(app, *work)),
|
||||
states::Event::Effect(choice(app, *work)),
|
||||
states::Event::TriggerNotes("A monitor result".into()),
|
||||
states::Event::Guard("connection unavailable".into()),
|
||||
states::Event::EffectNotes("Record the timeout".into()),
|
||||
states::Event::Timed(true),
|
||||
states::Event::TimeKind("At".into()),
|
||||
states::Event::Expression("12:30 UTC".into()),
|
||||
] {
|
||||
state_event(app, event);
|
||||
}
|
||||
if complete {
|
||||
let target = choice(app, *target);
|
||||
state_event(app, states::Event::Target(target));
|
||||
}
|
||||
assert_eq!(app.behavior.pending_owner(), Some(*machine));
|
||||
}
|
||||
|
||||
fn assert_typed_transition(app: &App, source: Id, target: Id, work: Id) {
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
let relation = app.project().relations.values().next().unwrap();
|
||||
assert_eq!(relation.name, "Wait for recovery");
|
||||
let RelationBody::StateTransition {
|
||||
source: actual_source,
|
||||
target: actual_target,
|
||||
triggers,
|
||||
effects,
|
||||
trigger,
|
||||
guard,
|
||||
effect,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
panic!("Expected the prepared state transition");
|
||||
};
|
||||
assert_eq!((*actual_source, *actual_target), (source, target));
|
||||
assert_eq!(effects, &[work]);
|
||||
assert_eq!(trigger, "A monitor result");
|
||||
assert_eq!(guard, "connection unavailable");
|
||||
assert_eq!(effect, "Record the timeout");
|
||||
assert_eq!(triggers.len(), 2);
|
||||
assert_eq!(triggers[0], work);
|
||||
assert!(matches!(
|
||||
app.project().elements[&triggers[1]].shared(),
|
||||
Some(SharedBody::Event {
|
||||
body: EventBody::Time { kind: TimeEventKind::At, expression }
|
||||
}) if expression == "12:30 UTC"
|
||||
));
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Event)
|
||||
.count(),
|
||||
1,
|
||||
"Retry must not leave a time event from the rejected transaction"
|
||||
);
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_writeback_failure_keeps_typed_behavior_form_for_repair_and_one_undo() {
|
||||
let mut fixture = fixture();
|
||||
let bundle = fixture.app.document.bundle().clone();
|
||||
let mut limits = ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 2000;
|
||||
fixture.app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
fixture.app.sync();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
state_event(app, states::Event::Name("Transition".repeat(1024)));
|
||||
let prepared = app
|
||||
.behavior
|
||||
.prepare_pending(app.project())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(execute_batch(app.project(), &[prepared]).is_ok());
|
||||
|
||||
state_event(app, states::Event::Submit);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.behavior.has_pending());
|
||||
assert!(app.dirty());
|
||||
assert!(!app.source_dirty());
|
||||
|
||||
state_event(app, states::Event::Name("Wait for recovery".into()));
|
||||
state_event(app, states::Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert_eq!(app.source_workspace.bundle(), *app.document.bundle());
|
||||
let generated_files: Vec<_> = app
|
||||
.document
|
||||
.bundle()
|
||||
.files
|
||||
.iter()
|
||||
.filter(|(path, _)| !bundle.files.contains_key(*path))
|
||||
.map(|(path, text)| (path.clone(), text.clone()))
|
||||
.collect();
|
||||
assert!(
|
||||
!generated_files.is_empty(),
|
||||
"The time event creates a source file"
|
||||
);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
bundle
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_workspace.reachable,
|
||||
bundle.files.keys().cloned().collect::<BTreeSet<_>>()
|
||||
);
|
||||
// Undo restores the accepted bundle exactly. Workspace retains clean buffers
|
||||
// from removed includes so the user may inspect or explicitly close them.
|
||||
for (path, text) in generated_files {
|
||||
assert!(!app.source_workspace.reachable.contains(&path));
|
||||
let detached = &app.source_workspace.files[&path];
|
||||
assert_eq!(detached.text, text);
|
||||
assert!(!detached.dirty());
|
||||
}
|
||||
assert!(!app.source_workspace.detached_dirty());
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_behavior_form_is_dirty_close_save_retains_it_and_cancel_allows_switching() {
|
||||
let mut fixture = fixture();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, false);
|
||||
let app = &mut fixture.app;
|
||||
assert!(!app.document.dirty());
|
||||
assert!(app.dirty());
|
||||
assert!(app.title().contains(" *"));
|
||||
let window = window::Id::unique();
|
||||
send(app, Message::Request(Pending::Close(window)));
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
|
||||
send(app, Message::SaveAndContinue);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.pending.is_none());
|
||||
assert!(!app.continue_after_save);
|
||||
assert!(!app.busy);
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
|
||||
state_event(app, states::Event::Cancel);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(!app.dirty());
|
||||
send(app, Message::Page(Page::Text));
|
||||
assert_eq!(app.page, Page::Text);
|
||||
send(app, Message::Select(fixture.target));
|
||||
assert_eq!(app.selected, fixture.target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_prepares_behavior_before_opening_destination_and_cancel_keeps_one_undo() {
|
||||
let mut fixture = fixture();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
send(app, Message::Save(true));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert!(
|
||||
app.document.dirty(),
|
||||
"Choosing a destination is not a successful disk save"
|
||||
);
|
||||
assert!(app.file_prompt.is_some() || app.busy);
|
||||
if app.file_prompt.is_some() {
|
||||
send(app, Message::CancelFile);
|
||||
} else {
|
||||
send(app, Message::SavePath(None));
|
||||
}
|
||||
assert!(!app.busy);
|
||||
assert!(app.document.dirty());
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
|
||||
let mut fixture = fixture();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
for message in [
|
||||
Message::Page(Page::Diagram),
|
||||
Message::Page(Page::Catalog),
|
||||
Message::Page(Page::Text),
|
||||
Message::Page(Page::Guide),
|
||||
Message::Layer(Layer::Oa),
|
||||
Message::Select(fixture.target),
|
||||
Message::Explore(Some(fixture.target)),
|
||||
Message::BrowseGroup(crate::explorer::Group::Work),
|
||||
Message::Field("/name".into(), "Overwrite properties".into()),
|
||||
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
|
||||
Message::ConfirmDelete,
|
||||
Message::Undo,
|
||||
Message::Redo,
|
||||
Message::ShortcutUndo(false),
|
||||
Message::SourceForSelection,
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Open(false)),
|
||||
] {
|
||||
send(app, message);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, fixture.source);
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert_eq!(app.behavior.pending_owner(), Some(fixture.machine));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
properties
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
|
||||
let mut fixture = fixture();
|
||||
let app = &mut fixture.app;
|
||||
let before = app.document.snapshot();
|
||||
send(
|
||||
app,
|
||||
Message::Field("/name".into(), "Unapplied state name".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let context = behavior::topology::Context {
|
||||
document: app.canvas_document,
|
||||
owner: fixture.machine,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
};
|
||||
for event in [
|
||||
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, None),
|
||||
] {
|
||||
send(app, Message::TopologyCanvas(event));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_behavior_blocks_source_creation_and_source_history_replay() {
|
||||
for redo in [false, true] {
|
||||
let mut fixture = fixture();
|
||||
let app = &mut fixture.app;
|
||||
let baseline = app.source_workspace.bundle();
|
||||
let edited = format!(
|
||||
"{}\n// Source draft that must stay in history.\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(edited),
|
||||
))),
|
||||
);
|
||||
send(
|
||||
app,
|
||||
if redo {
|
||||
Message::SourceUndo
|
||||
} else {
|
||||
Message::RevertSource
|
||||
},
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
send(
|
||||
app,
|
||||
if redo {
|
||||
Message::SourceRedo
|
||||
} else {
|
||||
Message::SourceUndo
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_workspace.bundle(),
|
||||
baseline,
|
||||
"Source history must not create a competing draft"
|
||||
);
|
||||
send(app, Message::SourceNewPath("queued.arcadia".into()));
|
||||
send(app, Message::SourceCreateFile);
|
||||
assert_eq!(
|
||||
app.source_workspace.bundle(),
|
||||
baseline,
|
||||
"A queued create-file action must not create a competing draft"
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.behavior.pending_owner(), Some(fixture.machine));
|
||||
state_event(app, states::Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
|
||||
use super::*;
|
||||
use crate::hosted::{
|
||||
Event, Operation, Outcome, Request, Review, Session,
|
||||
client::{Connection, Failure, Remote},
|
||||
};
|
||||
|
||||
impl App {
|
||||
pub fn hosted_label(&self) -> String {
|
||||
self.hosted.session.as_ref().map_or_else(
|
||||
|| {
|
||||
if self.dirty() {
|
||||
"Unsaved changes".into()
|
||||
} else if self.path.is_some() {
|
||||
"All changes saved".into()
|
||||
} else {
|
||||
"Local project".into()
|
||||
}
|
||||
},
|
||||
|session| {
|
||||
format!(
|
||||
"Hosted · revision {} · {}",
|
||||
session.revision,
|
||||
if self.dirty() {
|
||||
"local changes"
|
||||
} else {
|
||||
"saved"
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn save_label(&self) -> &'static str {
|
||||
if self.hosted.session.is_some() {
|
||||
"Save to server"
|
||||
} else {
|
||||
"Save all"
|
||||
}
|
||||
}
|
||||
|
||||
fn hosted_problem(&mut self, detail: impl Into<String>) {
|
||||
let detail = detail.into();
|
||||
self.status = detail.clone();
|
||||
self.hosted.error = Some(Failure {
|
||||
detail,
|
||||
code: "desktop_hosted".into(),
|
||||
status: None,
|
||||
uncertain: false,
|
||||
published: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn hosted_ready(&mut self) -> bool {
|
||||
if self.pending_recovery.is_some() {
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
self.hosted.visible = false;
|
||||
self.page = Page::Text;
|
||||
self.status =
|
||||
"Recover the interrupted source save before changing storage destinations.".into();
|
||||
return false;
|
||||
}
|
||||
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
||||
self.hosted.visible = false;
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return false;
|
||||
}
|
||||
if self.source_workspace.detached_dirty() {
|
||||
self.hosted.visible = false;
|
||||
self.page = Page::Text;
|
||||
self.status =
|
||||
"Reattach or revert detached source drafts before saving to a server.".into();
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn hosted_start(
|
||||
&mut self,
|
||||
operation: Operation,
|
||||
connection: Connection,
|
||||
resource_id: Option<Id>,
|
||||
) -> Task<Message> {
|
||||
let request = Request {
|
||||
id: Id::new_v4(),
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
operation,
|
||||
connection,
|
||||
resource_id,
|
||||
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
|
||||
snapshot: self.document.snapshot(),
|
||||
};
|
||||
self.hosted.request = Some(request.clone());
|
||||
if operation != Operation::Check {
|
||||
self.hosted.error = None;
|
||||
}
|
||||
self.busy = true;
|
||||
self.status = match operation {
|
||||
Operation::Catalog(_) => "Reading hosted models…",
|
||||
Operation::Open(_) => "Opening hosted model…",
|
||||
Operation::Publish => "Publishing source project…",
|
||||
Operation::Save => "Saving to server…",
|
||||
Operation::Check => "Checking current server revision…",
|
||||
}
|
||||
.into();
|
||||
let query = self.hosted.query.clone();
|
||||
let after = if operation == Operation::Catalog(true) {
|
||||
self.hosted.next
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Task::perform(
|
||||
async move {
|
||||
let result = match operation {
|
||||
Operation::Catalog(_) => request
|
||||
.connection
|
||||
.catalog(&query, after)
|
||||
.await
|
||||
.map(Outcome::Catalog),
|
||||
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
|
||||
Operation::Publish => request
|
||||
.connection
|
||||
.publish(request.snapshot.bundle.clone())
|
||||
.await
|
||||
.map(Outcome::Remote),
|
||||
Operation::Save => request
|
||||
.connection
|
||||
.save(
|
||||
request.resource_id.expect("save has a resource"),
|
||||
request.etag.as_deref().expect("save has a precondition"),
|
||||
request.snapshot.bundle.clone(),
|
||||
)
|
||||
.await
|
||||
.map(Outcome::Remote),
|
||||
Operation::Check => request
|
||||
.connection
|
||||
.open(request.resource_id.expect("check has a resource"))
|
||||
.await
|
||||
.map(Outcome::Remote),
|
||||
};
|
||||
Message::Hosted(Event::Completed(request.id, request.document, result))
|
||||
},
|
||||
|message| message,
|
||||
)
|
||||
}
|
||||
pub(super) fn open_hosted(&mut self) -> Task<Message> {
|
||||
if self.pending_recovery.is_some() {
|
||||
self.hosted_problem(
|
||||
"Recover the interrupted source save before opening another model.",
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
if let Some((connection, id)) = self.hosted.open_target.take() {
|
||||
return self.hosted_start(Operation::Open(id), connection, Some(id));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub(super) fn save_hosted(&mut self) -> Task<Message> {
|
||||
if !self.hosted_ready() {
|
||||
return Task::none();
|
||||
}
|
||||
if self.hosted.review.is_some() {
|
||||
self.hosted.visible = true;
|
||||
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(session) = &self.hosted.session {
|
||||
return self.hosted_start(
|
||||
Operation::Save,
|
||||
session.connection.clone(),
|
||||
Some(session.resource_id),
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn check_hosted(&mut self) -> Task<Message> {
|
||||
let destination = self
|
||||
.hosted
|
||||
.uncertain
|
||||
.as_ref()
|
||||
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
|
||||
.or_else(|| {
|
||||
self.hosted
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|s| (s.connection.clone(), s.resource_id))
|
||||
});
|
||||
if let Some((connection, id)) = destination {
|
||||
return self.hosted_start(Operation::Check, connection, Some(id));
|
||||
}
|
||||
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
|
||||
if remote.compiled.bundle != request.snapshot.bundle {
|
||||
self.hosted.review = Some(Review {
|
||||
connection: request.connection.clone(),
|
||||
remote,
|
||||
});
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.visible = true;
|
||||
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
resource_id: remote.resource_id,
|
||||
revision: remote.revision,
|
||||
etag: remote.etag,
|
||||
});
|
||||
self.path = None;
|
||||
self.disk_project = None;
|
||||
self.document.mark_saved(request.snapshot.clone());
|
||||
self.hosted.uncertain = None;
|
||||
self.hosted.review = None;
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.error = None;
|
||||
self.status = format!(
|
||||
"Hosted revision {} saved. Undo remains local until your next Save.",
|
||||
remote.revision
|
||||
);
|
||||
// A late receipt may save an older snapshot; it must not close newer work.
|
||||
if self.continue_after_save {
|
||||
self.continue_after_save = false;
|
||||
if request.revision == self.document.revision()
|
||||
&& !self.dirty()
|
||||
&& let Some(pending) = self.pending.take()
|
||||
{
|
||||
return self.proceed(pending);
|
||||
}
|
||||
self.pending = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Show => {
|
||||
self.hosted.visible = true;
|
||||
self.model_menu = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::Close => self.hosted.visible = false,
|
||||
Event::Base(base) => {
|
||||
self.hosted.base = base;
|
||||
self.hosted.connection = None;
|
||||
self.hosted.catalog.clear();
|
||||
self.hosted.invalidate_catalog();
|
||||
self.hosted.selected = None;
|
||||
}
|
||||
Event::Token(token) => {
|
||||
self.hosted.token = token;
|
||||
self.hosted.connection = None;
|
||||
self.hosted.catalog.clear();
|
||||
self.hosted.selected = None;
|
||||
self.hosted.invalidate_catalog();
|
||||
}
|
||||
Event::Query(query) => {
|
||||
if self.hosted.query != query {
|
||||
self.hosted.query = query;
|
||||
self.hosted.selected = None;
|
||||
self.hosted.invalidate_catalog();
|
||||
}
|
||||
}
|
||||
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
|
||||
Ok(connection) => {
|
||||
self.hosted.invalidate_catalog();
|
||||
return self.hosted_start(Operation::Catalog(false), connection, None);
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.detail.clone();
|
||||
self.hosted.error = Some(error);
|
||||
}
|
||||
},
|
||||
Event::More => {
|
||||
if !self.hosted.catalog_stale
|
||||
&& self.hosted.next.is_some()
|
||||
&& let Some(connection) = &self.hosted.connection
|
||||
{
|
||||
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
|
||||
}
|
||||
}
|
||||
Event::Select(id) => self.hosted.selected = Some(id),
|
||||
Event::OpenSelected => {
|
||||
if let (Some(connection), Some(id)) =
|
||||
(&self.hosted.connection, self.hosted.selected)
|
||||
{
|
||||
self.hosted.open_target = Some((connection.clone(), id));
|
||||
self.hosted.visible = false;
|
||||
return self.handle(Message::Request(Pending::HostedOpen));
|
||||
}
|
||||
}
|
||||
Event::Publish => {
|
||||
if !self.hosted_ready() {
|
||||
return Task::none();
|
||||
}
|
||||
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
self.status = error.detail.clone();
|
||||
self.hosted.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
return self.hosted_start(Operation::Publish, connection, None);
|
||||
}
|
||||
Event::Save => return self.save_hosted(),
|
||||
Event::Check => return self.check_hosted(),
|
||||
Event::ReviewFile(path) => {
|
||||
if let Some(source) = self
|
||||
.hosted
|
||||
.review
|
||||
.as_ref()
|
||||
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
|
||||
{
|
||||
self.hosted.review_editor = text_editor::Content::with_text(source);
|
||||
self.hosted.review_path = Some(path);
|
||||
}
|
||||
}
|
||||
Event::ReviewAction(action) => {
|
||||
if !action.is_edit() {
|
||||
self.hosted.review_editor.perform(action);
|
||||
}
|
||||
}
|
||||
Event::ReviewBack => self.hosted.review_path = None,
|
||||
Event::Reload => {
|
||||
if !self.hosted_ready() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(review) = self.hosted.review.clone() {
|
||||
let remote = review.remote;
|
||||
let connection = review.connection;
|
||||
{
|
||||
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||
Ok(_) => {
|
||||
self.document.mark_saved(remote.compiled.clone());
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
resource_id: remote.resource_id,
|
||||
revision: remote.revision,
|
||||
etag: remote.etag,
|
||||
});
|
||||
self.path = None;
|
||||
self.disk_project = None;
|
||||
self.hosted.review = None;
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.uncertain = None;
|
||||
self.hosted.error = None;
|
||||
self.sync();
|
||||
self.hosted.visible = false;
|
||||
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
|
||||
}
|
||||
Err(error) => self.hosted_problem(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Local => {
|
||||
self.hosted.visible = false;
|
||||
return self.handle(Message::Save(true));
|
||||
}
|
||||
Event::Forget => {
|
||||
if self.hosted.session.is_some() {
|
||||
self.hosted_problem("Save as local before disconnecting this hosted model.");
|
||||
} else {
|
||||
self.hosted = Default::default();
|
||||
self.hosted.visible = true;
|
||||
}
|
||||
}
|
||||
Event::Completed(id, document, result) => {
|
||||
let Some(request) = self.hosted.request.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if request.id != id
|
||||
|| request.document != document
|
||||
|| document != self.canvas_document
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let request = self.hosted.request.take().expect("matching request");
|
||||
self.busy = false;
|
||||
// A catalog is a read snapshot, never a live mirror. Known or
|
||||
// uncertain writes and observed newer revisions invalidate only
|
||||
// the service whose results are currently displayed.
|
||||
let catalog_changed = match &result {
|
||||
Ok(Outcome::Remote(remote)) => {
|
||||
matches!(request.operation, Operation::Publish | Operation::Save)
|
||||
// A changed bound head can enter/leave the active
|
||||
// filter or page even when no row was displayed.
|
||||
|| self.hosted.session.as_ref().is_some_and(|session| {
|
||||
session.connection.base() == request.connection.base()
|
||||
&& session.resource_id == remote.resource_id
|
||||
&& session.etag != remote.etag
|
||||
})
|
||||
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
|
||||
failed.operation == Operation::Save
|
||||
&& failed.connection.base() == request.connection.base()
|
||||
&& failed.resource_id == Some(remote.resource_id)
|
||||
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
|
||||
})
|
||||
|| self.hosted.catalog.iter().any(|item| {
|
||||
item.resource_id == remote.resource_id
|
||||
&& item.revision != remote.revision
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
matches!(request.operation, Operation::Publish | Operation::Save)
|
||||
&& (error.uncertain || error.status == Some(412))
|
||||
}
|
||||
Ok(Outcome::Catalog(_)) => false,
|
||||
};
|
||||
if catalog_changed
|
||||
&& self
|
||||
.hosted
|
||||
.connection
|
||||
.as_ref()
|
||||
.is_some_and(|connection| connection.base() == request.connection.base())
|
||||
{
|
||||
self.hosted.invalidate_catalog();
|
||||
}
|
||||
match result {
|
||||
Err(error) => {
|
||||
let conflict =
|
||||
error.status == Some(412) && request.operation == Operation::Save;
|
||||
if error.uncertain || conflict {
|
||||
let mut failed = request.clone();
|
||||
if let Some(id) = error.published {
|
||||
failed.resource_id = Some(id);
|
||||
}
|
||||
if matches!(request.operation, Operation::Save | Operation::Publish) {
|
||||
self.hosted.uncertain = Some(failed);
|
||||
}
|
||||
}
|
||||
self.status = error.detail.clone();
|
||||
self.hosted.error = Some(error);
|
||||
self.hosted.visible = true;
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
if conflict {
|
||||
return self.check_hosted();
|
||||
}
|
||||
}
|
||||
Ok(Outcome::Catalog(catalog)) => {
|
||||
if let Some(session) = &mut self.hosted.session
|
||||
&& session.connection.base() == request.connection.base()
|
||||
{
|
||||
session.connection = request.connection.clone();
|
||||
}
|
||||
if let Some(failed) = &mut self.hosted.uncertain
|
||||
&& failed.connection.base() == request.connection.base()
|
||||
{
|
||||
failed.connection = request.connection.clone();
|
||||
}
|
||||
if let Some(review) = &mut self.hosted.review
|
||||
&& review.connection.base() == request.connection.base()
|
||||
{
|
||||
review.connection = request.connection.clone();
|
||||
}
|
||||
self.hosted.connection = Some(request.connection);
|
||||
if request.operation != Operation::Catalog(true) {
|
||||
self.hosted.catalog.clear();
|
||||
}
|
||||
for item in catalog.items {
|
||||
if !self
|
||||
.hosted
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|old| old.resource_id == item.resource_id)
|
||||
{
|
||||
self.hosted.catalog.push(item);
|
||||
}
|
||||
}
|
||||
self.hosted.next = catalog.next_after;
|
||||
self.hosted.catalog_stale = false;
|
||||
if !self
|
||||
.hosted
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|item| Some(item.resource_id) == self.hosted.selected)
|
||||
{
|
||||
self.hosted.selected = None;
|
||||
}
|
||||
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
|
||||
}
|
||||
Ok(Outcome::Remote(remote)) => match request.operation {
|
||||
Operation::Open(_) => {
|
||||
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
|
||||
Ok(document) => {
|
||||
self.reset(document.project().clone(), None);
|
||||
self.document = document;
|
||||
self.source_workspace =
|
||||
Workspace::new(self.document.bundle(), true);
|
||||
self.sync();
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection,
|
||||
resource_id: remote.resource_id,
|
||||
revision: remote.revision,
|
||||
etag: remote.etag,
|
||||
});
|
||||
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
|
||||
}
|
||||
Err(error) => self.hosted_problem(error.to_string()),
|
||||
}
|
||||
}
|
||||
Operation::Publish | Operation::Save => {
|
||||
return self.acknowledge_hosted(&request, remote);
|
||||
}
|
||||
Operation::Check => {
|
||||
let acknowledged = self
|
||||
.hosted
|
||||
.uncertain
|
||||
.as_ref()
|
||||
.filter(|failed| {
|
||||
failed.connection.base() == request.connection.base()
|
||||
&& failed.resource_id == Some(remote.resource_id)
|
||||
&& failed.snapshot.bundle == remote.compiled.bundle
|
||||
})
|
||||
.cloned();
|
||||
if let Some(failed) = acknowledged {
|
||||
return self.acknowledge_hosted(&failed, remote);
|
||||
}
|
||||
if self.hosted.session.as_ref().is_some_and(|session| {
|
||||
session.connection.base() == request.connection.base()
|
||||
&& session.resource_id == remote.resource_id
|
||||
&& session.etag == remote.etag
|
||||
}) {
|
||||
self.hosted.review = None;
|
||||
self.hosted.review_path = None;
|
||||
self.status =
|
||||
"The server revision is unchanged. Local edits are retained."
|
||||
.into();
|
||||
} else {
|
||||
self.status = format!(
|
||||
"Server revision {} is available. Review before replacing local work.",
|
||||
remote.revision
|
||||
);
|
||||
self.hosted.review = Some(Review {
|
||||
connection: request.connection.clone(),
|
||||
remote,
|
||||
});
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.visible = true;
|
||||
}
|
||||
}
|
||||
Operation::Catalog(_) => {
|
||||
self.hosted_problem("Unexpected response to catalog request.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,817 @@
|
||||
use super::*;
|
||||
use crate::hosted::client::Secret;
|
||||
|
||||
fn client() -> Connection {
|
||||
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Hosted(event));
|
||||
}
|
||||
fn rename(app: &mut App, name: &str) {
|
||||
app.document
|
||||
.execute(Command::Rename(app.project().id, name.into()))
|
||||
.unwrap();
|
||||
app.sync();
|
||||
}
|
||||
fn bind(app: &mut App) -> Id {
|
||||
let resource = Id::new_v4();
|
||||
app.hosted.session = Some(Session {
|
||||
connection: client(),
|
||||
resource_id: resource,
|
||||
revision: 1,
|
||||
etag: "\"opaque-base\"".into(),
|
||||
});
|
||||
resource
|
||||
}
|
||||
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
|
||||
Remote {
|
||||
resource_id,
|
||||
revision,
|
||||
etag: format!("\"opaque-{revision}\""),
|
||||
compiled: snapshot,
|
||||
}
|
||||
}
|
||||
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
|
||||
let r = app.hosted.request.as_ref().unwrap();
|
||||
send(app, Event::Completed(r.id, r.document, result));
|
||||
}
|
||||
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
|
||||
Failure {
|
||||
detail: "Test failure".into(),
|
||||
code: "test".into(),
|
||||
status,
|
||||
uncertain,
|
||||
published: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
rename(&mut app, "Published revision");
|
||||
app.path = Some(PathBuf::from("local.arcadia"));
|
||||
send(&mut app, Event::Publish);
|
||||
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
|
||||
assert!(app.busy);
|
||||
rename(&mut app, "Later local edit");
|
||||
let resource = Id::new_v4();
|
||||
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
|
||||
assert!(!app.busy);
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||
assert!(app.path.is_none());
|
||||
assert_eq!(app.project().name, "Later local edit");
|
||||
assert!(app.document.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project().name, "Published revision");
|
||||
assert!(!app.document.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||
other
|
||||
.execute(Command::Rename(other.project().id, "Other engineer".into()))
|
||||
.unwrap();
|
||||
rename(&mut app, "Local engineering work");
|
||||
let local = app.document.snapshot();
|
||||
send(&mut app, Event::Save);
|
||||
complete(&mut app, Err(failure(Some(412), false)));
|
||||
assert_eq!(
|
||||
app.hosted.request.as_ref().unwrap().operation,
|
||||
Operation::Check
|
||||
);
|
||||
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
|
||||
send(&mut app, Event::Save);
|
||||
assert!(!app.busy);
|
||||
assert!(app.hosted.request.is_none());
|
||||
send(&mut app, Event::Reload);
|
||||
assert_eq!(app.project().name, "Other engineer");
|
||||
assert!(!app.document.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project().name, "Local engineering work");
|
||||
assert!(app.document.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
rename(&mut app, "Saved despite lost response");
|
||||
send(&mut app, Event::Save);
|
||||
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
|
||||
complete(&mut app, Err(failure(None, true)));
|
||||
assert!(app.document.dirty());
|
||||
assert!(!app.busy);
|
||||
assert!(app.hosted.request.is_none());
|
||||
send(&mut app, Event::Check);
|
||||
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
|
||||
assert!(!app.document.dirty());
|
||||
assert!(app.hosted.uncertain.is_none());
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
bind(&mut app);
|
||||
send(&mut app, Event::Save);
|
||||
let request = app.hosted.request.as_ref().unwrap().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
|
||||
);
|
||||
assert!(app.busy);
|
||||
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
|
||||
app.reset(Project::empty(), None);
|
||||
let current = app.document.snapshot();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Completed(
|
||||
request.id,
|
||||
request.document,
|
||||
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
|
||||
),
|
||||
);
|
||||
assert!(Arc::ptr_eq(¤t, &app.document.snapshot()));
|
||||
assert!(app.hosted.session.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
bind(&mut app);
|
||||
app.source_workspace
|
||||
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
|
||||
let draft = app.source_workspace.bundle();
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Event::Save);
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), draft);
|
||||
assert!(!app.busy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
rename(&mut app, "Keep this work");
|
||||
app.hosted.connection = Some(client());
|
||||
app.hosted.selected = Some(Id::new_v4());
|
||||
send(&mut app, Event::OpenSelected);
|
||||
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
|
||||
assert!(app.hosted.request.is_none());
|
||||
let _ = app.update(Message::Discard);
|
||||
let previous = app.document.snapshot();
|
||||
complete(&mut app, Err(failure(Some(401), false)));
|
||||
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
|
||||
assert_eq!(app.project().name, "Keep this work");
|
||||
assert!(app.document.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
rename(&mut app, "Local fallback");
|
||||
let failure = SaveFailure {
|
||||
document: app.canvas_document,
|
||||
message: "Disk failed".into(),
|
||||
recovery: None,
|
||||
};
|
||||
let _ = app.update(Message::Saved(Err(failure)));
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join(&app.document.bundle().entry);
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
target.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(app.hosted.session.is_none());
|
||||
assert_eq!(app.path, Some(target));
|
||||
assert!(!app.document.dirty());
|
||||
assert!(app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
send(&mut app, Event::Show);
|
||||
let original = app.document.snapshot();
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
app.project().id,
|
||||
"Underlying edit".into(),
|
||||
)));
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
let token = "a-secret-never-in-a-diagnostic";
|
||||
let event = Message::Hosted(Event::Token(Secret(token.into())));
|
||||
assert!(!format!("{event:?}").contains(token));
|
||||
let _ = app.update(event);
|
||||
send(&mut app, Event::Forget);
|
||||
assert!(app.hosted.token.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.insert("detached.arcadia".into(), "// kept draft".into());
|
||||
let reachable = app.document.bundle().files.keys().cloned().collect();
|
||||
app.source_workspace.discovered(&bundle, reachable);
|
||||
app.source_workspace
|
||||
.switch("detached.arcadia", app.source_editor.cursor());
|
||||
app.source_workspace.edit(
|
||||
"// unsaved detached rationale".into(),
|
||||
app.source_editor.cursor(),
|
||||
);
|
||||
send(&mut app, Event::Publish);
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert!(app.source_workspace.detached_dirty());
|
||||
assert_eq!(
|
||||
app.source_workspace.files["detached.arcadia"].text,
|
||||
"// unsaved detached rationale"
|
||||
);
|
||||
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
|
||||
send(&mut app, Event::Publish);
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert_eq!(
|
||||
app.pending_recovery,
|
||||
Some(PathBuf::from("interrupted.arcadia"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let old_id = bind(&mut app);
|
||||
app.hosted.base = "http://127.0.0.1:9876".into();
|
||||
rename(&mut app, "New publication");
|
||||
send(&mut app, Event::Publish);
|
||||
let new_id = Id::new_v4();
|
||||
let mut error = failure(None, true);
|
||||
error.published = Some(new_id);
|
||||
complete(&mut app, Err(error));
|
||||
send(&mut app, Event::Check);
|
||||
let request = app.hosted.request.as_ref().unwrap();
|
||||
assert_eq!(request.resource_id, Some(new_id));
|
||||
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
|
||||
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||
changed
|
||||
.execute(Command::Rename(
|
||||
changed.project().id,
|
||||
"Changed after publication".into(),
|
||||
))
|
||||
.unwrap();
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
|
||||
);
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
|
||||
send(&mut app, Event::Reload);
|
||||
let session = app.hosted.session.as_ref().unwrap();
|
||||
assert_eq!(session.resource_id, new_id);
|
||||
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
|
||||
assert_eq!(app.project().name, "Changed after publication");
|
||||
}
|
||||
|
||||
async fn dispatch(app: &mut App, message: Message) {
|
||||
use iced::futures::StreamExt;
|
||||
let mut tasks = vec![app.update(message)];
|
||||
while let Some(task) = tasks.pop() {
|
||||
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = stream.next().await {
|
||||
if let iced_runtime::Action::Output(message) = action {
|
||||
tasks.push(app.update(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
|
||||
let token = "current-secret-for-hosted-regression-0123456789";
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (stop, done) = tokio::sync::oneshot::channel::<()>();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, oa_server::app(store, Some(token.into())))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.insert_str(0, "// Hosted rationale\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
app.sync();
|
||||
app.hosted.base = base.clone();
|
||||
app.hosted.token = Secret(token.into());
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert!(app.hosted.catalog.is_empty());
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(
|
||||
!app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Close)).await;
|
||||
dispatch(&mut app, Message::Hosted(Event::Show)).await;
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(
|
||||
app.hosted.request.is_none(),
|
||||
"Showing the dialog does not poll"
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog.len(), 1);
|
||||
assert_eq!(app.hosted.catalog[0].revision, 1);
|
||||
dispatch(&mut app, Message::Hosted(Event::Close)).await;
|
||||
let session = app
|
||||
.hosted
|
||||
.session
|
||||
.clone()
|
||||
.expect("publish binds a resource");
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.dirty());
|
||||
assert!(
|
||||
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
|
||||
);
|
||||
let project_id = app.project().id;
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::Command(Command::Rename(project_id, "Graphical update".into())),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert_eq!(
|
||||
app.hosted.catalog[0].revision, 1,
|
||||
"Old results stay identified as a snapshot"
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog[0].revision, 2);
|
||||
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
|
||||
let external = Connection::new(&base, Secret(token.into())).unwrap();
|
||||
let remote = external.open(session.resource_id).await.unwrap();
|
||||
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
|
||||
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
|
||||
other
|
||||
.execute(Command::Rename(project_id, "API engineering change".into()))
|
||||
.unwrap();
|
||||
external
|
||||
.save(session.resource_id, &remote.etag, other.bundle().clone())
|
||||
.await
|
||||
.unwrap();
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::Command(Command::Rename(project_id, "Local pending change".into())),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
assert!(app.hosted.review.is_some());
|
||||
assert_eq!(app.project().name, "Local pending change");
|
||||
assert!(app.document.dirty());
|
||||
// Simulate a retired credential in every preexisting reconciliation handle.
|
||||
let old =
|
||||
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
|
||||
app.hosted.session.as_mut().unwrap().connection = old.clone();
|
||||
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
|
||||
app.hosted.review.as_mut().unwrap().connection = old;
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
let reconciled_write = app.hosted.uncertain.clone().unwrap();
|
||||
// Reload the captured review directly. A fresh Check must not hide a stale
|
||||
// credential in that review by replacing it with a newer response.
|
||||
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
|
||||
assert_eq!(app.project().name, "API engineering change");
|
||||
dispatch(&mut app, Message::Undo).await;
|
||||
assert_eq!(app.project().name, "Local pending change");
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
assert!(!app.document.dirty());
|
||||
// Independently exercise the corrected uncertain-write connection.
|
||||
app.hosted.uncertain = Some(reconciled_write);
|
||||
dispatch(&mut app, Message::Hosted(Event::Check)).await;
|
||||
assert!(app.hosted.error.is_none());
|
||||
assert!(app.hosted.uncertain.is_none());
|
||||
let accepted = external.open(session.resource_id).await.unwrap();
|
||||
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
|
||||
assert_eq!(
|
||||
accepted.compiled.decoded.project.name,
|
||||
"Local pending change"
|
||||
);
|
||||
let mut second = App::with_project(Project::empty());
|
||||
second.hosted.base = base;
|
||||
second.hosted.token = Secret(token.into());
|
||||
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
|
||||
assert!(
|
||||
second
|
||||
.hosted
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|item| item.resource_id == session.resource_id)
|
||||
);
|
||||
send(&mut second, Event::Select(session.resource_id));
|
||||
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
|
||||
assert_eq!(second.document.bundle(), app.document.bundle());
|
||||
assert_eq!(
|
||||
second.hosted.session.as_ref().unwrap().resource_id,
|
||||
session.resource_id
|
||||
);
|
||||
dispatch(
|
||||
&mut second,
|
||||
Message::Command(Command::Rename(project_id, "Saved before New".into())),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut second, Message::Request(Pending::New)).await;
|
||||
assert!(second.pending.is_some());
|
||||
dispatch(&mut second, Message::SaveAndContinue).await;
|
||||
assert!(second.hosted.session.is_none());
|
||||
assert!(second.pending.is_none());
|
||||
assert_ne!(second.project().id, project_id);
|
||||
assert_eq!(
|
||||
external
|
||||
.open(session.resource_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.compiled
|
||||
.decoded
|
||||
.project
|
||||
.name,
|
||||
"Saved before New"
|
||||
);
|
||||
drop(external);
|
||||
drop(app);
|
||||
drop(second);
|
||||
let _ = stop.send(());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (stop, done) = tokio::sync::oneshot::channel::<()>();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, oa_server::app(store, None))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.hosted.base = base.clone();
|
||||
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
|
||||
let session = app.hosted.session.clone().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let original_identity = app.canvas_document;
|
||||
let external = Connection::new(&base, Secret::default()).unwrap();
|
||||
let old = external.open(session.resource_id).await.unwrap();
|
||||
|
||||
let local = tempfile::tempdir().unwrap();
|
||||
let path = local.path().join("repair.arcadia");
|
||||
let invalid = "arcadia 5; ? // unfinished local work";
|
||||
std::fs::write(&path, invalid).unwrap();
|
||||
let path = path.canonicalize().unwrap();
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(app.canvas_document, original_identity);
|
||||
assert_eq!(app.path.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.source_editor.text(), invalid);
|
||||
assert!(
|
||||
Arc::ptr_eq(&accepted, &app.document.snapshot()),
|
||||
"A repair workspace must preserve the last valid graphical model"
|
||||
);
|
||||
assert!(!app.source_diagnostics.is_empty());
|
||||
assert!(
|
||||
app.hosted.session.is_none(),
|
||||
"The installed local repair workspace must own its save destination"
|
||||
);
|
||||
|
||||
let mut repaired = Project::empty();
|
||||
repaired.name = "Independent local repair".into();
|
||||
let repaired_source = language::format(&repaired);
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||
)
|
||||
.await;
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(repaired_source),
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut app, Message::ApplySource).await;
|
||||
assert_eq!(app.project(), &repaired);
|
||||
assert!(!app.source_dirty());
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
|
||||
let remote_after = external.open(session.resource_id).await.unwrap();
|
||||
assert_eq!(
|
||||
remote_after.etag, old.etag,
|
||||
"Saving a repaired local workspace must never target the old hosted resource"
|
||||
);
|
||||
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
|
||||
assert!(app.hosted.session.is_none());
|
||||
assert!(app.hosted.review.is_none());
|
||||
assert!(app.hosted.uncertain.is_none());
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert_eq!(app.path.as_ref(), Some(&path));
|
||||
assert!(!app.dirty());
|
||||
let saved = source_files::load_project(&path, LIMITS).unwrap();
|
||||
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
|
||||
assert_eq!(
|
||||
app.hosted.base, base,
|
||||
"Connection settings remain available in memory"
|
||||
);
|
||||
drop(external);
|
||||
drop(app);
|
||||
let _ = stop.send(());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
let accepted = app.document.snapshot();
|
||||
let identity = app.canvas_document;
|
||||
let source = app.source_workspace.bundle();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let unreadable = directory.path().join("missing.json");
|
||||
let error = read_snapshot(unreadable).unwrap_err();
|
||||
for result in [Ok(None), Err(error)] {
|
||||
let _ = app.update(Message::Loaded(result));
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.canvas_document, identity);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(app.path.is_none());
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
|
||||
Outcome::Catalog(crate::hosted::client::Catalog {
|
||||
items: vec![crate::hosted::client::Summary {
|
||||
resource_id: resource,
|
||||
revision,
|
||||
model_id: app.project().id,
|
||||
name: format!("Catalog snapshot {revision}"),
|
||||
elements: app.project().elements.len(),
|
||||
relations: app.project().relations.len(),
|
||||
}],
|
||||
next_after: Some(resource),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let first = Id::new_v4();
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, first, 1);
|
||||
complete(&mut app, Ok(catalog));
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
send(&mut app, Event::More);
|
||||
assert_eq!(
|
||||
app.hosted.request.as_ref().unwrap().operation,
|
||||
Operation::Catalog(true)
|
||||
);
|
||||
let next_page = catalog_result(&app, Id::new_v4(), 1);
|
||||
complete(&mut app, Ok(next_page));
|
||||
assert_eq!(app.hosted.catalog.len(), 2);
|
||||
send(&mut app, Event::Select(first));
|
||||
send(&mut app, Event::Query("different search".into()));
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.next.is_none());
|
||||
assert!(app.hosted.selected.is_none());
|
||||
assert_eq!(app.hosted.catalog.len(), 2);
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.contains("Connect / refresh")
|
||||
);
|
||||
send(&mut app, Event::More);
|
||||
assert!(app.hosted.request.is_none());
|
||||
send(&mut app, Event::Connect);
|
||||
complete(&mut app, Err(failure(None, false)));
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog[0].resource_id, first);
|
||||
send(&mut app, Event::Connect);
|
||||
let second = Id::new_v4();
|
||||
let catalog = catalog_result(&app, second, 1);
|
||||
complete(&mut app, Ok(catalog));
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog.len(), 1);
|
||||
assert_eq!(app.hosted.catalog[0].resource_id, second);
|
||||
send(&mut app, Event::Query("different search".into()));
|
||||
assert!(
|
||||
!app.hosted.catalog_stale,
|
||||
"Unchanged query preserves the snapshot"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Token(Secret("different-credential".into())),
|
||||
);
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.catalog.is_empty());
|
||||
assert!(app.hosted.connection.is_none());
|
||||
assert!(app.hosted.next.is_none());
|
||||
assert!(app.hosted.selected.is_none());
|
||||
assert!(
|
||||
!app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, second, 2);
|
||||
complete(&mut app, Ok(catalog));
|
||||
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||
assert!(app.hosted.catalog.is_empty());
|
||||
assert!(app.hosted.connection.is_none());
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.request.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
let other = Id::new_v4();
|
||||
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, other, 7);
|
||||
complete(&mut app, Ok(catalog));
|
||||
rename(&mut app, "Saved to original service");
|
||||
send(&mut app, Event::Save);
|
||||
let saved = remote(app.document.snapshot(), resource, 2);
|
||||
complete(&mut app, Ok(Outcome::Remote(saved)));
|
||||
assert!(
|
||||
!app.hosted.catalog_stale,
|
||||
"A write to A cannot invalidate B's catalog"
|
||||
);
|
||||
assert_eq!(app.hosted.next, Some(other));
|
||||
assert_eq!(app.hosted.catalog[0].resource_id, other);
|
||||
|
||||
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, resource, 2);
|
||||
complete(&mut app, Ok(catalog));
|
||||
send(&mut app, Event::Check);
|
||||
let changed = remote(app.document.snapshot(), resource, 3);
|
||||
complete(&mut app, Ok(Outcome::Remote(changed)));
|
||||
assert!(
|
||||
app.hosted.catalog_stale,
|
||||
"A known newer server revision invalidates old metadata"
|
||||
);
|
||||
assert!(app.hosted.next.is_none());
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, resource, 3);
|
||||
complete(&mut app, Ok(catalog));
|
||||
send(&mut app, Event::Reload);
|
||||
rename(&mut app, "Write with uncertain outcome");
|
||||
send(&mut app, Event::Save);
|
||||
complete(&mut app, Err(failure(None, true)));
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.next.is_none());
|
||||
assert_eq!(app.hosted.catalog[0].revision, 3);
|
||||
app.hosted.leave_document();
|
||||
assert!(
|
||||
app.hosted.catalog_stale,
|
||||
"Leaving the hosted destination cannot make old results fresh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
rename(&mut app, "Original");
|
||||
let resource = bind(&mut app);
|
||||
send(&mut app, Event::Query("Renamed".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
|
||||
items: vec![],
|
||||
next_after: None,
|
||||
})),
|
||||
);
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
|
||||
let mut unchanged = remote(app.document.snapshot(), resource, 1);
|
||||
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
|
||||
send(&mut app, Event::Check);
|
||||
complete(&mut app, Ok(Outcome::Remote(unchanged)));
|
||||
assert!(
|
||||
!app.hosted.catalog_stale,
|
||||
"An unchanged bound head keeps the filtered snapshot fresh"
|
||||
);
|
||||
|
||||
// A different engineer changes the hosted model so it now matches the filter.
|
||||
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||
changed
|
||||
.execute(Command::Rename(changed.project().id, "Renamed".into()))
|
||||
.unwrap();
|
||||
send(&mut app, Event::Check);
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
|
||||
);
|
||||
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
|
||||
assert!(
|
||||
app.hosted.request.is_none(),
|
||||
"No polling or automatic catalog request"
|
||||
);
|
||||
assert!(
|
||||
app.hosted.catalog_stale,
|
||||
"Known newer revision can change membership in an empty filtered catalog"
|
||||
);
|
||||
assert!(
|
||||
!app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||
send(&mut app, Event::Query("Renamed".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
|
||||
items: vec![],
|
||||
next_after: None,
|
||||
})),
|
||||
);
|
||||
send(&mut app, Event::Check);
|
||||
let changed = remote(app.document.snapshot(), resource, 2);
|
||||
complete(&mut app, Ok(Outcome::Remote(changed)));
|
||||
assert!(app.hosted.review.is_some());
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
assert!(app.hosted.request.is_none());
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::{self, CanvasEvent};
|
||||
impl App {
|
||||
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
|
||||
let owner = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& matches!(
|
||||
self.project().elements.get(&owner)?.kind(),
|
||||
Kind::Process | Kind::FunctionalChain
|
||||
))
|
||||
.then_some(topology::Context {
|
||||
document: self.canvas_document,
|
||||
owner,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.scrolled(area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.viewport(size)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
|
||||
{
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
self.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand(self.project(), context.owner, id);
|
||||
self.behavior.topology = state;
|
||||
if changed {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Open(_, occurrence) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(target) =
|
||||
topology::referenced(self.project(), context.owner, occurrence)
|
||||
{
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.select(target);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Back(_) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let back = state.back(self.project());
|
||||
self.behavior.topology = state;
|
||||
if let Some((owner, occurrence)) = back {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.select(occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the process.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::Begin {
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.begin(self.project(), context, tool, control, insertion);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status="Choose the contextual contribution and apply at the captured position.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::AppendExchange(_) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.append_exchange(self.project(), context.owner);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Connect { source, target, .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.connect(self.project(), context.owner, source, target);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Describe the path condition, then Create.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reconnect {
|
||||
link, end, target, ..
|
||||
} => {
|
||||
match topology::reconnect(self.project(), context.owner, link, end, target)
|
||||
{
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = link;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reorder {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
topology::{
|
||||
CanvasTool, Event, Insertion, Tool,
|
||||
direct_tests::{self, Fixture},
|
||||
},
|
||||
};
|
||||
fn setup() -> (App, Fixture) {
|
||||
let f = direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
(app, f)
|
||||
}
|
||||
fn form(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
|
||||
}
|
||||
fn begin(app: &App) -> CanvasEvent {
|
||||
CanvasEvent::Begin {
|
||||
context: app.topology_context().unwrap(),
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = direct_tests::empty_exchange_fixture(layer);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let empty = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Exchange,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}));
|
||||
assert!(app.behavior.has_pending());
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.exchange,
|
||||
label: "Assessment".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("First contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let first_id = app.selected;
|
||||
let first = app.document.snapshot();
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id]
|
||||
);
|
||||
assert_eq!(app.project().relations, empty.decoded.project.relations);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
|
||||
context,
|
||||
)));
|
||||
assert!(
|
||||
app.behavior.has_pending(),
|
||||
"reachable single-exchange continuation: {}",
|
||||
app.status
|
||||
);
|
||||
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: next,
|
||||
label: "Response".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Second contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let second_id = app.selected;
|
||||
let second = app.document.snapshot();
|
||||
assert_ne!(first_id, second_id);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id, second_id]
|
||||
);
|
||||
let links = app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::SequenceLink)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert_eq!(
|
||||
(links[0].source(), links[0].target()),
|
||||
(first_id, second_id)
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements.len(),
|
||||
empty.decoded.project.elements.len() + 2
|
||||
);
|
||||
for (id, relation) in &empty.decoded.project.relations {
|
||||
assert_eq!(app.project().relations[id], *relation);
|
||||
}
|
||||
for work in f.work {
|
||||
assert_eq!(
|
||||
app.project().elements[&work],
|
||||
empty.decoded.project.elements[&work]
|
||||
);
|
||||
}
|
||||
let reopened = Document::from_bundle(
|
||||
second.bundle.clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.bundle(), &second.bundle);
|
||||
for expected in [&first, &empty] {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"each Create must add exactly one history entry"
|
||||
);
|
||||
for expected in [&first, &second] {
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
|
||||
let (mut app, _) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
for field in 0..4 {
|
||||
let mut event = begin(&app);
|
||||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||
unreachable!()
|
||||
};
|
||||
match field {
|
||||
0 => context.document = Id::new_v4(),
|
||||
1 => context.owner = Id::new_v4(),
|
||||
2 => context.revision += 1,
|
||||
_ => context.epoch += 1,
|
||||
};
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.draft_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
for event in [
|
||||
CanvasEvent::Zoom(context, Some(0.7)),
|
||||
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
|
||||
let (mut app, f) = setup();
|
||||
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>() + 3000;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
app.sync();
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.work[0],
|
||||
label: "Assess".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
form(&mut app, Event::Name("Recovered contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_ne!(app.selected, f.owner);
|
||||
assert_eq!(app.project().named(app.selected), "Recovered contribution");
|
||||
let created = app.selected;
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert!(app.project().contains(created));
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Connect,
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
context, f.nodes[0],
|
||||
)));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let other = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let other_id = other.id;
|
||||
assert!(app.execute(Command::CreateElement(other)));
|
||||
let _ = app.update(Message::BehaviorOwner(Choice {
|
||||
id: other_id,
|
||||
label: "Other".into(),
|
||||
}));
|
||||
assert_eq!(app.topology_context().unwrap().owner, other_id);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
|
||||
context,
|
||||
source: f.nodes[0],
|
||||
target: f.nodes[2],
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
let mut stale = context;
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
snapshot: original.clone(),
|
||||
path: root.join(&original.bundle.entry),
|
||||
disk: Some(source_files::DiskProject {
|
||||
root,
|
||||
bundle: original.bundle.clone(),
|
||||
expectations: Default::default(),
|
||||
reachable: original.bundle.files.keys().cloned().collect(),
|
||||
diagnostics: vec![],
|
||||
}),
|
||||
};
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||
size.width + 20.,
|
||||
size.height,
|
||||
)));
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(&mut app, Event::Name("Keep this form".into()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 80.),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
|
||||
let (mut app, f) = setup();
|
||||
let source = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
assert!(app.execute(Command::CreateElement(source)));
|
||||
let mut occurrence = app
|
||||
.project()
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
|
||||
.unwrap();
|
||||
let occurrence_id = occurrence.id;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
|
||||
*body = OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
};
|
||||
}
|
||||
assert!(app.execute(Command::CreateElement(occurrence)));
|
||||
let snapshot = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
|
||||
context,
|
||||
occurrence_id,
|
||||
)));
|
||||
assert_eq!(app.selected, source_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, source_id);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(app.selected, occurrence_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let original = app.document.snapshot();
|
||||
let edited = format!(
|
||||
"{}\n// Retained source draft\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(edited.clone().into()),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(app.source_workspace.text(), edited);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Adapter from captured state-canvas events to the shared document transaction.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Event, Tool},
|
||||
};
|
||||
|
||||
impl App {
|
||||
/// The behavior view resolved independently of the current page. Capture
|
||||
/// this before selection/layer changes, while it still identifies the view
|
||||
/// whose camera and tool are retained during navigation.
|
||||
pub(super) fn state_view_identity(&self) -> (Option<Id>, Option<Id>) {
|
||||
let owner = behavior::selected_owner(self);
|
||||
let diagram = owner.and_then(|owner| behavior::state_view::diagram(self, owner));
|
||||
(owner, diagram)
|
||||
}
|
||||
|
||||
pub(super) fn reset_state_view_after_navigation(
|
||||
&mut self,
|
||||
previous: (Option<Id>, Option<Id>),
|
||||
) -> bool {
|
||||
let current = self.state_view_identity();
|
||||
if current == previous {
|
||||
return false;
|
||||
}
|
||||
self.behavior.diagram = behavior::state_view::State {
|
||||
diagram: current.1,
|
||||
..Default::default()
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub fn state_context(&self) -> Option<Context> {
|
||||
if self.page != Page::Behavior {
|
||||
return None;
|
||||
}
|
||||
let diagram = self.state_view_identity().1?;
|
||||
Some(Context {
|
||||
document: self.canvas_document,
|
||||
diagram,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn fit_state_diagram(&mut self) {
|
||||
let Some(context) = self.state_context() else {
|
||||
return;
|
||||
};
|
||||
let diagram = &self.project().diagrams[&context.diagram];
|
||||
let view = projection::project_diagram(self.project(), diagram);
|
||||
let bounds = routing::route_diagram(self.project(), diagram, &view).bounds;
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
));
|
||||
let width = (viewport.width - 48.).max(1.);
|
||||
let height = (viewport.height - 80.).max(1.);
|
||||
let zoom = (width / bounds.size.width.max(1.))
|
||||
.min(height / bounds.size.height.max(1.))
|
||||
.clamp(0.15, 1.25);
|
||||
self.behavior.diagram.zoom = zoom;
|
||||
self.behavior.diagram.pan = iced::Vector::new(
|
||||
24. + (width - bounds.size.width * zoom) / 2. - bounds.position.x * zoom,
|
||||
56. + (height - bounds.size.height * zoom) / 2. - bounds.position.y * zoom,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn edit_state_diagram(&mut self, event: Event) -> Task<Message> {
|
||||
if self.page != Page::Behavior {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(captured) = event.context() {
|
||||
let Some(current) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if state_edit::validate_context(self.project(), captured, current).is_err() {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
&& size.width > 0.
|
||||
&& size.height > 0.
|
||||
{
|
||||
let resized = self.behavior.diagram.viewport != Some(size);
|
||||
self.behavior.diagram.viewport = Some(size);
|
||||
if resized && self.behavior.diagram.fit {
|
||||
self.fit_state_diagram();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Fit(_) => {
|
||||
self.behavior.diagram.fit = true;
|
||||
self.fit_state_diagram();
|
||||
}
|
||||
Event::Zoom(_, zoom) if zoom.is_finite() => {
|
||||
let state = &mut self.behavior.diagram;
|
||||
let new = zoom.clamp(0.15, 3.);
|
||||
let center = state.viewport.map_or(iced::Vector::new(300., 220.), |s| {
|
||||
iced::Vector::new(s.width / 2., s.height / 2.)
|
||||
});
|
||||
state.pan = center - (center - state.pan) * (new / state.zoom);
|
||||
state.zoom = new;
|
||||
state.fit = false;
|
||||
}
|
||||
Event::Pan(_, delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
self.behavior.diagram.pan += delta;
|
||||
self.behavior.diagram.fit = false;
|
||||
}
|
||||
Event::Select(_, id) => {
|
||||
if !self.behavior.has_pending() {
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object,
|
||||
placement,
|
||||
anchor,
|
||||
} => {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
Event::EditTransition(_, id) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.handle(Message::Behavior(behavior::Event::States(
|
||||
behavior::states::Event::Edit(id),
|
||||
)));
|
||||
}
|
||||
Event::Choose(tool) => {
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.diagram.tool = tool;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::CreateView(machine) => {
|
||||
if behavior::selected_owner(self) != Some(machine) || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
match state_edit::create_view(self.project(), machine) {
|
||||
Ok(plan) => {
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.active_owner = Some(machine);
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
self.behavior.diagram.tool = Tool::Select;
|
||||
self.behavior.diagram.fit = true;
|
||||
self.selected = plan.selected;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.fit_state_diagram();
|
||||
self.status = "State diagram created · existing model elements are placed in this view".into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
Event::Zoom(..) | Event::Pan(..) => {}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the state diagram.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
match state_edit::plan(self.project(), context, &mutation) {
|
||||
Ok(plan) => {
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
self.selected = plan.selected;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
if !matches!(mutation, Event::Move { .. }) {
|
||||
self.behavior.diagram.tool = Tool::Select;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.status =
|
||||
"State diagram updated · Undo restores the complete operation"
|
||||
.into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
//! App transaction and draft boundaries for captured state-canvas messages.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||
states,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
machine: Id,
|
||||
region: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
leaf: Id,
|
||||
members: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
// Exercise the real App boundary without polling asynchronous I/O tasks.
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn event(app: &mut App, event: Event) {
|
||||
send(app, Message::StateDiagram(event));
|
||||
}
|
||||
|
||||
fn form(app: &mut App, event: states::Event) {
|
||||
send(app, Message::Behavior(behavior::Event::States(event)));
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap();
|
||||
let owner = add(Kind::System, "System", None);
|
||||
let machine = add(Kind::StateMachine, "Operating modes", Some(owner));
|
||||
let region = add(Kind::Region, "Main", Some(machine));
|
||||
let initial = add(Kind::Initial, "Start", Some(region));
|
||||
let a = add(Kind::State, "Operating", Some(region));
|
||||
let b = add(Kind::State, "Standby", Some(region));
|
||||
let nested = add(Kind::Region, "Work", Some(a));
|
||||
let leaf = add(Kind::State, "Working", Some(nested));
|
||||
let other_machine = add(Kind::StateMachine, "Other machine", None);
|
||||
let other_region = add(Kind::Region, "Other region", Some(other_machine));
|
||||
add(Kind::State, "Foreign state", Some(other_region));
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(a));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert_eq!(behavior::selected_owner(&app), Some(machine));
|
||||
Fixture {
|
||||
app,
|
||||
machine,
|
||||
region,
|
||||
a,
|
||||
b,
|
||||
leaf,
|
||||
members: BTreeSet::from([machine, region, initial, a, b, nested, leaf]),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_view(f: &mut Fixture) -> Context {
|
||||
event(&mut f.app, Event::CreateView(f.machine));
|
||||
f.app
|
||||
.state_context()
|
||||
.expect("CreateView must activate the new persisted view")
|
||||
}
|
||||
|
||||
fn node(app: &App, diagram: Id, element: Id) -> Id {
|
||||
app.project().diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == element)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
fn assert_source_model_agree(app: &App) {
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
|
||||
struct NavigationFixture {
|
||||
app: App,
|
||||
machine_a: Id,
|
||||
diagram_a: Id,
|
||||
state_a: Id,
|
||||
transition_a: Id,
|
||||
root_event: Id,
|
||||
machine_b: Id,
|
||||
diagram_b: Id,
|
||||
state_b: Id,
|
||||
}
|
||||
|
||||
fn navigation_fixture(layer_b: Layer) -> NavigationFixture {
|
||||
let mut project = Project::empty();
|
||||
let mut machine = |layer, name| {
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, layer, name, None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, layer, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, layer, "Ready", Some(region))
|
||||
.unwrap();
|
||||
(machine, state)
|
||||
};
|
||||
let (machine_a, state_a) = machine(Layer::Sa, "Machine A");
|
||||
let (machine_b, state_b) = machine(layer_b, "Machine B");
|
||||
let root_event = project
|
||||
.add_element(Kind::Event, Layer::Sa, "External event", None)
|
||||
.unwrap();
|
||||
let transition_a = project.relate(RelationKind::StateTransition, "Repeat", state_a, state_a);
|
||||
let plan_a = state_edit::create_view(&project, machine_a).unwrap();
|
||||
project = prepare_command(&project, &plan_a.command).unwrap().project;
|
||||
let plan_b = state_edit::create_view(&project, machine_b).unwrap();
|
||||
project = prepare_command(&project, &plan_b.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(state_a));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
send(
|
||||
&mut app,
|
||||
Message::BehaviorDiagram(Choice {
|
||||
id: plan_a.diagram,
|
||||
label: "A view".into(),
|
||||
}),
|
||||
);
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
event(&mut app, Event::Zoom(context, 0.65));
|
||||
event(
|
||||
&mut app,
|
||||
Event::Pan(context, iced::Vector::new(-1_400., -800.)),
|
||||
);
|
||||
event(
|
||||
&mut app,
|
||||
Event::Choose(Tool::Reconnect {
|
||||
relation: transition_a,
|
||||
endpoint: Endpoint::Target,
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.state_context().unwrap().diagram, plan_a.diagram);
|
||||
assert!(!app.behavior.diagram.fit);
|
||||
NavigationFixture {
|
||||
app,
|
||||
machine_a,
|
||||
diagram_a: plan_a.diagram,
|
||||
state_a,
|
||||
transition_a,
|
||||
root_event,
|
||||
machine_b,
|
||||
diagram_b: plan_b.diagram,
|
||||
state_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_switched_state_view(f: &mut NavigationFixture) {
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_b));
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_b);
|
||||
let defaults = behavior::state_view::State::default();
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(state.diagram, Some(f.diagram_b));
|
||||
assert_eq!(
|
||||
state.tool,
|
||||
Tool::Select,
|
||||
"Reconnect from A must not be armed in B"
|
||||
);
|
||||
assert_eq!(state.pan, defaults.pan);
|
||||
assert_eq!(state.zoom, defaults.zoom);
|
||||
assert!(state.fit);
|
||||
assert!(
|
||||
state.viewport.is_none(),
|
||||
"B needs its own first viewport measurement"
|
||||
);
|
||||
assert!(state.placement.is_none());
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
assert!(
|
||||
f.app.behavior.diagram.pan != defaults.pan || f.app.behavior.diagram.zoom != defaults.zoom,
|
||||
"The unchanged widget size still fits a newly selected view"
|
||||
);
|
||||
}
|
||||
|
||||
fn catalog_selects_another_machine(layer_b: Layer) {
|
||||
let mut f = navigation_fixture(layer_b);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Select(f.state_b));
|
||||
assert_eq!(f.app.layer, layer_b);
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
assert_switched_state_view(&mut f);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_source_model_agree(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_catalog_selection_of_another_machine_resets_camera_and_tool() {
|
||||
catalog_selects_another_machine(Layer::Sa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_catalog_selection_across_layers_resets_camera_and_tool() {
|
||||
catalog_selects_another_machine(Layer::Oa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_layer_change_resets_the_new_machine_at_the_same_viewport_size() {
|
||||
let mut f = navigation_fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Layer(Layer::Oa));
|
||||
assert_switched_state_view(&mut f);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_source_model_agree(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_return_to_the_same_view_preserves_camera_tool_and_fit_mode() {
|
||||
for fit in [false, true] {
|
||||
let mut f = navigation_fixture(Layer::Sa);
|
||||
if fit {
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(&mut f.app, Event::Fit(context));
|
||||
}
|
||||
let state = &f.app.behavior.diagram;
|
||||
let retained = (
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Select(f.state_a));
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
|
||||
assert_eq!(context.diagram, f.diagram_a);
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(
|
||||
(
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport
|
||||
),
|
||||
retained
|
||||
);
|
||||
assert_eq!(
|
||||
state.tool,
|
||||
Tool::Reconnect {
|
||||
relation: f.transition_a,
|
||||
endpoint: Endpoint::Target
|
||||
}
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_root_event_and_same_layer_keep_the_current_camera_and_tool() {
|
||||
let mut f = navigation_fixture(Layer::Sa);
|
||||
let state = &f.app.behavior.diagram;
|
||||
let retained = (
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Select(f.root_event));
|
||||
assert_eq!(f.app.selected, f.root_event);
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
send(&mut f.app, Message::Layer(Layer::Sa));
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_a);
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(
|
||||
(
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport
|
||||
),
|
||||
retained
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let diagram = &app.project().diagrams[&context.diagram];
|
||||
assert_eq!(
|
||||
diagram.projection,
|
||||
ProjectionKind::StateMachine { machine: f.machine }
|
||||
);
|
||||
assert_eq!(
|
||||
diagram
|
||||
.nodes
|
||||
.values()
|
||||
.map(|n| n.element)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
f.members
|
||||
);
|
||||
assert_eq!(app.project().elements, before.decoded.project.elements);
|
||||
assert_eq!(app.project().relations, before.decoded.project.relations);
|
||||
assert_eq!(app.selected, f.machine);
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert_source_model_agree(app);
|
||||
let accepted = app.document.snapshot();
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.dirty());
|
||||
assert_source_model_agree(app);
|
||||
send(app, Message::Redo);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let before_create = app.document.snapshot();
|
||||
event(
|
||||
app,
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 900., y: 700. },
|
||||
},
|
||||
);
|
||||
let created = app.selected;
|
||||
assert_eq!(app.project().elements[&created].parent, Some(f.region));
|
||||
assert_eq!(app.project().elements[&created].kind(), Kind::State);
|
||||
assert_eq!(
|
||||
app.project().elements.len(),
|
||||
before_create.decoded.project.elements.len() + 1
|
||||
);
|
||||
let created_node = node(app, context.diagram, created);
|
||||
assert_ne!(created_node, created);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&created_node].position,
|
||||
Position { x: 900., y: 700. }
|
||||
);
|
||||
assert_source_model_agree(app);
|
||||
let before_connect = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context,
|
||||
source: f.a,
|
||||
target: created,
|
||||
},
|
||||
);
|
||||
let relation = app.selected;
|
||||
assert!(matches!(app.project().relations[&relation].body,
|
||||
RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. }
|
||||
if source == f.a && target == created));
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(behavior::selected_owner(app), Some(f.machine));
|
||||
assert_source_model_agree(app);
|
||||
|
||||
let context = app.state_context().unwrap();
|
||||
let before_select = app.document.snapshot();
|
||||
event(app, Event::Select(context, f.a));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"The press selection must not invalidate its following drag release"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before_select, &app.document.snapshot()));
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
let leaf = node(app, context.diagram, f.leaf);
|
||||
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
|
||||
let leaf_original = app.project().diagrams[&context.diagram].nodes[&leaf].position;
|
||||
event(
|
||||
app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position {
|
||||
x: original.x + 31.,
|
||||
y: original.y + 43.,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&leaf].position,
|
||||
Position {
|
||||
x: leaf_original.x + 31.,
|
||||
y: leaf_original.y + 43.
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.document.revision(),
|
||||
4,
|
||||
"View, vertex, edge, and drag each commit once"
|
||||
);
|
||||
assert_source_model_agree(app);
|
||||
for expected in [before_select, before_connect, before_create] {
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
assert!(app.project().diagrams.contains_key(&context.diagram));
|
||||
assert!(!app.project().contains(created));
|
||||
assert!(!app.project().contains(relation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_context_dimensions_reject_stale_selection_camera_and_model_messages() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
let pan = app.behavior.diagram.pan;
|
||||
let zoom = app.behavior.diagram.zoom;
|
||||
let viewport = app.behavior.diagram.viewport;
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
for changed in 0..4 {
|
||||
let mut captured = context;
|
||||
match changed {
|
||||
0 => captured.document = Id::new_v4(),
|
||||
1 => captured.diagram = Id::new_v4(),
|
||||
2 => captured.revision += 1,
|
||||
_ => captured.epoch += 1,
|
||||
}
|
||||
for stale in [
|
||||
Event::Select(captured, f.b),
|
||||
Event::Viewport(captured, iced::Size::new(999., 777.)),
|
||||
Event::Pan(captured, iced::Vector::new(55., 66.)),
|
||||
Event::Zoom(captured, 2.7),
|
||||
Event::Move {
|
||||
context: captured,
|
||||
node: placement,
|
||||
position: Position { x: 88., y: 99. },
|
||||
},
|
||||
Event::Connect {
|
||||
context: captured,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
Event::CreateAt {
|
||||
context: captured,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 800., y: 800. },
|
||||
},
|
||||
] {
|
||||
event(app, stale);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.behavior.diagram.pan, pan);
|
||||
assert_eq!(app.behavior.diagram.zoom, zoom);
|
||||
assert_eq!(app.behavior.diagram.viewport, viewport);
|
||||
assert_eq!(app.state_context(), Some(context));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
}
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_view_tool_and_history_changes_invalidate_old_release_events() {
|
||||
let mut f = fixture();
|
||||
let first = create_view(&mut f);
|
||||
let second = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
assert_ne!(first.diagram, second.diagram);
|
||||
let first_choice = Choice {
|
||||
id: first.diagram,
|
||||
label: app.project().named(first.diagram).into(),
|
||||
};
|
||||
send(app, Message::BehaviorDiagram(first_choice));
|
||||
let restored_view = app.state_context().unwrap();
|
||||
assert_eq!(restored_view.diagram, first.diagram);
|
||||
let before = app.document.snapshot();
|
||||
for captured in [first, second] {
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: captured,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
event(app, Event::Choose(Tool::Transition));
|
||||
let armed = app.state_context().unwrap();
|
||||
assert_ne!(restored_view.epoch, armed.epoch);
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: restored_view,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: armed,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().relations.len(),
|
||||
before.decoded.project.relations.len() + 1
|
||||
);
|
||||
let committed = app.state_context().unwrap();
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let undone = app.document.snapshot();
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: armed,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: committed,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
|
||||
send(app, Message::Page(Page::Diagram));
|
||||
assert!(app.state_context().is_none());
|
||||
let diagram_count = app.project().diagrams.len();
|
||||
event(app, Event::CreateView(f.machine));
|
||||
assert_eq!(app.project().diagrams.len(), diagram_count);
|
||||
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
event(
|
||||
app,
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.a,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 600., y: 700. },
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.state_context(), Some(context));
|
||||
event(
|
||||
app,
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 600., y: 700. },
|
||||
},
|
||||
);
|
||||
assert_eq!(app.project().elements[&app.selected].parent, Some(f.region));
|
||||
assert_eq!(app.document.revision(), context.revision + 1);
|
||||
assert_source_model_agree(app);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs() {
|
||||
for pending in ["properties", "source", "behavior"] {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Connect {
|
||||
context,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
let relation = f.app.selected;
|
||||
// A valid Place request must have a genuinely absent placement.
|
||||
let missing = f
|
||||
.app
|
||||
.project()
|
||||
.make_element(Kind::State, Layer::Sa, "Not yet displayed", Some(f.region))
|
||||
.unwrap();
|
||||
let missing_id = missing.id;
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Command(Command::CreateElement(missing)),
|
||||
);
|
||||
send(&mut f.app, Message::Select(f.a));
|
||||
let app = &mut f.app;
|
||||
let context = app.state_context().unwrap();
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
let at = app.project().diagrams[&context.diagram].nodes[&placement].position;
|
||||
let mutations = [
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 700., y: 800. },
|
||||
},
|
||||
Event::Place {
|
||||
context,
|
||||
element: missing_id,
|
||||
position: Position { x: 800., y: 900. },
|
||||
},
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position {
|
||||
x: at.x + 50.,
|
||||
y: at.y + 70.,
|
||||
},
|
||||
},
|
||||
Event::Connect {
|
||||
context,
|
||||
source: f.b,
|
||||
target: f.a,
|
||||
},
|
||||
Event::Reconnect {
|
||||
context,
|
||||
relation,
|
||||
endpoint: Endpoint::Target,
|
||||
vertex: f.leaf,
|
||||
},
|
||||
];
|
||||
for mutation in &mutations {
|
||||
let plan = state_edit::plan(app.project(), context, mutation).unwrap();
|
||||
assert_ne!(
|
||||
&prepare_command(app.project(), &plan.command)
|
||||
.unwrap()
|
||||
.project,
|
||||
app.project(),
|
||||
"Each blocked probe must otherwise be a valid, substantive model change"
|
||||
);
|
||||
}
|
||||
match pending {
|
||||
"properties" => send(app, Message::Field("/name".into(), "Unapplied name".into())),
|
||||
"source" => {
|
||||
let draft = format!(
|
||||
"{}\n// Unapplied source note.\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(draft),
|
||||
))),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
form(app, states::Event::Edit(relation));
|
||||
form(app, states::Event::Name("Retained form name".into()));
|
||||
form(app, states::Event::Guard("temperature nominal".into()));
|
||||
form(app, states::Event::TriggerNotes("Retained trigger".into()));
|
||||
}
|
||||
}
|
||||
assert_eq!(app.draft_dirty(), pending == "properties");
|
||||
assert_eq!(app.source_dirty(), pending == "source");
|
||||
assert_eq!(app.behavior.has_pending(), pending == "behavior");
|
||||
let before = app.document.snapshot();
|
||||
let source = app.source_workspace.bundle();
|
||||
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
let pending_candidate = app
|
||||
.behavior
|
||||
.prepare_pending(app.project())
|
||||
.unwrap()
|
||||
.map(|command| prepare_command(app.project(), &command).unwrap().project);
|
||||
let selected = app.selected;
|
||||
for mutation in mutations {
|
||||
event(app, mutation);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
properties
|
||||
);
|
||||
}
|
||||
for passive in [
|
||||
Event::Viewport(context, iced::Size::new(960., 640.)),
|
||||
Event::Pan(context, iced::Vector::new(12., 18.)),
|
||||
Event::Zoom(context, 0.7),
|
||||
Event::Fit(context),
|
||||
] {
|
||||
event(app, passive);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
properties
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.behavior
|
||||
.prepare_pending(app.project())
|
||||
.unwrap()
|
||||
.map(|command| prepare_command(app.project(), &command).unwrap().project),
|
||||
pending_candidate
|
||||
);
|
||||
match pending {
|
||||
"properties" => send(app, Message::RevertProperties),
|
||||
"source" => send(app, Message::RevertSource),
|
||||
_ => form(app, states::Event::Cancel),
|
||||
}
|
||||
assert!(!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending());
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position {
|
||||
x: at.x + 50.,
|
||||
y: at.y + 70.,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_ne!(app.document.bundle(), &before.bundle);
|
||||
assert_source_model_agree(app);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move() {
|
||||
let f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
// One eligible machine makes the fallback deterministic. Start from a loaded
|
||||
// project with no selected behavior element and no explicitly chosen owner.
|
||||
project
|
||||
.elements
|
||||
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
|
||||
let plan = state_edit::create_view(&project, f.machine).unwrap();
|
||||
let project = prepare_command(&project, &plan.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
assert_eq!(app.selected, app.project().id);
|
||||
assert!(app.behavior.active_owner.is_none());
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
let context = app.state_context().unwrap();
|
||||
assert_eq!(context.diagram, plan.diagram);
|
||||
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
|
||||
let before = app.document.snapshot();
|
||||
let placement = node(&app, context.diagram, f.a);
|
||||
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
|
||||
event(&mut app, Event::Select(context, f.a));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"Pinning the already-visible fallback owner must not advance the gesture epoch"
|
||||
);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(placement));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
let destination = Position {
|
||||
x: original.x + 37.,
|
||||
y: original.y + 29.,
|
||||
};
|
||||
event(
|
||||
&mut app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: destination,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&placement].position,
|
||||
destination
|
||||
);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.document.revision(),
|
||||
1,
|
||||
"The first release must commit without another press"
|
||||
);
|
||||
assert_source_model_agree(&app);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let size = iced::Size::new(960., 640.);
|
||||
event(app, Event::Viewport(context, size));
|
||||
assert!(app.behavior.diagram.fit);
|
||||
let camera = (app.behavior.diagram.pan, app.behavior.diagram.zoom);
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
// Move far enough to change projected bounds. Refitting now would visibly
|
||||
// jump the camera even though the canvas has not changed size.
|
||||
event(
|
||||
app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position { x: 4000., y: 3000. },
|
||||
},
|
||||
);
|
||||
let changed = app.state_context().unwrap();
|
||||
assert_ne!(changed.revision, context.revision);
|
||||
assert_eq!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera
|
||||
);
|
||||
assert!(app.behavior.diagram.fit);
|
||||
let after_move = app.document.snapshot();
|
||||
event(app, Event::Viewport(changed, size));
|
||||
assert_eq!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera,
|
||||
"Context initialization is not a canvas resize"
|
||||
);
|
||||
event(app, Event::Choose(Tool::Transition));
|
||||
let changed_tool = app.state_context().unwrap();
|
||||
assert_ne!(changed_tool.epoch, changed.epoch);
|
||||
event(app, Event::Viewport(changed_tool, size));
|
||||
assert_eq!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera
|
||||
);
|
||||
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
|
||||
// A real resize still uses automatic fitting while fit mode is active.
|
||||
event(
|
||||
app,
|
||||
Event::Viewport(changed_tool, iced::Size::new(1200., 720.)),
|
||||
);
|
||||
assert_ne!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera
|
||||
);
|
||||
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_semantic_states_keep_architecture_and_behavior_placements_independent() {
|
||||
let f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let architecture = project.ensure_diagram(Layer::Sa);
|
||||
let arch_a = project.place(architecture, f.a, Position { x: 110., y: 90. });
|
||||
let arch_b = project.place(architecture, f.b, Position { x: 780., y: 240. });
|
||||
project.place(architecture, f.leaf, Position { x: 190., y: 310. });
|
||||
let state_view = state_edit::create_view(&project, f.machine).unwrap();
|
||||
let project = prepare_command(&project, &state_view.command)
|
||||
.unwrap()
|
||||
.project;
|
||||
let architecture_before = project.diagrams[&architecture].clone();
|
||||
let mut app = App::with_project(project);
|
||||
let architecture_choice = Choice {
|
||||
id: architecture,
|
||||
label: app.project().named(architecture).into(),
|
||||
};
|
||||
send(&mut app, Message::Diagram(architecture_choice));
|
||||
send(&mut app, Message::Select(f.b));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected_node, Some(arch_b));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
let context = app.state_context().unwrap();
|
||||
assert_eq!(context.diagram, state_view.diagram);
|
||||
let state_a = node(&app, context.diagram, f.a);
|
||||
assert_ne!(state_a, arch_a);
|
||||
event(&mut app, Event::Select(context, f.a));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert!(
|
||||
app.selected_node.is_none(),
|
||||
"Behavior must not borrow the architecture placement"
|
||||
);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
let before_move = app.document.snapshot();
|
||||
let original = app.project().diagrams[&context.diagram].nodes[&state_a].position;
|
||||
let destination = Position {
|
||||
x: original.x + 41.,
|
||||
y: original.y + 63.,
|
||||
};
|
||||
event(
|
||||
&mut app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: state_a,
|
||||
position: destination,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&state_a].position,
|
||||
destination
|
||||
);
|
||||
assert_eq!(app.project().diagrams[&architecture], architecture_before);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
assert_source_model_agree(&app);
|
||||
let moved = app.document.snapshot();
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.selected_node,
|
||||
Some(arch_a),
|
||||
"Returning to architecture resolves that view's placement of the same state"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&moved, &app.document.snapshot()));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before_move.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before_move.bundle);
|
||||
assert_eq!(app.project().diagrams[&architecture], architecture_before);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
+1968
-112
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
//! State actions capture their own diagram; architecture selection stays separate.
|
||||
use super::{
|
||||
state_edit::{self, Endpoint, Event, Tool},
|
||||
states,
|
||||
};
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
context_menu::{Entry, Menu},
|
||||
model_menu::{self, Opened},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
let project = app.project();
|
||||
let machine = super::selected_owner(app)
|
||||
.filter(|id| project.elements[id].kind() == Kind::StateMachine)?;
|
||||
let context = app.state_context();
|
||||
let request = &opened.request;
|
||||
let id = request.object;
|
||||
let item = |caption: &str, message: Message, enabled: bool| {
|
||||
if clean && enabled {
|
||||
Entry::action(
|
||||
caption,
|
||||
Message::Menu(model_menu::Event::Invoke(opened.token, Box::new(message))),
|
||||
)
|
||||
} else {
|
||||
Entry::disabled(caption)
|
||||
}
|
||||
};
|
||||
let diagram_event = Message::StateDiagram;
|
||||
let form = |tool| {
|
||||
Message::Behavior(super::Event::States(states::Event::Start(
|
||||
machine, tool, id,
|
||||
)))
|
||||
};
|
||||
let mut entries = vec![Entry::title(crate::views::short(project.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or cancel pending edits first"));
|
||||
}
|
||||
|
||||
if let Some(context) = context.filter(|context| context.diagram == id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Fit diagram", diagram_event(Event::Fit(context)), true),
|
||||
item(
|
||||
"Create another state view",
|
||||
diagram_event(Event::CreateView(machine)),
|
||||
true,
|
||||
),
|
||||
Entry::separator(),
|
||||
item(
|
||||
"Create region…",
|
||||
diagram_event(Event::Choose(Tool::Region)),
|
||||
true,
|
||||
),
|
||||
item(
|
||||
"New transition with details…",
|
||||
form(states::Tool::Transition),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
entries.extend([
|
||||
item("Properties", Message::EditProperties(id), true),
|
||||
item("Open source", Message::SourceForSelection, true),
|
||||
item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
if let Some(element) = project.elements.get(&id)
|
||||
&& projection::state_machine_contains(project, machine, id)
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
let creates = if element.kind() == Kind::Region {
|
||||
Some((
|
||||
"Create state…",
|
||||
Kind::State,
|
||||
states::Tool::Vertex,
|
||||
Tool::Vertex(Kind::State),
|
||||
))
|
||||
} else if element.kind() == Kind::StateMachine || element.kind().is_stable_state() {
|
||||
Some((
|
||||
"Create region…",
|
||||
Kind::Region,
|
||||
states::Tool::Region,
|
||||
Tool::Region,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((caption, kind, typed, tool)) = creates {
|
||||
entries.push(if let Some(context) = context {
|
||||
item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(tool)),
|
||||
state_edit::eligible_owner(project, context.diagram, id, kind),
|
||||
)
|
||||
} else {
|
||||
item(caption, form(typed), true)
|
||||
});
|
||||
}
|
||||
entries.push(item(
|
||||
"New transition with details…",
|
||||
form(states::Tool::Transition),
|
||||
true,
|
||||
));
|
||||
if let Some(context) = context
|
||||
&& let Some(node) = request
|
||||
.placement
|
||||
.and_then(|placement| project.diagrams[&context.diagram].nodes.get(&placement))
|
||||
.filter(|node| node.element == id)
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item(
|
||||
"Placement properties",
|
||||
Message::EditProperties(node.id),
|
||||
true,
|
||||
));
|
||||
if element.kind() == Kind::StateMachine
|
||||
|| element.kind() == Kind::Region
|
||||
|| element.kind().is_stable_state()
|
||||
{
|
||||
let mut changed = node.clone();
|
||||
changed.collapsed = !changed.collapsed;
|
||||
entries.push(item(
|
||||
if node.collapsed {
|
||||
"Unfold contents"
|
||||
} else {
|
||||
"Fold contents"
|
||||
},
|
||||
Message::Command(Command::EditNode {
|
||||
diagram: context.diagram,
|
||||
original: node.id,
|
||||
node: changed,
|
||||
}),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Hide from this diagram",
|
||||
Message::Command(Command::Delete(node.id)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id)
|
||||
&& relation.kind() == RelationKind::StateTransition
|
||||
&& projection::state_machine_contains(project, machine, relation.source())
|
||||
&& projection::state_machine_contains(project, machine, relation.target())
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
let edit = context.map_or_else(
|
||||
|| Message::Behavior(super::Event::States(states::Event::Edit(id))),
|
||||
|context| diagram_event(Event::EditTransition(context, id)),
|
||||
);
|
||||
entries.push(item("Edit transition", edit, true));
|
||||
if let Some(context) = context {
|
||||
let visible = projection::project_view(project, context.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == id);
|
||||
for (caption, endpoint) in [
|
||||
("Reconnect source", Endpoint::Source),
|
||||
("Reconnect target", Endpoint::Target),
|
||||
] {
|
||||
entries.push(item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(Tool::Reconnect {
|
||||
relation: id,
|
||||
endpoint,
|
||||
})),
|
||||
visible,
|
||||
));
|
||||
}
|
||||
if !visible {
|
||||
entries.push(Entry::title(
|
||||
"Use transition details for hidden or internal endpoints",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if id != project.id {
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(model_menu::Event::Close),
|
||||
})
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
//! State geometry follows semantic containment; presentation never reparents records.
|
||||
use super::*;
|
||||
|
||||
fn internal_transitions(p: &Project, id: Id) -> impl Iterator<Item = &Relation> {
|
||||
p.relations.values().filter(move |r| {
|
||||
matches!(&r.body, RelationBody::StateTransition {
|
||||
source, transition_kind: TransitionKind::Internal, ..
|
||||
} if *source == id)
|
||||
})
|
||||
}
|
||||
|
||||
fn content_width(p: &Project, owner: Id) -> f32 {
|
||||
p.children(owner)
|
||||
.map(|e| match e.kind() {
|
||||
Kind::Region => content_width(p, e.id) + 120.,
|
||||
k if k.is_stable_state() => content_width(p, e.id) + 32.,
|
||||
_ => 300.,
|
||||
})
|
||||
.fold(300., f32::max)
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub(super) fn states(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(400., 180.),
|
||||
selected,
|
||||
};
|
||||
if !p.children(owner).any(|e| e.kind() == Kind::Region) {
|
||||
return scene;
|
||||
}
|
||||
let width = content_width(p, owner) + 40.;
|
||||
scene.boxes.push(BoxItem {
|
||||
id: owner,
|
||||
rect: Rectangle::new(Point::new(24., 20.), Size::new(width, 100.)),
|
||||
title: p.named(owner).into(),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::StateMachine),
|
||||
});
|
||||
let mut y = 64.;
|
||||
for region in p.children(owner).filter(|e| e.kind() == Kind::Region) {
|
||||
y = scene.state_region(p, region.id, 44., y, width - 40.) + 14.;
|
||||
}
|
||||
scene.boxes[0].rect.height = y - 20.;
|
||||
|
||||
// Entry/exit symbols are region-owned but attach to the containing boundary.
|
||||
let rectangles: BTreeMap<_, _> = scene.boxes.iter().map(|b| (b.id, b.rect)).collect();
|
||||
for b in &mut scene.boxes {
|
||||
if !matches!(b.marker, Some(Kind::EntryPoint | Kind::ExitPoint)) {
|
||||
continue;
|
||||
}
|
||||
let boundary = p
|
||||
.elements
|
||||
.get(&b.id)
|
||||
.and_then(|e| e.parent)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.parent);
|
||||
if let Some(rect) = boundary.and_then(|id| rectangles.get(&id)) {
|
||||
// The symbol is centered 16 px into its 32 px hit target.
|
||||
b.rect.x = if b.marker == Some(Kind::EntryPoint) {
|
||||
rect.x - 16.
|
||||
} else {
|
||||
rect.x + rect.width - 16.
|
||||
};
|
||||
}
|
||||
}
|
||||
let vertices: BTreeMap<_, _> = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|b| b.marker.is_some_and(|k| k.is_state()))
|
||||
.map(|b| (b.id, (b.rect, b.marker.unwrap())))
|
||||
.collect();
|
||||
for (index, r) in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::StateTransition
|
||||
&& vertices.contains_key(&r.source())
|
||||
&& vertices.contains_key(&r.target())
|
||||
})
|
||||
.enumerate()
|
||||
{
|
||||
if matches!(
|
||||
&r.body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Internal,
|
||||
..
|
||||
}
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let (a, ak) = vertices[&r.source()];
|
||||
let (b, bk) = vertices[&r.target()];
|
||||
let ay = a.y + if ak.is_stable_state() { 25. } else { 28. };
|
||||
let by = if r.source() == r.target() {
|
||||
ay + 28.
|
||||
} else {
|
||||
b.y + if bk.is_stable_state() { 25. } else { 28. }
|
||||
};
|
||||
let ax = if ak.is_stable_state() {
|
||||
a.x + a.width
|
||||
} else {
|
||||
a.x + 32.
|
||||
};
|
||||
let bx = if bk.is_stable_state() {
|
||||
b.x + b.width
|
||||
} else {
|
||||
b.x + 32.
|
||||
};
|
||||
let lane = (ax.max(bx) + 28. + (index % 4) as f32 * 20.).max(360.);
|
||||
let activation = states::transition_summary(p, r);
|
||||
scene.lines.push(Line {
|
||||
id: r.id,
|
||||
points: vec![
|
||||
Point::new(ax, ay),
|
||||
Point::new(lane, ay),
|
||||
Point::new(lane, by),
|
||||
Point::new(bx, by),
|
||||
],
|
||||
label: format!("{}. {}", index + 1, short(&activation, 24)),
|
||||
label_at: Point::new(lane + 5., (ay + by) / 2.),
|
||||
dashed: matches!(
|
||||
&r.body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Local,
|
||||
..
|
||||
}
|
||||
),
|
||||
});
|
||||
scene.size.width = scene.size.width.max(lane + 220.);
|
||||
}
|
||||
scene.size.width = scene.size.width.max(width + 180.);
|
||||
scene.size.height = (y + 24.).max(180.);
|
||||
scene
|
||||
}
|
||||
|
||||
fn state_region(&mut self, p: &Project, id: Id, x: f32, top: f32, width: f32) -> f32 {
|
||||
let index = self.boxes.len();
|
||||
self.boxes.push(BoxItem {
|
||||
id,
|
||||
rect: Rectangle::new(Point::new(x, top), Size::new(width, 90.)),
|
||||
title: format!("REGION · {}", p.named(id)),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::Region),
|
||||
});
|
||||
let mut y = top + 42.;
|
||||
for state in p.children(id).filter(|e| e.kind().is_state()) {
|
||||
let stable = state.kind().is_stable_state();
|
||||
let inner_width = if stable { width - 100. } else { 300. };
|
||||
let index = self.boxes.len();
|
||||
let detail = match state.shared() {
|
||||
Some(SharedBody::State {
|
||||
entry,
|
||||
exit,
|
||||
do_activity,
|
||||
..
|
||||
}) => [("entry", entry), ("do", do_activity), ("exit", exit)]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(label, value)| format!("{label} / {value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
_ => String::new(),
|
||||
};
|
||||
self.boxes.push(BoxItem {
|
||||
id: state.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(x + 18., y),
|
||||
Size::new(inner_width, if stable { 76. } else { 56. }),
|
||||
),
|
||||
title: state.name.clone(),
|
||||
detail,
|
||||
marker: Some(state.kind()),
|
||||
});
|
||||
let start = y;
|
||||
y += if stable { 76. } else { 56. };
|
||||
for r in internal_transitions(p, state.id) {
|
||||
self.boxes.push(BoxItem {
|
||||
id: r.id,
|
||||
rect: Rectangle::new(Point::new(x + 28., y), Size::new(inner_width - 20., 55.)),
|
||||
title: format!("INTERNAL · {}", r.name),
|
||||
detail: states::transition_summary(p, r),
|
||||
marker: None,
|
||||
});
|
||||
y += 61.;
|
||||
}
|
||||
for region in p.children(state.id).filter(|e| e.kind() == Kind::Region) {
|
||||
y = self.state_region(p, region.id, x + 34., y, inner_width - 32.) + 12.;
|
||||
}
|
||||
self.boxes[index].rect.height = y - start;
|
||||
y += 18.;
|
||||
}
|
||||
self.boxes[index].rect.height = (y - top).max(90.);
|
||||
top + self.boxes[index].rect.height
|
||||
}
|
||||
|
||||
pub(super) fn is_container(&self, item: &BoxItem) -> bool {
|
||||
matches!(
|
||||
item.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
) || (item.marker.is_some_and(|kind| kind.is_stable_state())
|
||||
&& self.boxes.iter().any(|other| {
|
||||
other.id != item.id
|
||||
&& item.rect.contains(other.rect.position())
|
||||
&& item.rect.contains(Point::new(
|
||||
other.rect.x + other.rect.width,
|
||||
other.rect.y + other.rect.height,
|
||||
))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pseudostates use actual vector shapes, with a full row as their hit target.
|
||||
pub(super) fn draw_symbol(f: &mut Frame, b: &BoxItem, color: Color) {
|
||||
let center = Point::new(b.rect.x + 16., b.rect.y + 28.);
|
||||
let stroke = Stroke::default().with_color(color).with_width(1.8);
|
||||
let circle = Path::circle(center, 11.);
|
||||
match b.marker.unwrap() {
|
||||
Kind::Initial | Kind::Junction => f.fill(
|
||||
&Path::circle(
|
||||
center,
|
||||
if b.marker == Some(Kind::Initial) {
|
||||
8.
|
||||
} else {
|
||||
5.
|
||||
},
|
||||
),
|
||||
color,
|
||||
),
|
||||
Kind::Final => {
|
||||
f.stroke(&circle, stroke);
|
||||
f.fill(&Path::circle(center, 7.), color);
|
||||
}
|
||||
Kind::Choice => f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(Point::new(center.x, center.y - 12.));
|
||||
p.line_to(Point::new(center.x + 12., center.y));
|
||||
p.line_to(Point::new(center.x, center.y + 12.));
|
||||
p.line_to(Point::new(center.x - 12., center.y));
|
||||
p.close();
|
||||
}),
|
||||
stroke,
|
||||
),
|
||||
Kind::Fork | Kind::Join => f.fill_rectangle(
|
||||
Point::new(center.x - 3., center.y - 18.),
|
||||
Size::new(6., 36.),
|
||||
color,
|
||||
),
|
||||
Kind::ShallowHistory | Kind::DeepHistory => {
|
||||
f.stroke(&circle, stroke);
|
||||
f.fill_text(Text {
|
||||
content: if b.marker == Some(Kind::DeepHistory) {
|
||||
"H*"
|
||||
} else {
|
||||
"H"
|
||||
}
|
||||
.into(),
|
||||
position: Point::new(center.x - 8., center.y - 9.),
|
||||
color,
|
||||
size: 13.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Kind::EntryPoint => {
|
||||
f.fill(&circle, Color::WHITE);
|
||||
f.stroke(&circle, stroke);
|
||||
}
|
||||
Kind::ExitPoint | Kind::Terminate => {
|
||||
if b.marker == Some(Kind::ExitPoint) {
|
||||
f.fill(&circle, Color::WHITE);
|
||||
f.stroke(&circle, stroke);
|
||||
}
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(Point::new(center.x - 7., center.y - 7.));
|
||||
p.line_to(Point::new(center.x + 7., center.y + 7.));
|
||||
p.move_to(Point::new(center.x - 7., center.y + 7.));
|
||||
p.line_to(Point::new(center.x + 7., center.y - 7.));
|
||||
}),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! State workspace controls. Persistent semantics and layout live in the model.
|
||||
use super::state_edit::{Event, Tool};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, opaque, stack};
|
||||
|
||||
pub struct State {
|
||||
pub diagram: Option<Id>,
|
||||
pub placement: Option<Id>,
|
||||
pub tool: Tool,
|
||||
pub viewport: Option<Size>,
|
||||
pub zoom: f32,
|
||||
pub pan: iced::Vector,
|
||||
pub fit: bool,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
diagram: None,
|
||||
placement: None,
|
||||
tool: Tool::Select,
|
||||
viewport: None,
|
||||
zoom: 0.8,
|
||||
pan: iced::Vector::new(24., 48.),
|
||||
fit: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
let valid = |id: &Id| {
|
||||
app.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.projection.state_machine() == Some(machine))
|
||||
};
|
||||
app.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.filter(valid)
|
||||
.or_else(|| app.project().diagrams.keys().find(|id| valid(id)).copied())
|
||||
}
|
||||
|
||||
fn event(e: Event) -> Message {
|
||||
Message::StateDiagram(e)
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let Some(id) = diagram(app, machine) else {
|
||||
let base = out.push(container(column![
|
||||
text("Create a state diagram").size(20),
|
||||
label("Place the machine’s existing regions and states in an editable view. Creating another view leaves the existing views unchanged."),
|
||||
button("Create diagram").padding(12).on_press(event(Event::CreateView(machine))),
|
||||
row![
|
||||
action("Add region…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Region, app.selected)))),
|
||||
action("Add state…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Vertex, app.selected)))),
|
||||
transition_details(machine, app.selected),
|
||||
].spacing(5),
|
||||
].spacing(14)).padding(24).style(style::card)).padding(18).into();
|
||||
return with_form(app, machine, base);
|
||||
};
|
||||
let context = app
|
||||
.state_context()
|
||||
.expect("a visible state diagram has a context");
|
||||
let p = app.project();
|
||||
let state = &app.behavior.diagram;
|
||||
let views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.projection.state_machine() == Some(machine))
|
||||
.map(|d| Choice {
|
||||
id: d.id,
|
||||
label: d.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let current = views.iter().find(|v| v.id == id).cloned();
|
||||
out = out.push(
|
||||
row![
|
||||
pick_list(views, current, Message::BehaviorDiagram)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
action("+ View", event(Event::CreateView(machine))),
|
||||
action("Fit", event(Event::Fit(context))),
|
||||
action("−", event(Event::Zoom(context, state.zoom / 1.2))),
|
||||
label(format!("{:.0}%", state.zoom * 100.)),
|
||||
action("+", event(Event::Zoom(context, state.zoom * 1.2))),
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
|
||||
let tool = |name: &'static str, value: Tool| {
|
||||
button(text(name).size(12))
|
||||
.padding([8, 10])
|
||||
.style(if state.tool == value {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Choose(value)))
|
||||
};
|
||||
let kinds = [
|
||||
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,
|
||||
];
|
||||
let selected_kind = if let Tool::Vertex(kind) = state.tool {
|
||||
Some(kind)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out = out.push(
|
||||
row![
|
||||
tool("Select / move", Tool::Select),
|
||||
pick_list(kinds.to_vec(), selected_kind, |k| event(Event::Choose(
|
||||
Tool::Vertex(k)
|
||||
)))
|
||||
.placeholder("+ State / symbol")
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
tool("+ Region", Tool::Region),
|
||||
tool("Transition", Tool::Transition),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
|
||||
let missing = state_edit::unplaced(p, id)
|
||||
.into_iter()
|
||||
.map(|element| Choice {
|
||||
id: element,
|
||||
label: format!("{} · {}", p.named(element), p.elements[&element].kind()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
y: (90. - state.pan.y) / state.zoom,
|
||||
};
|
||||
out = out.push(
|
||||
pick_list(missing, None::<Choice>, move |c| {
|
||||
event(Event::Place {
|
||||
context,
|
||||
element: c.id,
|
||||
position,
|
||||
})
|
||||
})
|
||||
.placeholder("Show an element not yet in this view…")
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
|
||||
project: p,
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
tool: state.tool,
|
||||
zoom: state.zoom,
|
||||
pan: state.pan,
|
||||
editable: !app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
out = out.push(canvas.map(event));
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
with_form(app, machine, base)
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
action(
|
||||
"New transition with details…",
|
||||
Message::Behavior(super::Event::States(states::Event::Start(
|
||||
machine,
|
||||
states::Tool::Transition,
|
||||
selected,
|
||||
))),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn with_form<'a>(app: &'a App, machine: Id, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||
if let Some(form) = states::form(app, machine) {
|
||||
stack![
|
||||
base,
|
||||
opaque(
|
||||
container(
|
||||
container(
|
||||
scrollable(form).height((app.window_size.height - 250.).clamp(280., 610.))
|
||||
)
|
||||
.padding(18)
|
||||
.width(600)
|
||||
.style(style::card)
|
||||
)
|
||||
.center(Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! State authoring through the same validated commands as source and REST.
|
||||
use super::*;
|
||||
use iced::widget::{Column, checkbox, column, text_input};
|
||||
use iced::widget::{checkbox, column, text_input};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -41,15 +41,10 @@ pub enum Event {
|
||||
Timed(bool),
|
||||
TimeKind(String),
|
||||
Expression(String),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
}
|
||||
struct Draft {
|
||||
machine: Id,
|
||||
@@ -189,7 +184,28 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.machine)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a state modeling tool.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..) | Event::Edit(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Start(machine, tool, selected) => {
|
||||
let mut d = Draft::new(machine, tool);
|
||||
@@ -249,20 +265,8 @@ impl State {
|
||||
d.effect = effect.clone();
|
||||
self.draft = Some(d);
|
||||
}
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a state modeling tool.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
event => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match event {
|
||||
@@ -347,359 +351,218 @@ fn picker<'a>(
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn transition_summary(p: &Project, r: &Relation) -> String {
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
triggers,
|
||||
trigger,
|
||||
guard,
|
||||
effects,
|
||||
effect,
|
||||
..
|
||||
} = &r.body
|
||||
else {
|
||||
return r.name.clone();
|
||||
};
|
||||
let activation = if !triggers.is_empty() {
|
||||
triggers
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ")
|
||||
} else if !trigger.trim().is_empty() {
|
||||
format!("Unresolved trigger: {trigger}")
|
||||
} else if p
|
||||
.elements
|
||||
.get(&r.source())
|
||||
.is_some_and(|e| e.kind().is_stable_state())
|
||||
{
|
||||
"Completion".into()
|
||||
} else {
|
||||
"Transient routing".into()
|
||||
};
|
||||
let mut result = format!("{transition_kind:?} · {activation}");
|
||||
if !triggers.is_empty() && !trigger.is_empty() {
|
||||
result.push_str(&format!(" · {trigger}"));
|
||||
}
|
||||
if !guard.is_empty() {
|
||||
result.push_str(&format!(" [{guard}]"));
|
||||
}
|
||||
if !effects.is_empty() {
|
||||
result.push_str(&format!(
|
||||
" / {}",
|
||||
effects
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
if !effect.is_empty() {
|
||||
result.push_str(&format!(" / {effect}"));
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
pub(super) fn form(app: &App, machine: Id) -> Option<Element<'_, Message>> {
|
||||
let p = app.project();
|
||||
let state = &app.behavior.states;
|
||||
out = out.push(
|
||||
row![
|
||||
action(
|
||||
"+ Vertex",
|
||||
event(Event::Start(machine, Tool::Vertex, app.selected))
|
||||
),
|
||||
action(
|
||||
"+ Region",
|
||||
event(Event::Start(machine, Tool::Region, app.selected))
|
||||
),
|
||||
action(
|
||||
"+ Transition",
|
||||
event(Event::Start(machine, Tool::Transition, app.selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(machine)),
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.machine == machine) {
|
||||
let mut form = column![
|
||||
text(if d.original.is_some() {
|
||||
"Edit transition"
|
||||
} else {
|
||||
match d.tool {
|
||||
Tool::Vertex => "Add state or pseudostate",
|
||||
Tool::Region => "Add region",
|
||||
Tool::Transition => "Connect states",
|
||||
}
|
||||
})
|
||||
.size(17),
|
||||
text_input("Engineering name (optional)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Transition {
|
||||
if d.tool == Tool::Vertex {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state())
|
||||
.collect::<Vec<_>>(),
|
||||
Some(d.vertex),
|
||||
|k| event(Event::Vertex(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let parents = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
in_machine(p, e.id, machine)
|
||||
&& if d.tool == Tool::Vertex {
|
||||
e.kind() == Kind::Region
|
||||
} else {
|
||||
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
|
||||
}
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
|
||||
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
|
||||
let d = app
|
||||
.behavior
|
||||
.states
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.machine == machine)?;
|
||||
let mut form = column![
|
||||
text(if d.original.is_some() {
|
||||
"Edit transition"
|
||||
} else {
|
||||
let mut context = p.clone();
|
||||
if let Some(id) = d.original {
|
||||
context.relations.remove(&id);
|
||||
match d.tool {
|
||||
Tool::Vertex => "Add state or pseudostate",
|
||||
Tool::Region => "Add region",
|
||||
Tool::Transition => "Connect states",
|
||||
}
|
||||
let vertices: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
let targets = vertices
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
d.source.is_some_and(|source| {
|
||||
if d.kind == TransitionKind::Internal {
|
||||
return c.id == source && p.elements[&source].kind().is_stable_state();
|
||||
}
|
||||
if d.kind == TransitionKind::Local
|
||||
&& p.elements[&source].kind().is_stable_state()
|
||||
{
|
||||
return c.id != source && in_machine(p, c.id, source);
|
||||
}
|
||||
validation::can_connect(
|
||||
&context,
|
||||
RelationKind::StateTransition,
|
||||
source,
|
||||
c.id,
|
||||
)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
form = form
|
||||
.push(picker(vertices, d.source, "From state…", Event::Source))
|
||||
.push(picker(targets, d.target, "To state…", Event::Target))
|
||||
.push(
|
||||
pick_list(
|
||||
[
|
||||
TransitionChoice(TransitionKind::External),
|
||||
TransitionChoice(TransitionKind::Local),
|
||||
TransitionChoice(TransitionKind::Internal),
|
||||
],
|
||||
Some(TransitionChoice(d.kind)),
|
||||
|k| event(Event::Kind(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
let layer = p.elements[&machine].layer();
|
||||
let triggers = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& (e.kind() == Kind::Event || e.kind().is_work())
|
||||
&& !d.triggers.contains(&e.id)
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(label("Trigger events / work (alternatives)"));
|
||||
for id in &d.triggers {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveTrigger(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(
|
||||
triggers,
|
||||
None,
|
||||
"Add existing trigger…",
|
||||
Event::Trigger,
|
||||
))
|
||||
.push(
|
||||
checkbox(d.timed)
|
||||
.label("Add a time event with this transition")
|
||||
.on_toggle(|b| event(Event::Timed(b))),
|
||||
);
|
||||
if d.timed {
|
||||
form = form.push(
|
||||
row![
|
||||
pick_list(
|
||||
["After".to_string(), "At".to_string()],
|
||||
Some(format!("{:?}", d.time_kind)),
|
||||
|s| event(Event::TimeKind(s))
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
text_input("Time expression, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
text_input("Trigger notes (optional)", &d.trigger)
|
||||
.on_input(|s| event(Event::TriggerNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
})
|
||||
.size(17),
|
||||
text_input("Engineering name (optional)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Transition {
|
||||
if d.tool == Tool::Vertex {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state())
|
||||
.collect::<Vec<_>>(),
|
||||
Some(d.vertex),
|
||||
|k| event(Event::Vertex(k)),
|
||||
)
|
||||
.push(
|
||||
text_input("Guard condition (optional)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let effects = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
for id in &d.effects {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveEffect(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(effects, None, "Add effect work…", Event::Effect))
|
||||
.push(
|
||||
text_input("Effect notes (optional)", &d.effect)
|
||||
.on_input(|s| event(Event::EffectNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
|
||||
form=form.push(label("No event trigger: completion from a state, transient routing from a pseudostate."));
|
||||
}
|
||||
}
|
||||
let candidate = d.command(p).and_then(|command| {
|
||||
execute_batch(p, &[command])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(error) = &candidate {
|
||||
form = form.push(text(error.clone()).size(12).color(style::MUTED));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Apply")
|
||||
.padding(9)
|
||||
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let mut scene = Scene::states(p, machine, app.selected);
|
||||
if scene.boxes.is_empty() {
|
||||
out=out.push(container(column![text("Start with a region").size(18),label("Add a region, then an Initial vertex and a State or Mode. Connect them to describe the default entry.")].spacing(12)).padding(24).style(style::card));
|
||||
} else {
|
||||
let width = state
|
||||
.viewport
|
||||
.map_or((app.window_size.width - 600.).max(200.), |s| s.width);
|
||||
let scale = state.zoom.unwrap_or_else(|| {
|
||||
((width - 24.).max(1.) / scene.size.width)
|
||||
.min(440. / scene.size.height)
|
||||
.clamp(0.05, 1.)
|
||||
});
|
||||
scene.scale = scale;
|
||||
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.)))
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.push(
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(180., 460.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|s| event(Event::Viewport(s)))
|
||||
.on_resize(|s| event(Event::Viewport(s))),
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::StateTransition && in_machine(p, r.source(), machine))
|
||||
{
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
action(
|
||||
&format!("{} → {}", p.named(r.source()), p.named(r.target())),
|
||||
event(Event::Edit(r.id))
|
||||
)
|
||||
.width(Fill),
|
||||
text(transition_summary(p, r)).size(12),
|
||||
action("Properties", Message::EditProperties(r.id))
|
||||
}
|
||||
let parents = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
in_machine(p, e.id, machine)
|
||||
&& if d.tool == Tool::Vertex {
|
||||
e.kind() == Kind::Region
|
||||
} else {
|
||||
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
|
||||
}
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
|
||||
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
|
||||
} else {
|
||||
let mut context = p.clone();
|
||||
if let Some(id) = d.original {
|
||||
context.relations.remove(&id);
|
||||
}
|
||||
let vertices: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
let targets = vertices
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
d.source.is_some_and(|source| {
|
||||
if d.kind == TransitionKind::Internal {
|
||||
return c.id == source && p.elements[&source].kind().is_stable_state();
|
||||
}
|
||||
if d.kind == TransitionKind::Local
|
||||
&& p.elements[&source].kind().is_stable_state()
|
||||
{
|
||||
return c.id != source && in_machine(p, c.id, source);
|
||||
}
|
||||
validation::can_connect(&context, RelationKind::StateTransition, source, c.id)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
form = form
|
||||
.push(picker(vertices, d.source, "From state…", Event::Source))
|
||||
.push(picker(targets, d.target, "To state…", Event::Target))
|
||||
.push(
|
||||
pick_list(
|
||||
[
|
||||
TransitionChoice(TransitionKind::External),
|
||||
TransitionChoice(TransitionKind::Local),
|
||||
TransitionChoice(TransitionKind::Internal),
|
||||
],
|
||||
Some(TransitionChoice(d.kind)),
|
||||
|k| event(Event::Kind(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
let layer = p.elements[&machine].layer();
|
||||
let triggers = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& (e.kind() == Kind::Event || e.kind().is_work())
|
||||
&& !d.triggers.contains(&e.id)
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(label("Trigger events / work (alternatives)"));
|
||||
for id in &d.triggers {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveTrigger(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(
|
||||
triggers,
|
||||
None,
|
||||
"Add existing trigger…",
|
||||
Event::Trigger,
|
||||
))
|
||||
.push(
|
||||
checkbox(d.timed)
|
||||
.label("Add a time event with this transition")
|
||||
.on_toggle(|b| event(Event::Timed(b))),
|
||||
);
|
||||
if d.timed {
|
||||
form = form.push(
|
||||
row![
|
||||
pick_list(
|
||||
["After".to_string(), "At".to_string()],
|
||||
Some(format!("{:?}", d.time_kind)),
|
||||
|s| event(Event::TimeKind(s))
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
text_input("Time expression, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
text_input("Trigger notes (optional)", &d.trigger)
|
||||
.on_input(|s| event(Event::TriggerNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
);
|
||||
.push(
|
||||
text_input("Guard condition (optional)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let effects = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
for id in &d.effects {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveEffect(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(effects, None, "Add effect work…", Event::Effect))
|
||||
.push(
|
||||
text_input("Effect notes (optional)", &d.effect)
|
||||
.on_input(|s| event(Event::EffectNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
|
||||
form = form.push(label(
|
||||
"No event trigger: completion from a state, transient routing from a pseudostate.",
|
||||
));
|
||||
}
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
let candidate = d.command(p).and_then(|command| {
|
||||
execute_batch(p, &[command])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(error) = &candidate {
|
||||
form = form.push(text(error.clone()).size(12).color(style::MUTED));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Apply")
|
||||
.padding(9)
|
||||
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
Some(container(form).padding(4).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -771,7 +634,7 @@ mod tests {
|
||||
assert_eq!(guard, "link unavailable");
|
||||
assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s")));
|
||||
assert_eq!(&language::parse(&language::format(result)).unwrap(), result);
|
||||
assert!(transition_summary(result, r).contains(" OR "));
|
||||
assert!(routing::state_transition_summary(result, r).contains(" OR "));
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
@@ -810,6 +673,7 @@ mod tests {
|
||||
}
|
||||
let mut result =
|
||||
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
let r = result.relations.values_mut().next().unwrap();
|
||||
r.description = "Retain configuration".into();
|
||||
let (id, alias) = (r.id, r.alias.clone());
|
||||
@@ -828,14 +692,7 @@ mod tests {
|
||||
result.relations[&id].source(),
|
||||
result.relations[&id].target()
|
||||
);
|
||||
let scene = Scene::states(&result, machine, id);
|
||||
assert!(
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.any(|b| b.id == id && b.title.starts_with("INTERNAL"))
|
||||
);
|
||||
assert!(!scene.lines.iter().any(|l| l.id == id));
|
||||
assert!(!routing::state_transition_summary(&result, &result.relations[&id]).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn selected_composite_owns_new_region_and_history_uses_selected_region() {
|
||||
@@ -846,6 +703,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let result =
|
||||
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
let region = result
|
||||
.children(ready)
|
||||
.find(|e| e.kind() == Kind::Region)
|
||||
@@ -867,29 +725,6 @@ mod tests {
|
||||
.children(region)
|
||||
.any(|e| e.kind() == Kind::DeepHistory)
|
||||
);
|
||||
let mut scene = Scene::states(&result, machine, ready);
|
||||
let container = scene.boxes.iter().find(|b| b.id == ready).unwrap().rect;
|
||||
let child = scene.boxes.iter().find(|b| b.id == region).unwrap().rect;
|
||||
assert!(container.contains(child.position()));
|
||||
assert!(container.contains(Point::new(child.x + child.width, child.y + child.height)));
|
||||
let history = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|b| b.marker == Some(Kind::DeepHistory))
|
||||
.unwrap();
|
||||
let id = history.id;
|
||||
let point = Point::new((history.rect.x + 16.) * 0.5, (history.rect.y + 28.) * 0.5);
|
||||
scene.scale = 0.5;
|
||||
let action = canvas::Program::update(
|
||||
&scene,
|
||||
&mut (),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, scene.size),
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(action, Some(Message::Select(selected)) if selected == id));
|
||||
assert_eq!(language::parse(&language::format(&result)).unwrap(), result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
//! Process/chain authoring and projection use contextual occurrences and explicit
|
||||
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
mod direct;
|
||||
mod view;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) mod direct_tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -40,9 +48,8 @@ pub enum Event {
|
||||
Guard(String),
|
||||
AlternateGuard(String),
|
||||
Control(ControlChoice),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
Search(usize, String),
|
||||
Page(usize, usize),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ControlChoice(pub ControlKind);
|
||||
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
struct Draft {
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
browsers: [Browser; 4],
|
||||
owner: Id,
|
||||
tool: Tool,
|
||||
name: String,
|
||||
@@ -76,11 +97,29 @@ struct Draft {
|
||||
control: ControlKind,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.owner)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a contribution tool first.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
Event::Start(owner, tool, selected) => {
|
||||
let e = p
|
||||
.elements
|
||||
@@ -106,6 +145,9 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool,
|
||||
name: String::new(),
|
||||
@@ -118,19 +160,8 @@ impl State {
|
||||
control: ControlKind::Choice,
|
||||
});
|
||||
}
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a contribution tool first.")?;
|
||||
let command = d.command(p)?;
|
||||
// Validate before clearing the user's inputs. The ordinary command
|
||||
// handler remains responsible for the single undoable transaction.
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
other => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match other {
|
||||
@@ -144,6 +175,11 @@ impl State {
|
||||
Event::Guard(value) => d.guard = value,
|
||||
Event::AlternateGuard(value) => d.alternate_guard = value,
|
||||
Event::Control(value) => d.control = value.0,
|
||||
Event::Search(index, value) if index < 4 => {
|
||||
d.browsers[index].search = value;
|
||||
d.browsers[index].page = 0;
|
||||
}
|
||||
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -153,12 +189,13 @@ impl State {
|
||||
}
|
||||
|
||||
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
||||
self.zoom.unwrap_or_else(|| {
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
})
|
||||
if !self.fit {
|
||||
return self.zoom.unwrap_or(1.);
|
||||
}
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
}
|
||||
}
|
||||
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
||||
@@ -237,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
|
||||
})
|
||||
.map(|e| Choice {
|
||||
id: e.id,
|
||||
label: format!("{} · {}", e.name, e.alias),
|
||||
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
fn semantic_path(p: &Project, id: Id) -> String {
|
||||
let mut path = Vec::new();
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
let Some(e) = p.elements.get(&id) else { break };
|
||||
path.push(e.alias.as_str());
|
||||
current = e.parent;
|
||||
}
|
||||
path.reverse();
|
||||
path.join(" / ")
|
||||
}
|
||||
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
|
||||
p.elements
|
||||
.get(&owner)
|
||||
@@ -384,6 +432,40 @@ impl Draft {
|
||||
if self.before.is_some() && self.before == self.after {
|
||||
return Err("Choose different preceding and following contributions.".into());
|
||||
}
|
||||
let captured = self
|
||||
.insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, self.owner, r.source())
|
||||
&& is_contribution(p, self.owner, r.target())
|
||||
})
|
||||
.ok_or("The captured path no longer exists in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if captured
|
||||
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
|
||||
{
|
||||
return Err("The captured path changed. Choose its position again.".into());
|
||||
}
|
||||
let matches: Vec<_> = sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
|
||||
.collect();
|
||||
let split_link = if self.tool == Tool::Connect {
|
||||
None
|
||||
} else if captured.is_some() {
|
||||
captured
|
||||
} else if !self.fixed && matches.len() == 1 {
|
||||
Some(matches[0])
|
||||
} else if !self.fixed && matches.len() > 1 {
|
||||
return Err("Select the specific sequence link to insert on.".into());
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut commands = preserved_order(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -408,7 +490,6 @@ impl Draft {
|
||||
{
|
||||
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
|
||||
}
|
||||
require_guard(p, source, &self.guard)?;
|
||||
commands.push(link(p, source, target, &self.guard));
|
||||
return Ok(unique_batch(p, commands));
|
||||
}
|
||||
@@ -449,12 +530,6 @@ impl Draft {
|
||||
return Err("Choose same-layer work for the second path.".into());
|
||||
}
|
||||
let parallel = self.control == ControlKind::Fork;
|
||||
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Describe both alternative guards; use 'else' for the fallback path.".into(),
|
||||
);
|
||||
}
|
||||
let split = occurrence(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -542,30 +617,47 @@ impl Draft {
|
||||
commands.push(Command::CreateElement(e));
|
||||
(id, id)
|
||||
};
|
||||
if let Some(before) = self.before {
|
||||
// Branch guards belong to the split's two outgoing paths. A branch
|
||||
// inserted after another choice must first use an explicit link.
|
||||
let guard = if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
};
|
||||
require_guard(p, before, guard)?;
|
||||
commands.push(link(p, before, first, guard));
|
||||
if let Some(existing) = split_link {
|
||||
let mut updated = existing.clone();
|
||||
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
|
||||
*target = first;
|
||||
}
|
||||
commands.push(Command::EditRelation(existing.id, updated));
|
||||
} else if let Some(before) = self.before {
|
||||
commands.push(link(
|
||||
p,
|
||||
before,
|
||||
first,
|
||||
if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(after) = self.after {
|
||||
commands.push(link(p, last, after, ""));
|
||||
}
|
||||
// Inserting between two selected contributions replaces their direct
|
||||
// precedence while preserving other links and their semantic identities.
|
||||
if let (Some(before), Some(after)) = (self.before, self.after) {
|
||||
commands.extend(
|
||||
sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| r.source() == before && r.target() == after)
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
}
|
||||
let new_ids: Vec<_> = commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut order = owner.steps().expect("process steps").clone();
|
||||
let slot = self.insertion.before.or(self.after);
|
||||
let index = slot
|
||||
.map(|id| {
|
||||
order
|
||||
.iter()
|
||||
.position(|v| *v == id)
|
||||
.ok_or("The presentation slot changed.")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(order.len());
|
||||
order.splice(index..index, new_ids);
|
||||
commands.push(Command::SetSteps(self.owner, order));
|
||||
Ok(unique_batch(p, commands))
|
||||
}
|
||||
}
|
||||
@@ -588,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
|
||||
}
|
||||
Command::Batch(commands)
|
||||
}
|
||||
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
|
||||
if guard.trim().is_empty()
|
||||
&& matches!(
|
||||
p.elements.get(&source).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice | ControlKind::Loop
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
Err("Describe this choice / repeat condition, or enter 'else'.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(e: Event) -> Message {
|
||||
Message::Behavior(if matches!(e, Event::Submit) {
|
||||
super::Event::Submit
|
||||
@@ -612,307 +687,9 @@ fn event(e: Event) -> Message {
|
||||
super::Event::Topology(e)
|
||||
})
|
||||
}
|
||||
fn picker<'a>(
|
||||
choices: Vec<Choice>,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
pick_list(choices, selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
.into()
|
||||
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
view::view(app, owner, out)
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
if !p.elements[&owner].description.is_empty() {
|
||||
out = out.push(
|
||||
text(&p.elements[&owner].description)
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
|
||||
action(
|
||||
"+ Exchange",
|
||||
event(Event::Start(owner, Tool::Exchange, selected))
|
||||
),
|
||||
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
action(
|
||||
"+ Control",
|
||||
event(Event::Start(owner, Tool::Control, selected))
|
||||
),
|
||||
action(
|
||||
"Branch / parallel…",
|
||||
event(Event::Start(owner, Tool::Branch, selected))
|
||||
),
|
||||
action(
|
||||
"Connect…",
|
||||
event(Event::Start(owner, Tool::Connect, selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(owner)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.topology
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
|
||||
let legacy = legacy_order(p, owner);
|
||||
if legacy.len() > 1 {
|
||||
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices: Vec<_> = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|choice| compatible_source(p, d, choice.id))
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d.target,
|
||||
"Choose an existing source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source name)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d.alternate,
|
||||
"Work on the second path…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form.push(
|
||||
row![
|
||||
text_input("First path condition", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
text_input("Second path condition", &d.alternate_guard)
|
||||
.on_input(|s| event(Event::AlternateGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.before,
|
||||
if d.tool == Tool::Connect {
|
||||
"From contribution…"
|
||||
} else {
|
||||
"After contribution (optional)…"
|
||||
},
|
||||
Event::Before
|
||||
),
|
||||
action("Clear", event(Event::ClearBefore)),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.after,
|
||||
if d.tool == Tool::Connect {
|
||||
"To contribution…"
|
||||
} else {
|
||||
"Before contribution (optional)…"
|
||||
},
|
||||
Event::After
|
||||
),
|
||||
action("Clear", event(Event::ClearAfter)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if d.tool != Tool::Branch {
|
||||
form = form.push(
|
||||
text_input("Sequence guard (for a choice or loop)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if let Err(problem) = d.command(p) {
|
||||
form = form.push(label(problem));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|
||||
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
|
||||
{
|
||||
let mut graph = Graph::new(p, owner, app.selected);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(container(column![
|
||||
text("Start this process").size(18),
|
||||
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
|
||||
].spacing(12)).padding(24).width(Fill).style(style::card));
|
||||
return scrollable(out.padding(24)).into();
|
||||
} else if graph.edges.is_empty() {
|
||||
out = out.push(label(
|
||||
"No explicit sequence links. Connect contributions to describe topology.",
|
||||
));
|
||||
}
|
||||
graph.scale = app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
let scale = graph.scale;
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
out = out.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.))),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
} else {
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
let conversion = preserved_order(p, owner, None);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · the stored contribution order defines this sequence.",
|
||||
));
|
||||
if !conversion.is_empty() {
|
||||
out = out.push(action(
|
||||
"Convert ordered path to explicit links",
|
||||
Message::Command(unique_batch(p, conversion)),
|
||||
));
|
||||
}
|
||||
(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(canvas)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(200., 470.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|size| event(Event::Viewport(size)))
|
||||
.on_resize(|size| event(Event::Viewport(size))),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(target),
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
|
||||
{
|
||||
out = out.push(action(
|
||||
&format!("Open source: {} →", p.named(*target)),
|
||||
Message::Select(*target),
|
||||
));
|
||||
}
|
||||
for r in sequence_links(p, owner) {
|
||||
let guard = match &r.body {
|
||||
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
out = out.push(
|
||||
action(
|
||||
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
|
||||
Message::Select(r.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
p.relations
|
||||
.values()
|
||||
@@ -932,6 +709,7 @@ struct GraphNode {
|
||||
source: Option<Id>,
|
||||
preview: Vec<(Id, String)>,
|
||||
preview_links: Vec<(Id, Id)>,
|
||||
preview_total: usize,
|
||||
}
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
@@ -941,15 +719,21 @@ struct GraphEdge {
|
||||
points: Vec<Point>,
|
||||
label: Rectangle,
|
||||
}
|
||||
struct Graph {
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
}
|
||||
impl Graph {
|
||||
fn new(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
impl<'a> Graph<'a> {
|
||||
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
|
||||
Self::expanded(p, owner, selected, &BTreeSet::new())
|
||||
}
|
||||
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
@@ -990,17 +774,17 @@ impl Graph {
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut nodes = vec![];
|
||||
let mut size: Size = Size::new(640., 220.);
|
||||
for c in choices {
|
||||
let e = &p.elements[&c.id];
|
||||
let rank = ranks[&c.id];
|
||||
let slot = rows.entry(rank).or_default();
|
||||
let slot = rows.entry(rank).or_insert(24.);
|
||||
let mut node = GraphNode {
|
||||
id: e.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
|
||||
Point::new(24. + rank as f32 * 420., *slot),
|
||||
Size::new(270., 104.),
|
||||
),
|
||||
title: e.name.clone(),
|
||||
@@ -1009,8 +793,8 @@ impl Graph {
|
||||
source: None,
|
||||
preview: vec![],
|
||||
preview_links: vec![],
|
||||
preview_total: 0,
|
||||
};
|
||||
*slot += 1;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
||||
match body {
|
||||
OccurrenceBody::Action { action, .. } => {
|
||||
@@ -1041,11 +825,19 @@ impl Graph {
|
||||
.map(|id| format!("ref · {}", p.named(id)))
|
||||
.unwrap_or("Choose source in Properties".into());
|
||||
if let Some(source) = sequence {
|
||||
node.preview = contributions(p, *source)
|
||||
let contributions = contributions(p, *source);
|
||||
node.preview_total = contributions.len();
|
||||
node.preview = contributions
|
||||
.into_iter()
|
||||
.take(4)
|
||||
.take(if expanded.contains(&e.id) {
|
||||
usize::MAX
|
||||
} else {
|
||||
4
|
||||
})
|
||||
.map(|c| (c.id, p.named(c.id).to_owned()))
|
||||
.collect();
|
||||
node.rect.height =
|
||||
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
|
||||
let preview_ids: BTreeSet<_> =
|
||||
node.preview.iter().map(|(id, _)| *id).collect();
|
||||
node.preview_links = sequence_links(p, *source)
|
||||
@@ -1061,11 +853,13 @@ impl Graph {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
*slot += node.rect.height + 36.;
|
||||
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
|
||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||
nodes.push(node);
|
||||
}
|
||||
let mut edges = vec![];
|
||||
let mut routed_pairs = BTreeSet::new();
|
||||
for (i, r) in links.into_iter().enumerate() {
|
||||
let a = nodes
|
||||
.iter()
|
||||
@@ -1079,11 +873,14 @@ impl Graph {
|
||||
.rect;
|
||||
let start = Point::new(a.x + a.width, a.center_y());
|
||||
let end = Point::new(b.x, b.center_y());
|
||||
let points = if end.x > start.x {
|
||||
// Parallel records keep distinct visible paths and guard hit targets.
|
||||
// Their semantic endpoints and IDs remain unchanged.
|
||||
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
|
||||
let points = if forward {
|
||||
let mid = (start.x + end.x) / 2.;
|
||||
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
||||
} else {
|
||||
let below = size.height + 24. + i as f32 * 30.;
|
||||
let below = size.height + 24. + i as f32 * 42.;
|
||||
vec![
|
||||
start,
|
||||
Point::new(start.x + 18., start.y),
|
||||
@@ -1097,7 +894,7 @@ impl Graph {
|
||||
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let label_at = if end.x > start.x {
|
||||
let label_at = if forward {
|
||||
Point::new(end.x - 138., end.y - 43.)
|
||||
} else {
|
||||
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
||||
@@ -1118,6 +915,9 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
size,
|
||||
@@ -1194,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
impl canvas::Program<Message> for Graph {
|
||||
type State = ();
|
||||
impl canvas::Program<Message> for Graph<'_> {
|
||||
type State = Input;
|
||||
fn update(
|
||||
&self,
|
||||
_: &mut (),
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && let Some(point) = cursor.position_in(bounds)
|
||||
{
|
||||
let point = Point::new(point.x / self.scale, point.y / self.scale);
|
||||
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
|
||||
let id = if point.y > n.rect.y + 75. {
|
||||
n.source.unwrap_or(n.id)
|
||||
} else {
|
||||
n.id
|
||||
};
|
||||
return Some(Action::publish(Message::Select(id)).and_capture());
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points
|
||||
.windows(2)
|
||||
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
|
||||
}) {
|
||||
return Some(Action::publish(Message::Select(e.id)).and_capture());
|
||||
}
|
||||
}
|
||||
None
|
||||
self.input(state, event, bounds, cursor)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
state: &Input,
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
@@ -1317,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
"Open source →".into(),
|
||||
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
@@ -1390,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.overlay(state, &mut f, cursor, bounds);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_: &(),
|
||||
_: &Input,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
@@ -1468,6 +1246,9 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool: Tool::Branch,
|
||||
name: String::new(),
|
||||
@@ -1656,7 +1437,7 @@ mod tests {
|
||||
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
||||
session.execute(command).unwrap();
|
||||
let result = session.project();
|
||||
assert!(!result.relations.contains_key(&old_link));
|
||||
assert!(result.relations.contains_key(&old_link));
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
assert_eq!(sequence_links(result, owner).len(), 6);
|
||||
assert_eq!(
|
||||
@@ -1767,14 +1548,34 @@ mod tests {
|
||||
draft: Some(branch(owner, a, b, before, after)),
|
||||
..State::default()
|
||||
};
|
||||
state.update(&p, Event::Guard(String::new())).unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: Id::nil(),
|
||||
label: "Missing".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.update(&p, Event::Submit).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: a,
|
||||
label: "Work".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_none());
|
||||
assert!(state.draft.is_some());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
assert_eq!(p.elements[&owner].steps().unwrap().len(), 2);
|
||||
let result = execute_batch(&p, &[command]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
assert!(state.draft.is_none());
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
}
|
||||
#[test]
|
||||
fn cycle_projection_terminates_and_retains_actual_links() {
|
||||
@@ -1822,9 +1623,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(486., 460.)))
|
||||
.unwrap();
|
||||
state.viewport(Size::new(486., 460.));
|
||||
state.zoom(None);
|
||||
graph.scale = state.scale(graph.size, 500.);
|
||||
assert!(graph.size.width * graph.scale <= 462.1);
|
||||
assert!(graph.size.height * graph.scale <= 450.1);
|
||||
@@ -1835,7 +1635,7 @@ mod tests {
|
||||
);
|
||||
let result = canvas::Program::update(
|
||||
&graph,
|
||||
&mut (),
|
||||
&mut Input::default(),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
||||
mouse::Cursor::Available(click),
|
||||
@@ -1844,12 +1644,10 @@ mod tests {
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
||||
state.update(&p, Event::Zoom(1.)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(800., 460.)))
|
||||
.unwrap();
|
||||
state.zoom(Some(1.));
|
||||
state.viewport(Size::new(800., 460.));
|
||||
assert_eq!(state.scale(graph.size, 500.), 1.);
|
||||
state.update(&p, Event::Fit).unwrap();
|
||||
state.zoom(None);
|
||||
assert!(state.scale(graph.size, 500.) < 1.);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Local pointer state and ordinary command proposals for the derived process graph.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Insertion {
|
||||
pub link: Option<Id>,
|
||||
pub before: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CanvasTool {
|
||||
#[default]
|
||||
Select,
|
||||
Add(Tool, ControlKind),
|
||||
Connect,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum End {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
},
|
||||
Connect {
|
||||
context: Context,
|
||||
source: Id,
|
||||
target: Id,
|
||||
},
|
||||
Reconnect {
|
||||
context: Context,
|
||||
link: Id,
|
||||
end: End,
|
||||
target: Id,
|
||||
},
|
||||
Reorder {
|
||||
context: Context,
|
||||
occurrence: Id,
|
||||
before: Id,
|
||||
},
|
||||
Expand(Context, Id),
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
| Self::Reconnect { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
self.tool = CanvasTool::Select;
|
||||
self.zoom = Some(1.);
|
||||
self.fit = false;
|
||||
self.viewport = None;
|
||||
self.expanded.clear();
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
scenario::ScrollArea::Canvas => 0,
|
||||
scenario::ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||
// Choosing how to edit must retain the user's current view.
|
||||
self.tool = tool;
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||
self.fit = value.is_none();
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
let changed = self.viewport != Some(size);
|
||||
self.viewport = Some(size);
|
||||
changed
|
||||
}
|
||||
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
if !is_contribution(p, owner, id)
|
||||
|| !matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.expanded.remove(&id) {
|
||||
self.expanded.insert(id);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
commands.iter().find_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub(crate) fn begin(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !matches!(
|
||||
tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
|
||||
) {
|
||||
return Err("Choose a contribution tool.".into());
|
||||
}
|
||||
if !legacy_order(p, context.owner).is_empty() {
|
||||
return Err("Convert the ordered path to explicit links before drawing.".into());
|
||||
}
|
||||
let link = insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, context.owner, r.source())
|
||||
&& is_contribution(p, context.owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if insertion
|
||||
.before
|
||||
.is_some_and(|id| !is_contribution(p, context.owner, id))
|
||||
{
|
||||
return Err("Choose a presentation slot in this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(context.owner, tool, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.insertion = insertion;
|
||||
d.control = control;
|
||||
if let Some(r) = link {
|
||||
d.before = Some(r.source());
|
||||
d.after = Some(r.target());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
|
||||
let last = legacy_order(p, owner)
|
||||
.last()
|
||||
.copied()
|
||||
.ok_or("Choose an ordered exchange path to append to.")?;
|
||||
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
|
||||
self.draft.as_mut().expect("started form").fixed = true;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn connect(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !is_contribution(p, owner, source)
|
||||
|| !is_contribution(p, owner, target)
|
||||
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
|
||||
{
|
||||
return Err("Choose compatible contributions directly owned by this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(owner, Tool::Connect, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.before = Some(source);
|
||||
d.after = Some(target);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
|
||||
if !is_contribution(p, owner, occurrence) {
|
||||
return None;
|
||||
}
|
||||
match p.elements[&occurrence].shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { action, .. },
|
||||
..
|
||||
} => *action,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
} => *exchange,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => *sequence,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn reconnect(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
end: End,
|
||||
endpoint: Id,
|
||||
) -> Result<Command, String> {
|
||||
let mut r = p
|
||||
.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, owner, r.source())
|
||||
&& is_contribution(p, owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")?
|
||||
.clone();
|
||||
if !is_contribution(p, owner, endpoint) {
|
||||
return Err("Choose a local contribution endpoint.".into());
|
||||
}
|
||||
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
|
||||
match end {
|
||||
End::Source => *source = endpoint,
|
||||
End::Target => *target = endpoint,
|
||||
}
|
||||
}
|
||||
let command = Command::EditRelation(id, r);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
|
||||
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
|
||||
return Err("Reorder contributions in this process only.".into());
|
||||
}
|
||||
let graph = Graph::new(p, owner, id);
|
||||
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
|
||||
if position(id) != position(before) {
|
||||
return Err("Reorder within this column; links determine the path columns.".into());
|
||||
}
|
||||
let mut steps = p.elements[&owner].steps().unwrap().clone();
|
||||
if id != before {
|
||||
steps.retain(|v| *v != id);
|
||||
let index = steps
|
||||
.iter()
|
||||
.position(|v| *v == before)
|
||||
.ok_or("This slot no longer exists.")?;
|
||||
steps.insert(index, id);
|
||||
}
|
||||
let command = Command::SetSteps(owner, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Input {
|
||||
context: Option<Context>,
|
||||
tool: CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
bounds: Option<Rectangle>,
|
||||
source: Option<Id>,
|
||||
press: Option<Press>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Press {
|
||||
Connect {
|
||||
source: Id,
|
||||
first: bool,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reconnect {
|
||||
link: Id,
|
||||
end: End,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reorder {
|
||||
occurrence: Id,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Add {
|
||||
insertion: Insertion,
|
||||
},
|
||||
}
|
||||
impl Input {
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn node_at(&self, point: Point) -> Option<Id> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points.windows(2).any(|v| {
|
||||
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
|
||||
Size::new(23., 24.),
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
.find(|(p, _)| p.distance(point) < 10. / self.scale)
|
||||
.map(|(_, end)| (e.id, end))
|
||||
}
|
||||
fn connectable(&self, source: Id, target: Id) -> bool {
|
||||
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
|
||||
}
|
||||
pub(super) fn input(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
(Some(c), t, e)
|
||||
});
|
||||
if state.context != context
|
||||
|| state.tool != tool
|
||||
|| state.bounds.is_some_and(|b| b != bounds)
|
||||
{
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
state.context = context;
|
||||
state.tool = tool;
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(
|
||||
Press::Connect { start, moved, .. }
|
||||
| Press::Reconnect { start, moved, .. }
|
||||
| Press::Reorder { start, moved, .. },
|
||||
) = &mut state.press
|
||||
{
|
||||
*moved |= position.distance(*start) >= 3.;
|
||||
}
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(
|
||||
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
|
||||
) => {
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) => {
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
let point = world?;
|
||||
let Some(context) = context else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture());
|
||||
};
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let start = cursor.position()? - self.scroll_offset;
|
||||
match tool {
|
||||
CanvasTool::Add(..) => {
|
||||
state.press = Some(Press::Add {
|
||||
insertion: Insertion {
|
||||
link: self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none()),
|
||||
before: self.node_at(point),
|
||||
},
|
||||
});
|
||||
}
|
||||
CanvasTool::Connect => {
|
||||
let first = state.source.is_none();
|
||||
let source = state.source.or_else(|| self.node_at(point))?;
|
||||
state.source = Some(source);
|
||||
state.press = Some(Press::Connect {
|
||||
source,
|
||||
first,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
{
|
||||
state.press = Some(Press::Reorder {
|
||||
occurrence: self.selected,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.and_then(|id| publish(CanvasEvent::Select(context, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
Press::Add { insertion } if world.is_some() => {
|
||||
if let CanvasTool::Add(tool, control) = tool {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Begin {
|
||||
context,
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Connect {
|
||||
source,
|
||||
first,
|
||||
moved,
|
||||
..
|
||||
} if moved || !first => {
|
||||
if let Some(target) = world
|
||||
.and_then(|p| self.node_at(p))
|
||||
.filter(|target| self.connectable(source, *target))
|
||||
{
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Connect {
|
||||
context,
|
||||
source,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(target) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reconnect {
|
||||
context,
|
||||
link,
|
||||
end,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reorder {
|
||||
occurrence,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(before) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence,
|
||||
before,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn overlay(
|
||||
&self,
|
||||
state: &Input,
|
||||
f: &mut Frame,
|
||||
cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
if !editable {
|
||||
return;
|
||||
}
|
||||
let point = state
|
||||
.pointer
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
for n in &self.nodes {
|
||||
if n.id == source || self.connectable(source, n.id) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
|
||||
draw_arrow(
|
||||
f,
|
||||
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
|
||||
style::TEAL,
|
||||
2.,
|
||||
);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reconnect { link, end, .. }) = state.press
|
||||
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
|
||||
{
|
||||
for node in &self.nodes {
|
||||
let (source, target) = match end {
|
||||
End::Source => (node.id, edge.target),
|
||||
End::Target => (edge.source, node.id),
|
||||
};
|
||||
if self.connectable(source, target) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(point) = point {
|
||||
let (from, to) = match end {
|
||||
End::Source => (point, *edge.points.last().unwrap()),
|
||||
End::Target => (edge.points[0], point),
|
||||
};
|
||||
draw_arrow(f, &[from, to], style::TEAL, 2.5);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reorder { occurrence, .. }) = state.press
|
||||
&& let Some(point) = point
|
||||
&& let Some(target) = self.node_at(point)
|
||||
{
|
||||
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
|
||||
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
|
||||
if source.rect.x == target.rect.x {
|
||||
f.stroke(
|
||||
&Path::line(
|
||||
Point::new(target.rect.x, target.rect.y - 7.),
|
||||
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
|
||||
),
|
||||
Stroke::default().with_color(style::TEAL).with_width(3.),
|
||||
);
|
||||
draw_text(
|
||||
f,
|
||||
"Order before · paths unchanged".into(),
|
||||
Point::new(target.rect.x, target.rect.y - 24.),
|
||||
12.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(r) = self.handle(self.selected) {
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
|
||||
for p in [e.points[0], *e.points.last().unwrap()] {
|
||||
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let CanvasTool::Add(..) = tool
|
||||
&& let Some(point) = point
|
||||
{
|
||||
let at = self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none());
|
||||
let caption = if let Some(id) = at {
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
|
||||
draw_arrow(f, &e.points, style::TEAL, 3.);
|
||||
}
|
||||
"Insert on this path · preserve its guard"
|
||||
} else if self.node_at(point).is_some() {
|
||||
"Add before this contribution · unconnected"
|
||||
} else {
|
||||
"Append contribution · unconnected"
|
||||
};
|
||||
draw_text(
|
||||
f,
|
||||
caption.into(),
|
||||
Point::new(point.x + 12., point.y + 16.),
|
||||
12. / self.scale,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
use super::*;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::{Rectangle, Vector};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
scrolls: Vec<(Option<widget::Id>, Rectangle, Vector)>,
|
||||
actions: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
operate(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("test-process-actions")) {
|
||||
self.actions = Some(bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
self.scrolls.push((id.cloned(), bounds, translation));
|
||||
}
|
||||
}
|
||||
fn workspace(pending: bool) -> iced::Element<'static, Message, iced::Theme, ()> {
|
||||
let diagram = scrollable(iced::widget::Space::new().width(500).height(1200))
|
||||
.id("test-process-page")
|
||||
.height(Fill)
|
||||
.into();
|
||||
let form = if pending {
|
||||
editor_panel(
|
||||
container(iced::widget::Space::new().height(32))
|
||||
.id("test-process-actions")
|
||||
.into(),
|
||||
iced::widget::Space::new().height(1400).into(),
|
||||
)
|
||||
} else {
|
||||
iced::widget::Space::new().height(0).into()
|
||||
};
|
||||
panes(diagram, form, pending)
|
||||
}
|
||||
fn geometry(
|
||||
view: &mut iced::Element<'_, Message, iced::Theme, ()>,
|
||||
tree: &mut widget::Tree,
|
||||
size: Size,
|
||||
) -> (layout::Node, Geometry) {
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, size));
|
||||
let mut geometry = Geometry::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut geometry);
|
||||
(node, geometry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible() {
|
||||
// Actual available center-pane sizes at the minimum/default supported windows.
|
||||
// Space represents arbitrarily long diagram/form content; the production
|
||||
// pane/card builders and real Iced layout/scroll operations are exercised.
|
||||
for size in [Size::new(500., 570.), Size::new(840., 770.)] {
|
||||
let mut view = workspace(false);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, _) = geometry(&mut view, &mut tree, size);
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("test-process-page"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
view = workspace(true);
|
||||
tree.diff(view.as_widget());
|
||||
let (node, before) = geometry(&mut view, &mut tree, size);
|
||||
let page = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("test-process-page")))
|
||||
.unwrap();
|
||||
let form = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-form")))
|
||||
.unwrap();
|
||||
assert_eq!(node.size(), size);
|
||||
assert!(
|
||||
form.1.height >= 200.,
|
||||
"usable form viewport: {size:?} {:?}",
|
||||
form.1
|
||||
);
|
||||
assert!(form.1.y >= page.1.y + page.1.height);
|
||||
assert!(form.1.y + form.1.height <= size.height);
|
||||
assert_eq!(page.2.y, 180., "opening a form retains the page scroll");
|
||||
let actions = before.actions.unwrap();
|
||||
assert!(actions.y >= page.1.y + page.1.height);
|
||||
assert!(actions.y + actions.height <= form.1.y);
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-form"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(600.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
let (_, after) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(
|
||||
after.actions,
|
||||
Some(actions),
|
||||
"Create/Cancel stay pinned during form scrolling"
|
||||
);
|
||||
assert_eq!(
|
||||
after.scrolls[0].2.y, 180.,
|
||||
"form scrolling does not move the diagram page"
|
||||
);
|
||||
assert!(
|
||||
after
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.)
|
||||
);
|
||||
view = workspace(false);
|
||||
tree.diff(view.as_widget());
|
||||
let (_, closed) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(
|
||||
closed.scrolls[0].2.y, 180.,
|
||||
"cancel retains the page scroll"
|
||||
);
|
||||
assert_eq!(closed.scrolls[0].1.height, size.height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
pub(super) fn picker<'a>(
|
||||
mut choices: Vec<Choice>,
|
||||
d: &'a Draft,
|
||||
index: usize,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
let browser = &d.browsers[index];
|
||||
let query = browser.search.trim().to_lowercase();
|
||||
choices.retain(|c| c.label.to_lowercase().contains(&query));
|
||||
let range = choice_page(choices.len(), browser.page);
|
||||
let page = range.start / 24;
|
||||
let mut out = column![
|
||||
text_input("Search compatible choices…", &browser.search)
|
||||
.on_input(move |v| event(Event::Search(index, v)))
|
||||
.padding(7)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(selected) = selected
|
||||
.as_ref()
|
||||
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
|
||||
{
|
||||
out = out.push(label(format!("Selected: {}", selected.label)));
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
button("Previous")
|
||||
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
|
||||
text(format!(
|
||||
"{} choices · {}/{}",
|
||||
choices.len(),
|
||||
page + 1,
|
||||
choices.len().div_ceil(24).max(1)
|
||||
))
|
||||
.size(11),
|
||||
button("Next").on_press_maybe(
|
||||
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
out.push(
|
||||
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
|
||||
let start = requested.min(len.saturating_sub(1) / 24) * 24;
|
||||
start..(start + 24).min(len)
|
||||
}
|
||||
pub(super) fn view<'a>(
|
||||
app: &'a App,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let context = app.topology_context().expect("visible process context");
|
||||
let state = &app.behavior.topology;
|
||||
let pending = state.draft.is_some();
|
||||
let explicit =
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
// Keep the canvas at the same sibling position when its palette becomes a
|
||||
// pending-form notice, so Iced retains the scrollable's local offsets.
|
||||
let mut controls = Column::new().spacing(14);
|
||||
if !pending && explicit {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.style(if state.tool == CanvasTool::Select {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Select)),
|
||||
button("Work")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||||
button("Exchange")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||||
button("Reuse")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
pick_list(
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop
|
||||
]
|
||||
.into_iter()
|
||||
.map(ControlChoice)
|
||||
.collect::<Vec<_>>(),
|
||||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||||
Some(ControlChoice(kind))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Add(Tool::Control, c.0)
|
||||
))
|
||||
)
|
||||
.placeholder("Draw control…")
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
button("Sequence link")
|
||||
.style(if state.tool == CanvasTool::Connect {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Connect)),
|
||||
button("Two paths…").on_press(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
out = out.push(controls);
|
||||
if explicit {
|
||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
graph.scroll_offset = state.scroll_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
row![
|
||||
button("Fit overview")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
let canvas = scrollable(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 510.).clamp(160., 240.)
|
||||
} else {
|
||||
(app.window_size.height - 420.).clamp(190., 420.)
|
||||
});
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
} else {
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
if !pending {
|
||||
out = out.push(
|
||||
button("Append exchange…")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
|
||||
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
|
||||
}
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
out = out.push(
|
||||
button("Convert ordered path to explicit links")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
} else {
|
||||
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
|
||||
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
|
||||
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
|
||||
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
|
||||
}
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
out = out.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
out = out.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
),
|
||||
form_panel,
|
||||
pending,
|
||||
)
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
/// workspace height. The editor must never be clipped by the page scrollable.
|
||||
fn panes<'a, R: iced::advanced::Renderer + 'a>(
|
||||
diagram: iced::Element<'a, Message, iced::Theme, R>,
|
||||
form: iced::Element<'a, Message, iced::Theme, R>,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
column![
|
||||
container(diagram).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
Fill
|
||||
}),
|
||||
container(form).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
iced::Length::Shrink
|
||||
}),
|
||||
]
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||
fields: iced::Element<'a, Message, iced::Theme, R>,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
container(
|
||||
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(iced::Padding {
|
||||
top: 0.,
|
||||
right: 18.,
|
||||
bottom: 18.,
|
||||
left: 18.,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
#[path = "layout_tests.rs"]
|
||||
mod layout_tests;
|
||||
@@ -0,0 +1,142 @@
|
||||
//! A palette operation is transient; only a completed connection enters history.
|
||||
use crate::{connection_tool::Tool, route_edit::Context};
|
||||
use oa_core::{Id, Project, projection::ProjectedView};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Choose(Option<Tool>),
|
||||
Begin(Tool, Id),
|
||||
Endpoint(Context, Id),
|
||||
Restart(Context),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub context: Context,
|
||||
pub tool: Tool,
|
||||
pub source: Option<Id>,
|
||||
pub candidates: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(project: &Project, view: &ProjectedView, context: Context, tool: Tool) -> Self {
|
||||
let candidates = visible(view)
|
||||
.filter(|id| tool.can_start(project, *id))
|
||||
.collect();
|
||||
Self {
|
||||
context,
|
||||
tool,
|
||||
source: None,
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, project: &Project, view: &ProjectedView, source: Id) {
|
||||
self.source = Some(source);
|
||||
let compatible = self.tool.targets(project, source);
|
||||
self.candidates = visible(view).filter(|id| compatible.contains(id)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
view.nodes
|
||||
.iter()
|
||||
.map(|n| n.element)
|
||||
.chain(view.ports.iter().map(|p| p.element))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{App, Message, Page};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Send command", 0., 0.);
|
||||
let b = p.add(Kind::Function, "Receive command", 400., 0.);
|
||||
let hidden = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Hidden work", None)
|
||||
.unwrap();
|
||||
(App::with_project(p), a, b, hidden)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Connection(event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_endpoint_drawing_commits_ports_and_exchange_once_with_exact_history() {
|
||||
let (mut app, a, b, hidden) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
assert!(
|
||||
!app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&hidden)
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, a));
|
||||
send(&mut app, Event::Endpoint(context, hidden));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let relation = &app.project().relations[&app.selected];
|
||||
assert_eq!(relation.kind(), RelationKind::FunctionalExchange);
|
||||
assert_eq!((relation.source(), relation.target()), (a, b));
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_navigation_revision_and_dirty_drafts_cannot_commit_a_stale_connection() {
|
||||
let (mut app, a, b, _) = fixture();
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, a));
|
||||
let _ = app.update(Message::Escape);
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.project().relations.is_empty());
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
let current = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(current, a));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert_eq!(
|
||||
app.connection.as_ref().unwrap().source,
|
||||
Some(a),
|
||||
"A stale click must leave the newer drawing session intact"
|
||||
);
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Route(crate::route_edit::Event::AddBend));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.connection.is_some());
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert!(app.connection.is_none());
|
||||
let _ = app.update(Message::Page(Page::Diagram));
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.project().relations.is_empty());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending rename".into()));
|
||||
assert!(app.draft_dirty());
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.project().named(a), "Send command");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
//! Semantic endpoints for direct canvas connections. Gesture state and visible
|
||||
//! hit targets belong to the canvas; ordinary core commands own model changes.
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Interaction,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Interaction",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::FunctionalExchange => {
|
||||
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
"Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::Interaction => project
|
||||
.elements
|
||||
.get(&source)
|
||||
.is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity),
|
||||
Self::FunctionalExchange => function_endpoint(project, source, true).is_some(),
|
||||
Self::BoundaryExchange => component_endpoint(project, source)
|
||||
.is_some_and(|contract| component_allows(contract, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap feasibility for highlighting candidates. The caller intersects this
|
||||
/// with the actual projected nodes and ports; hidden objects are not hits.
|
||||
pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool {
|
||||
if !self.can_start(project, source) {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::Interaction => {
|
||||
validation::can_connect(project, RelationKind::Interaction, source, target)
|
||||
}
|
||||
Self::FunctionalExchange => {
|
||||
let (Some((source, _)), Some((target, _))) = (
|
||||
function_endpoint(project, source, true),
|
||||
function_endpoint(project, target, false),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
validation::can_connect(project, RelationKind::FunctionalExchange, source, target)
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
component_endpoint(project, target).is_some()
|
||||
&& validation::can_connect_boundary(
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
boundary_oriented(project, source),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn targets(self, project: &Project, source: Id) -> BTreeSet<Id> {
|
||||
if !self.can_start(project, source) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
project
|
||||
.elements
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|target| self.can_connect(project, source, *target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return one validated history operation without modifying the input.
|
||||
/// FE endpoint generation uses the same atomic planner as the form workflow.
|
||||
/// No carried items are inferred from a port's allowed item declarations.
|
||||
pub fn command(self, project: &Project, source: Id, target: Id) -> Result<Command, ModelError> {
|
||||
if !self.can_connect(project, source, target) {
|
||||
return Err(ModelError(format!(
|
||||
"Choose compatible endpoints for {}. {}",
|
||||
self.label(),
|
||||
self.hint()
|
||||
)));
|
||||
}
|
||||
let id = Id::new_v4();
|
||||
if self == Self::FunctionalExchange {
|
||||
let (source, source_port) = function_endpoint(project, source, true)
|
||||
.expect("Functional source was checked above");
|
||||
let (target, target_port) = function_endpoint(project, target, false)
|
||||
.expect("Functional target was checked above");
|
||||
let name = engineering_name(project, self, source, target);
|
||||
return plan_functional_exchange(
|
||||
project,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "exchange", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items: Vec::new(),
|
||||
},
|
||||
)
|
||||
.map(Command::Batch);
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
|
||||
Self::BoundaryExchange => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: boundary_oriented(project, source),
|
||||
items: Vec::new(),
|
||||
},
|
||||
Self::FunctionalExchange => unreachable!("Functional planner returned above"),
|
||||
};
|
||||
let command = Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
});
|
||||
prepare_command(project, &command)?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clicked port determines its own function owner. It cannot be paired with
|
||||
/// a different remembered owner or silently substituted by automatic selection.
|
||||
fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> {
|
||||
let element = project.elements.get(&id)?;
|
||||
if element.layer() != Layer::Sa {
|
||||
return None;
|
||||
}
|
||||
if element.kind() == Kind::Function {
|
||||
return Some((id, PortChoice::Auto));
|
||||
}
|
||||
match (port_contract(element)?, outgoing) {
|
||||
(PortContract::FunctionOutput { .. }, true)
|
||||
| (PortContract::FunctionInput { .. }, false) => {}
|
||||
_ => return None,
|
||||
}
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
(owner.layer() == Layer::Sa && owner.kind() == Kind::Function)
|
||||
.then_some((owner.id, PortChoice::Reuse(id)))
|
||||
}
|
||||
|
||||
fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> {
|
||||
let element = project.elements.get(&id)?;
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() {
|
||||
return None;
|
||||
}
|
||||
let contract = port_contract(element)?;
|
||||
match contract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
}
|
||||
| PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
} => Some(contract),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary_oriented(project: &Project, source: Id) -> bool {
|
||||
matches!(
|
||||
component_endpoint(project, source),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String {
|
||||
let endpoint = |id| {
|
||||
if tool == Tool::BoundaryExchange
|
||||
&& let Some(owner) = project.elements.get(&id).and_then(|e| e.parent)
|
||||
{
|
||||
format!("{} / {}", project.named(owner), project.named(id))
|
||||
} else {
|
||||
project.named(id).to_owned()
|
||||
}
|
||||
};
|
||||
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
|
||||
"—"
|
||||
} else {
|
||||
"→"
|
||||
};
|
||||
format!(
|
||||
"{}: {} {arrow} {}",
|
||||
tool.label(),
|
||||
endpoint(source),
|
||||
endpoint(target)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
|
||||
|
||||
fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
|
||||
fn flow(orientation: ComponentPortOrientation) -> PortContract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> PortContract {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn output() -> PortContract {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn created(project: &Project, command: &Command) -> (Project, Relation) {
|
||||
let prepared = prepare_command(project, command).unwrap();
|
||||
let relation = prepared.project.relations[&prepared.selected.unwrap()].clone();
|
||||
(prepared.project, relation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_interaction_targets_only_distinct_oa_activities() {
|
||||
let mut p = Project::empty();
|
||||
let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report");
|
||||
let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report");
|
||||
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::Interaction.targets(&p, source),
|
||||
BTreeSet::from([target])
|
||||
);
|
||||
for invalid in [source, actor, function, Id::new_v4()] {
|
||||
assert!(Tool::Interaction.command(&p, source, invalid).is_err());
|
||||
}
|
||||
let command = Tool::Interaction.command(&p, source, target).unwrap();
|
||||
assert!(matches!(command, Command::CreateRelation(_)));
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert_eq!(relation.body.endpoints(), (source, target));
|
||||
assert_eq!(relation.name, "Interaction: Send report → Assess report");
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let source = port(
|
||||
&mut p,
|
||||
a,
|
||||
"Reports out",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![report],
|
||||
},
|
||||
);
|
||||
let target = port(
|
||||
&mut p,
|
||||
b,
|
||||
"Results in",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![result],
|
||||
},
|
||||
);
|
||||
let other = port(&mut p, a, "Other output", output());
|
||||
let before = p.clone();
|
||||
let command = Tool::FunctionalExchange
|
||||
.command(&p, source, target)
|
||||
.unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1));
|
||||
let (accepted, relation) = created(&p, &command);
|
||||
assert!(matches!(relation.body, RelationBody::FunctionalExchange {
|
||||
source: s, target: t, source_port: sp, target_port: tp, ref items,
|
||||
} if (s,t,sp,tp) == (a,b,source,target) && items.is_empty()));
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
assert_eq!(relation.name, "Functional exchange: Detect → Assess");
|
||||
for (s, t) in [
|
||||
(target, source),
|
||||
(source, other),
|
||||
(source, a),
|
||||
(source, source),
|
||||
] {
|
||||
assert!(!Tool::FunctionalExchange.can_connect(&p, s, t));
|
||||
assert!(Tool::FunctionalExchange.command(&p, s, t).is_err());
|
||||
}
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let out = port(&mut p, a, "Report out", output());
|
||||
let input = port(&mut p, b, "Report in", input());
|
||||
let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap());
|
||||
assert!(
|
||||
matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. }
|
||||
if (source_port,target_port) == (out,input))
|
||||
);
|
||||
let second = port(&mut p, a, "Other report out", output());
|
||||
let command = Tool::FunctionalExchange.command(&p, a, input).unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2));
|
||||
let (accepted, generated) = created(&p, &command);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = generated.body
|
||||
else {
|
||||
panic!("Expected functional exchange")
|
||||
};
|
||||
assert_eq!(target_port, input);
|
||||
assert!(![out, second].contains(&source_port));
|
||||
assert_eq!(accepted.elements[&source_port].parent, Some(a));
|
||||
assert_eq!(accepted.elements.len(), p.elements.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "Response system");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher");
|
||||
let mut endpoints = Vec::new();
|
||||
for owner in [a, b] {
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::InOut,
|
||||
ComponentPortOrientation::Unset,
|
||||
] {
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
&format!("Flow {orientation:?}"),
|
||||
flow(orientation),
|
||||
));
|
||||
}
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
"Control",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
));
|
||||
}
|
||||
for source in &endpoints {
|
||||
let oriented = boundary_oriented(&p, *source);
|
||||
let targets = Tool::BoundaryExchange.targets(&p, *source);
|
||||
for target in &endpoints {
|
||||
let expected = validation::can_connect_boundary(&p, *source, *target, oriented);
|
||||
assert_eq!(targets.contains(target), expected, "{source} → {target}");
|
||||
let command = Tool::BoundaryExchange.command(&p, *source, *target);
|
||||
assert_eq!(command.is_ok(), expected);
|
||||
if let Ok(command) = command {
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert!(
|
||||
matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented)
|
||||
);
|
||||
assert!(relation.name.contains(" / "));
|
||||
assert_eq!(relation.name.contains('→'), oriented);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let standard = PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
let source = port(&mut p, a, "Service", standard.clone());
|
||||
let target = port(&mut p, b, "Required service", standard);
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
|
||||
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
|
||||
for (interface, item) in [(provided, report), (required, result)] {
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides reporting",
|
||||
source,
|
||||
provided,
|
||||
);
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires results",
|
||||
target,
|
||||
required,
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let before = p.clone();
|
||||
assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(validation::can_connect_boundary(&p, source, target, false));
|
||||
let (accepted, relation) = created(
|
||||
&p,
|
||||
&Tool::BoundaryExchange.command(&p, source, target).unwrap(),
|
||||
);
|
||||
let warnings: Vec<_> = validation::validate(&accepted)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.code == "unsatisfied_interface_requirement")
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0].severity, validation::Severity::Warning);
|
||||
assert_eq!(warnings[0].element, Some(requirement));
|
||||
assert_eq!(warnings[0].field.as_deref(), Some("target"));
|
||||
|
||||
let mut carrying = relation.clone();
|
||||
let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else {
|
||||
unreachable!()
|
||||
};
|
||||
items.push(result);
|
||||
assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err());
|
||||
assert_eq!(p, before, "Rejected carried items never mutate the input");
|
||||
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides results",
|
||||
source,
|
||||
required,
|
||||
);
|
||||
let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap();
|
||||
assert!(
|
||||
validation::validate(&prepared.project)
|
||||
.iter()
|
||||
.all(|issue| issue.code != "unsatisfied_interface_requirement")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() {
|
||||
let mut p = Project::empty();
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Function");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity");
|
||||
let function_on_system = port(&mut p, system, "Wrong function owner", output());
|
||||
let component_on_function = port(
|
||||
&mut p,
|
||||
function,
|
||||
"Wrong component owner",
|
||||
flow(ComponentPortOrientation::Out),
|
||||
);
|
||||
let oa_function_port = port(&mut p, activity, "Wrong layer", output());
|
||||
let malformed_standard = port(
|
||||
&mut p,
|
||||
system,
|
||||
"Wrong Standard orientation",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
);
|
||||
for source in [
|
||||
function_on_system,
|
||||
component_on_function,
|
||||
oa_function_port,
|
||||
malformed_standard,
|
||||
] {
|
||||
for tool in Tool::for_layer(Layer::Sa) {
|
||||
assert!(!tool.can_start(&p, source));
|
||||
assert!(tool.targets(&p, source).is_empty());
|
||||
assert!(tool.command(&p, source, function).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_connections_share_exact_source_history_and_model_parity() {
|
||||
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 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");
|
||||
let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor");
|
||||
let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out));
|
||||
let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In));
|
||||
let mut bundle = format_bundle(&p);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Preserve engineering rationale exactly.\n");
|
||||
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
for (tool, source, target) in [
|
||||
(Tool::Interaction, oa_a, oa_b),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
] {
|
||||
let before = document.bundle().clone();
|
||||
let original = document.project().clone();
|
||||
let revision = document.revision();
|
||||
let command = tool.command(document.project(), source, target).unwrap();
|
||||
let expected = prepare_command(document.project(), &command).unwrap();
|
||||
let selected = document.execute(command).unwrap();
|
||||
assert_eq!(selected, expected.selected);
|
||||
assert_eq!(document.project(), &expected.project);
|
||||
assert_eq!(document.revision(), revision + 1);
|
||||
assert_eq!(
|
||||
compile_bundle(document.bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected.project
|
||||
);
|
||||
let changed = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert_eq!(document.project(), &original);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out));
|
||||
let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In));
|
||||
assert!(Tool::BoundaryExchange.can_connect(&p, source, target));
|
||||
// An indexed key alone is not proof of a live model identity. Preserve
|
||||
// the reference check even when inspecting an inconsistent input model.
|
||||
p.elements.get_mut(&target).unwrap().id = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert!(!validation::can_connect_boundary(&p, source, target, true));
|
||||
assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(Tool::BoundaryExchange.command(&p, source, target).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_command_validation_rejects_invalid_models_without_mutation() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Activity, Layer::Oa, "Send");
|
||||
let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive");
|
||||
p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone();
|
||||
let before = p.clone();
|
||||
assert!(
|
||||
Tool::Interaction.can_connect(&p, a, b),
|
||||
"Candidate highlighting checks the proposed link; commit checks the whole model"
|
||||
);
|
||||
assert!(Tool::Interaction.command(&p, a, b).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
//! One address for a carried value, shared by scenario creation and property editing.
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FieldPathKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldPathChoice {
|
||||
pub key: FieldPathKey,
|
||||
pub label: String,
|
||||
pub picker_label: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldPathChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.picker_label)
|
||||
}
|
||||
}
|
||||
|
||||
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
|
||||
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|
||||
|| match phase {
|
||||
MessageKind::Call => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
|
||||
let qualified = entry
|
||||
.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&entry.field))
|
||||
.map(|id| {
|
||||
project
|
||||
.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
ParameterDirection::Unspecified => " · direction unspecified — review",
|
||||
ParameterDirection::In => " · In",
|
||||
ParameterDirection::Out => " · Out",
|
||||
ParameterDirection::InOut => " · InOut",
|
||||
ParameterDirection::Return => " · Return",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let terminal = qualified
|
||||
.last()
|
||||
.expect("A field path always has a terminal");
|
||||
let owner_path = qualified[..qualified.len() - 1].join(" → ");
|
||||
let picker_label = format!(
|
||||
"{terminal}{direction} — {}{}",
|
||||
project.named(entry.item),
|
||||
if owner_path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" / {owner_path}")
|
||||
},
|
||||
);
|
||||
FieldPathChoice {
|
||||
key: FieldPathKey {
|
||||
path: entry.path,
|
||||
field: entry.field,
|
||||
},
|
||||
label: format!(
|
||||
"{} / {}{direction}",
|
||||
project.named(entry.item),
|
||||
qualified.join(" → ")
|
||||
),
|
||||
picker_label,
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded picker must expose its distinguishing field before a long path.
|
||||
// Keep the full address separately for the wrapping detail text.
|
||||
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
|
||||
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn choices(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
Ok(sorted_choices(
|
||||
data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
sorted_choices(
|
||||
exchange
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
data::effective_fields(project, *item)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
let SharedBody::DataField { direction, .. } = field.shared()? else {
|
||||
return None;
|
||||
};
|
||||
Some(data::CarriedFieldPath {
|
||||
item: *item,
|
||||
path: vec![],
|
||||
field: field.id,
|
||||
direction: *direction,
|
||||
})
|
||||
})
|
||||
})
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Browse one record at a time, including explicitly chosen recursive hops.
|
||||
pub fn at_scope(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
scope: Option<&FieldPathKey>,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
match scope {
|
||||
Some(key) => children(project, exchange, phase, key),
|
||||
None => Ok(roots(project, exchange, phase)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||
/// In particular, optional recursive Value records may be traversed repeatedly.
|
||||
pub fn resolve(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Option<FieldPathChoice> {
|
||||
data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
}
|
||||
|
||||
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
|
||||
let (&field, path) = key.path.split_last()?;
|
||||
Some(FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand exactly one selected record. This keeps recursive authoring explicit
|
||||
/// and finite, and never traverses Reference storage or manufactures value paths.
|
||||
pub fn children(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
storage: FieldStorage::Value,
|
||||
..
|
||||
}) = project.elements.get(&key.field).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if !data::is_record(project, *record) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
|
||||
return Err(format!(
|
||||
"A value path can contain at most {} enclosing fields.",
|
||||
data::MAX_VALUE_PATH_DEPTH
|
||||
));
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
Ok(sorted_choices(
|
||||
data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
named_choice(
|
||||
project,
|
||||
data::CarriedFieldPath {
|
||||
item: entry.item,
|
||||
path: path.clone(),
|
||||
field: field.id,
|
||||
direction: entry.direction,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub struct Payload {
|
||||
pub project: Project,
|
||||
pub exchange: Id,
|
||||
pub item: Id,
|
||||
pub leaf: Id,
|
||||
pub origin: Id,
|
||||
pub destination: Id,
|
||||
pub scenario: Id,
|
||||
}
|
||||
pub fn payload() -> Payload {
|
||||
let mut p = Project::empty();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
supertype: None,
|
||||
};
|
||||
let record = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
|
||||
.unwrap();
|
||||
let origin = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
|
||||
.unwrap();
|
||||
let destination = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref, direction) in [
|
||||
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
|
||||
(origin, TypeRef::Named(record), ParameterDirection::In),
|
||||
(destination, TypeRef::Named(record), ParameterDirection::Out),
|
||||
] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
direction,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
|
||||
source,
|
||||
target,
|
||||
items: vec![item],
|
||||
};
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
|
||||
.unwrap();
|
||||
validate_candidate(&p).unwrap();
|
||||
Payload {
|
||||
project: p,
|
||||
exchange,
|
||||
item,
|
||||
leaf,
|
||||
origin,
|
||||
destination,
|
||||
scenario,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_choices_keep_reused_record_addresses_distinct() {
|
||||
let mut f = payload();
|
||||
assert_eq!(
|
||||
data::item_mechanism(&f.project, f.item),
|
||||
Some(ExchangeMechanism::Operation)
|
||||
);
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
call.iter()
|
||||
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
|
||||
let reply = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Reply,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
reply
|
||||
.iter()
|
||||
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
|
||||
if let Some(SharedBody::DataField { direction, .. }) = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.destination)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
{
|
||||
*direction = ParameterDirection::InOut;
|
||||
}
|
||||
let both = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let leaves = both
|
||||
.iter()
|
||||
.filter(|c| c.key.field == f.leaf)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(leaves.len(), 2);
|
||||
assert_ne!(leaves[0].key, leaves[1].key);
|
||||
assert_ne!(leaves[0].label, leaves[1].label);
|
||||
let values = leaves
|
||||
.into_iter()
|
||||
.map(|c| (c.key.clone(), "1".to_string()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
|
||||
let mut f = payload();
|
||||
if let Some(SharedBody::DataField {
|
||||
storage, direction, ..
|
||||
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
|
||||
{
|
||||
*storage = FieldStorage::Reference;
|
||||
*direction = ParameterDirection::Unspecified;
|
||||
}
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
|
||||
assert!(direct.key.path.is_empty());
|
||||
assert!(direct.label.contains("unspecified"));
|
||||
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
|
||||
}
|
||||
pub fn recursive_payload() -> (Payload, Id) {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let next = f
|
||||
.project
|
||||
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&next)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, next)
|
||||
}
|
||||
pub fn wide_payload() -> Payload {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] {
|
||||
for index in 0..count {
|
||||
let body = f.project.elements[&template].shared().unwrap().clone();
|
||||
let id = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
&format!("Field {template} {index}"),
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = body;
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
#[test]
|
||||
fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() {
|
||||
let mut f = wide_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let direct = roots(&f.project, exchange, &MessageKind::Call);
|
||||
assert_eq!(direct.len(), 96);
|
||||
assert!(direct.iter().all(|choice| choice.key.path.is_empty()));
|
||||
let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap();
|
||||
assert_eq!(representative.len(), 96 * 129);
|
||||
for root in &direct {
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &root.key),
|
||||
Some(root.clone())
|
||||
);
|
||||
}
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap();
|
||||
assert_eq!(nested.len(), 128);
|
||||
for child in &nested {
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &child.key),
|
||||
Some(child.clone())
|
||||
);
|
||||
assert!(child.label.ends_with(" · In"));
|
||||
}
|
||||
// A temporarily over-limit model still exposes its unrelated root fields;
|
||||
// the editor must not expand the whole graph before showing a choice.
|
||||
for index in 0..32 {
|
||||
let body = f.project.elements[&f.origin].shared().unwrap().clone();
|
||||
let id = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
&format!("Extra {index}"),
|
||||
Some(f.item),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = body;
|
||||
}
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
assert!(choices(&f.project, exchange, &MessageKind::Call).is_err());
|
||||
assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128);
|
||||
assert_eq!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &direct[0].key)
|
||||
.unwrap()
|
||||
.len(),
|
||||
128
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let record = FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
};
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
|
||||
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
|
||||
assert_eq!(leaf.key.path, vec![f.origin, next]);
|
||||
assert!(
|
||||
!choices(&f.project, exchange, &MessageKind::Call)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.key == leaf.key)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
assert_eq!(parent(&leaf.key), Some(record));
|
||||
let deepest = FieldPathKey {
|
||||
path: std::iter::once(f.origin)
|
||||
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
|
||||
.collect(),
|
||||
field: next,
|
||||
};
|
||||
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
|
||||
assert!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &deepest)
|
||||
.unwrap_err()
|
||||
.contains("128")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let context = FieldPathKey {
|
||||
path: vec![f.origin, next, next],
|
||||
field: next,
|
||||
};
|
||||
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
|
||||
let leaf = visible
|
||||
.iter()
|
||||
.find(|choice| choice.key.field == f.leaf)
|
||||
.unwrap();
|
||||
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
|
||||
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
|
||||
assert!(
|
||||
visible.windows(2).all(
|
||||
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
visible[0].to_string().chars().take(24).collect::<String>(),
|
||||
visible[1].to_string().chars().take(24).collect::<String>()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
context_menu::Entry,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, keyboard, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
(app, output, input)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn screen(app: &App, id: Id) -> Point {
|
||||
let v = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let p = v.ports.iter().find(|p| p.element == id).unwrap().position;
|
||||
Point::new(bounds().x + app.pan.x + p.x, bounds().y + app.pan.y + p.y)
|
||||
}
|
||||
fn event(c: &DiagramCanvas<'_>, s: &mut State, e: mouse::Event, p: Point) -> Option<Message> {
|
||||
c.update(
|
||||
s,
|
||||
&canvas::Event::Mouse(e),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(p),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn choose(app: &App, caption: &str) -> Message {
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("No action {caption}"))
|
||||
}
|
||||
fn send(app: &mut App, m: Message) {
|
||||
let _ = app.update(m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_and_arrow_panning_preserve_source_and_history() {
|
||||
for armed in [false, true] {
|
||||
let (mut app, a, _) = fixture();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))),
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
if armed {
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Endpoint(context, a)),
|
||||
);
|
||||
}
|
||||
let before = app.document.bundle().clone();
|
||||
let pan = app.pan;
|
||||
let mut state = State::default();
|
||||
let start = screen(&app, a);
|
||||
let end = start + Vector::new(140., 70.);
|
||||
let c = canvas(&app);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert_eq!(app.pan, pan + Vector::new(140., 70.));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, armed.then_some(a));
|
||||
let e = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowRight),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let message = canvas(&app)
|
||||
.update(&mut state, &e, bounds(), mouse::Cursor::Available(end))
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.pan, pan + Vector::new(60., 70.));
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, context);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_reset_and_draw_use_fresh_open_context() {
|
||||
let (mut app, a, b) = fixture();
|
||||
let c = app.route_context().unwrap();
|
||||
let v = projection::project_view(app.project(), c.diagram);
|
||||
let owner = v.ports.iter().find(|p| p.element == a).unwrap().owner;
|
||||
send(
|
||||
&mut app,
|
||||
Message::PortAnchor(port_edit::Event::Commit(
|
||||
c,
|
||||
owner,
|
||||
PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.4,
|
||||
},
|
||||
)),
|
||||
);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let mut request = model_menu::Request::object(a, screen(&app, a));
|
||||
request.canvas = app.route_context();
|
||||
let old = request.canvas.unwrap();
|
||||
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
|
||||
assert_ne!(app.route_context().unwrap().epoch, old.epoch);
|
||||
let action = choose(&app, "Reset automatic position");
|
||||
send(&mut app, action);
|
||||
assert!(
|
||||
app.project().diagrams[&c.diagram].nodes[&owner]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let mut request = model_menu::Request::object(a, screen(&app, a));
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
|
||||
let caption = model_menu::popup(&app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, .. } if label.starts_with("Draw functional exchange") => {
|
||||
Some(label.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let action = choose(&app, &caption);
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
|
||||
let context = app.route_context().unwrap();
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, context);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Endpoint(context, b)),
|
||||
);
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_after_release_must_not_move_clicked_port() {
|
||||
let (mut app, a, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let start = screen(&app, a);
|
||||
let end = start + Vector::new(-120., -150.);
|
||||
let mut state = State::default();
|
||||
let c = canvas(&app);
|
||||
// Iced batches all window events until AboutToWait and passes the final
|
||||
// window cursor to every event, even when the final motion follows release.
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert!(
|
||||
app.document.bundle() == &before,
|
||||
"Moving the cursor after release must not author a port anchor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_after_source_click_release_must_not_create_connection() {
|
||||
let (mut app, a, b) = fixture();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))),
|
||||
);
|
||||
let start = screen(&app, a);
|
||||
let end = screen(&app, b);
|
||||
let mut state = State::default();
|
||||
let c = canvas(&app);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert!(
|
||||
app.project().relations.is_empty(),
|
||||
"Hover after releasing the source is not a connection commit"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
(
|
||||
{
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
app
|
||||
},
|
||||
output,
|
||||
input,
|
||||
)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn screen(p: Position, zoom: f32) -> Point {
|
||||
Point::new(bounds().x + p.x * zoom + 20., bounds().y + p.y * zoom + 30.)
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn event(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
c.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn batch(app: &App, state: &mut State, start: Point, end: Point, zoom: f32) -> Vec<Message> {
|
||||
let c = canvas(app, zoom);
|
||||
[
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, state, e, end))
|
||||
.collect()
|
||||
}
|
||||
fn assert_history(app: &mut App, before: &oa_core::source_project::SourceBundle, revision: u64) {
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_fe_ports_commit_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let at = |id| {
|
||||
screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == id)
|
||||
.unwrap()
|
||||
.position,
|
||||
zoom,
|
||||
)
|
||||
};
|
||||
let messages = batch(&app, &mut State::default(), at(output), at(input), zoom);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(
|
||||
matches!(&messages[0],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==output)
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[1],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==input)
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(
|
||||
matches!(app.project().relations[&app.selected].body,RelationBody::FunctionalExchange{source_port,target_port,..}if source_port==output&&target_port==input)
|
||||
);
|
||||
assert!(app.connection.is_none());
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let dest = projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35);
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(port.position, zoom),
|
||||
screen(dest, zoom),
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
let node = owner.id;
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(app.project().relations.is_empty());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()].nodes[&node].port_anchors[0].port,
|
||||
output
|
||||
);
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let a = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == output)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let b = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let _ = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
a,
|
||||
);
|
||||
let right = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
a,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(right);
|
||||
assert!(app.model_menu.is_some());
|
||||
// The overlay consumes the right release, movement to B, outside press and
|
||||
// its release. None of those events reach the underlying canvas.
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Close));
|
||||
let press = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
b,
|
||||
)
|
||||
.unwrap();
|
||||
let release = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
let _ = app.update(press);
|
||||
if let Some(message) = release {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
picked,
|
||||
Some(input),
|
||||
"Click at B picked another port; following release: {release_debug}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"A stationary click after closing a menu must not edit port geometry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
let (mut app, output, _) = fixture();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let a = screen(port.position, 1.);
|
||||
let b = screen(
|
||||
projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35),
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let selected = batch(&app, &mut state, a, a, 1.);
|
||||
for message in selected {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.selected, output);
|
||||
// No new motion to A occurs: the previous selection ended there. Winit
|
||||
// batches press(A), motion(B), release(B) with cursor B on every event.
|
||||
let c = canvas(&app, 1.);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.last(),Some(Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))) if anchor.port==output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_and_stale_epoch_block_queued_connection_and_port_commits() {
|
||||
for is_connection in [false, true] {
|
||||
for barrier in ["menu", "modal", "epoch"] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
if is_connection {
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
}
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let out = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let target = if is_connection {
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position
|
||||
} else {
|
||||
projection::port_anchor_position(
|
||||
view.nodes.iter().find(|n| n.id == out.owner).unwrap(),
|
||||
PortAnchorSide::Top,
|
||||
0.35,
|
||||
)
|
||||
};
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(out.position, 1.),
|
||||
screen(target, 1.),
|
||||
1.,
|
||||
);
|
||||
match barrier {
|
||||
"menu" => {
|
||||
let _ = app.update(model_menu::open(output, Point::ORIGIN));
|
||||
}
|
||||
"modal" => {
|
||||
let _ = app.update(Message::AddRelation);
|
||||
}
|
||||
_ => {
|
||||
let _ = app.update(Message::Escape);
|
||||
}
|
||||
}
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"{barrier}; connection={is_connection}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project,
|
||||
diagram,
|
||||
selected: Id::nil(),
|
||||
zoom,
|
||||
pan: Vector::new(17., 23.),
|
||||
context: Context {
|
||||
document: project.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn local(canvas: &DiagramCanvas<'_>, position: Position) -> Point {
|
||||
Point::new(
|
||||
canvas.pan.x + position.x * canvas.zoom,
|
||||
canvas.pan.y + position.y * canvas.zoom,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
|
||||
let project = Project::empty();
|
||||
let diagram = Id::new_v4();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
||||
let mut view = ProjectedView {
|
||||
ports: (0..2)
|
||||
.map(|i| projection::ProjectedPort {
|
||||
element: Id::from_u128(i + 10),
|
||||
owner: diagram,
|
||||
position: Position {
|
||||
x: 100. + if horizontal { i as f32 * 28. } else { 0. },
|
||||
y: 100. + if horizontal { 0. } else { i as f32 * 28. },
|
||||
},
|
||||
side,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for zoom in [0.0001, 0.35, 1., 3.] {
|
||||
let canvas = canvas(&project, diagram, zoom);
|
||||
for _ in 0..2 {
|
||||
for port in &view.ports {
|
||||
assert_eq!(
|
||||
canvas.endpoint_at(&view, local(&canvas, port.position)),
|
||||
Some(port.element),
|
||||
"Exact port center must win at zoom {zoom} on {side:?}"
|
||||
);
|
||||
}
|
||||
view.ports.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_ports_keep_selection_then_use_stable_identity() {
|
||||
let project = Project::empty();
|
||||
let diagram = Id::new_v4();
|
||||
let a = Id::from_u128(10);
|
||||
let b = Id::from_u128(20);
|
||||
let mut view = ProjectedView {
|
||||
ports: [b, a]
|
||||
.into_iter()
|
||||
.map(|element| projection::ProjectedPort {
|
||||
element,
|
||||
owner: diagram,
|
||||
position: Position { x: 100., y: 100. },
|
||||
side: PortAnchorSide::Right,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut canvas = canvas(&project, diagram, 0.35);
|
||||
let at = local(&canvas, view.ports[0].position);
|
||||
for _ in 0..2 {
|
||||
canvas.selected = Id::nil();
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(a));
|
||||
canvas.selected = b;
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(b));
|
||||
view.ports.reverse();
|
||||
}
|
||||
assert!(canvas.hit_port(&view, Point::new(-100., -100.)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
let mut project = Project::empty();
|
||||
let group = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Response", None)
|
||||
.unwrap();
|
||||
let owner = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Send report", Some(group))
|
||||
.unwrap();
|
||||
let ports = [Id::from_u128(10), Id::from_u128(20)];
|
||||
for (i, id) in ports.into_iter().enumerate() {
|
||||
let port = make_port(
|
||||
&project,
|
||||
id,
|
||||
owner,
|
||||
&format!("Output {i}"),
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.elements.insert(id, port);
|
||||
}
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
project.place(diagram, group, Position { x: 60., y: 60. });
|
||||
let node = project.place(diagram, owner, Position { x: 100., y: 140. });
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors = ports
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, port)| PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.4 + i as f32 * 0.1,
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
let canvas = canvas(&project, diagram, 0.35);
|
||||
let view = project_view(&project, diagram);
|
||||
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
|
||||
for port in &view.ports {
|
||||
let at = local(&canvas, port.position);
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(port.element));
|
||||
let mut state = State::default();
|
||||
let cursor = mouse::Cursor::Available(Point::new(bounds.x + at.x, bounds.y + at.y));
|
||||
let press = canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
|
||||
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
|
||||
let menu = canvas.context_request(&state, at, bounds);
|
||||
assert_eq!(menu.object, port.element);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Passive renderer layer for port identity feedback. Iced batches every text
|
||||
//! primitive after meshes within one GPU layer, even across Canvas frames.
|
||||
//! A second stacked canvas places the complete tooltip above diagram text.
|
||||
use super::*;
|
||||
|
||||
pub struct PortLabels<'a>(pub DiagramCanvas<'a>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
view: RefCell<Option<(Context, ProjectedView)>>,
|
||||
context: Option<Context>,
|
||||
held_buttons: u8,
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for PortLabels<'_> {
|
||||
type State = State;
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if state.context != Some(self.0.context) {
|
||||
state.context = Some(self.0.context);
|
||||
state.held_buttons = 0;
|
||||
}
|
||||
let button_mask = |button| match button {
|
||||
mouse::Button::Left => 1,
|
||||
mouse::Button::Middle => 2,
|
||||
_ => 0,
|
||||
};
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) if cursor.is_over(bounds) => {
|
||||
state.held_buttons |= button_mask(*button)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
state.held_buttons &= !button_mask(*button)
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => state.held_buttons = 0,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
|
||||
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) =>
|
||||
{
|
||||
state.held_buttons = 0
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Never capture input: every event still reaches the interactive canvas.
|
||||
matches!(event, canvas::Event::Mouse(_)).then(Action::request_redraw)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
state: &State,
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
if state.context == Some(self.0.context) && state.held_buttons != 0 {
|
||||
return vec![];
|
||||
}
|
||||
let canvas = &self.0;
|
||||
let mut cached = state.view.borrow_mut();
|
||||
if cached
|
||||
.as_ref()
|
||||
.is_none_or(|(context, _)| *context != canvas.context)
|
||||
{
|
||||
*cached = Some((canvas.context, project_view(canvas.project, canvas.diagram)));
|
||||
}
|
||||
let view = &cached.as_ref().unwrap().1;
|
||||
let Some(port) = cursor
|
||||
.position_in(bounds)
|
||||
.and_then(|p| canvas.hit_port(view, p))
|
||||
.or_else(|| view.ports.iter().find(|p| p.element == canvas.selected))
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let center = point(port.position);
|
||||
let mut port_label = Frame::new(renderer, bounds.size());
|
||||
|
||||
let name = short(canvas.project.named(port.element), 40);
|
||||
let owner = canvas.project.elements[&port.element]
|
||||
.parent
|
||||
.map(|id| short(canvas.project.named(id), 40))
|
||||
.unwrap_or_default();
|
||||
let alias = &canvas.project.elements[&port.element].alias;
|
||||
let alias = if alias.chars().count() > 48 {
|
||||
format!(
|
||||
"@{}…{}",
|
||||
alias.chars().take(22).collect::<String>(),
|
||||
alias
|
||||
.chars()
|
||||
.skip(alias.chars().count() - 24)
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
format!("@{alias}")
|
||||
};
|
||||
let width = ((name
|
||||
.chars()
|
||||
.count()
|
||||
.max(owner.chars().count())
|
||||
.max(alias.chars().count()) as f32
|
||||
* 7.)
|
||||
+ 16.)
|
||||
.min((bounds.width - 8.).max(0.));
|
||||
let height = 60.;
|
||||
let anchor = Point::new(
|
||||
center.x * canvas.zoom + canvas.pan.x + 12.,
|
||||
center.y * canvas.zoom + canvas.pan.y + 12.,
|
||||
);
|
||||
let origin = Point::new(
|
||||
anchor.x.clamp(4., (bounds.width - width - 4.).max(4.)),
|
||||
anchor.y.clamp(4., (bounds.height - height - 4.).max(4.)),
|
||||
);
|
||||
let background = Path::rounded_rectangle(origin, Size::new(width, height), 4.into());
|
||||
port_label.fill(&background, Color::WHITE);
|
||||
port_label.stroke(&background, Stroke::default().with_color(style::LINE));
|
||||
port_label.fill_text(Text {
|
||||
content: name,
|
||||
position: origin + Vector::new(8., 4.),
|
||||
color: style::TEAL,
|
||||
size: 12.into(),
|
||||
..Default::default()
|
||||
});
|
||||
port_label.fill_text(Text {
|
||||
content: owner,
|
||||
position: origin + Vector::new(8., 22.),
|
||||
color: style::MUTED,
|
||||
size: 11.into(),
|
||||
..Default::default()
|
||||
});
|
||||
port_label.fill_text(Text {
|
||||
content: alias,
|
||||
position: origin + Vector::new(8., 40.),
|
||||
color: style::MUTED,
|
||||
size: 11.into(),
|
||||
..Default::default()
|
||||
});
|
||||
vec![port_label.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{app::App, model_menu};
|
||||
use canvas::Program;
|
||||
|
||||
fn fixture() -> (App, Id) {
|
||||
let mut project = Project::empty();
|
||||
let owner = project.add(Kind::Function, "Send report", 100., 150.);
|
||||
let port = Id::new_v4();
|
||||
project.elements.insert(
|
||||
port,
|
||||
make_port(
|
||||
&project,
|
||||
port,
|
||||
owner,
|
||||
"Report output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
(App::with_project(project), port)
|
||||
}
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(120., 80.), Size::new(900., 600.))
|
||||
}
|
||||
|
||||
// Stack visits this canvas first. Every event must remain available to the
|
||||
// interactive child, and this layer must never levitate its cursor.
|
||||
fn passive(labels: &PortLabels<'_>, state: &mut State, event: canvas::Event, at: Point) {
|
||||
let cursor = mouse::Cursor::Available(at);
|
||||
if let Some(action) = labels.update(state, &event, bounds(), cursor) {
|
||||
let (message, _, status) = action.into_inner();
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, iced::event::Status::Ignored);
|
||||
}
|
||||
assert_eq!(
|
||||
labels.mouse_interaction(state, bounds(), cursor),
|
||||
mouse::Interaction::None
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse(labels: &PortLabels<'_>, state: &mut State, event: mouse::Event) {
|
||||
passive(
|
||||
labels,
|
||||
state,
|
||||
canvas::Event::Mouse(event),
|
||||
bounds().center(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_and_reentering_keeps_suppression_until_each_held_button_releases() {
|
||||
let (app, _) = fixture();
|
||||
let labels = program(&app);
|
||||
let mut state = State::default();
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
);
|
||||
mouse(&labels, &mut state, mouse::Event::CursorLeft);
|
||||
mouse(&labels, &mut state, mouse::Event::CursorEntered);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved {
|
||||
position: bounds().center(),
|
||||
},
|
||||
);
|
||||
assert_ne!(
|
||||
state.held_buttons, 0,
|
||||
"Preview geometry remains active across reentry"
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Right),
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
);
|
||||
assert_ne!(state.held_buttons, 0, "Middle-button pan is still held");
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_epoch_clears_suppression_when_the_overlay_consumes_release() {
|
||||
let (mut app, port) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let mut state = State::default();
|
||||
mouse(
|
||||
&program(&app),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
);
|
||||
let old_context = state.context;
|
||||
let _ = app.update(model_menu::open(port, bounds().center()));
|
||||
assert!(app.model_menu.is_some());
|
||||
assert_ne!(old_context, app.route_context());
|
||||
// Left release and dismissal input are captured by the root popup.
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Close));
|
||||
passive(
|
||||
&program(&app),
|
||||
&mut state,
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
bounds().center(),
|
||||
);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
assert_eq!(state.context, app.route_context());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_presses_do_not_suppress_and_cancel_or_focus_loss_clear_held_buttons() {
|
||||
let (app, _) = fixture();
|
||||
let labels = program(&app);
|
||||
let mut state = State::default();
|
||||
passive(
|
||||
&labels,
|
||||
&mut state,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Point::ORIGIN,
|
||||
);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
for event in [
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
physical_key: iced::keyboard::key::Physical::Code(
|
||||
iced::keyboard::key::Code::Escape,
|
||||
),
|
||||
location: iced::keyboard::Location::Standard,
|
||||
modifiers: iced::keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}),
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
] {
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
);
|
||||
passive(&labels, &mut state, event, bounds().center());
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_layer_leaves_port_drag_selection_and_commit_with_the_interactive_canvas() {
|
||||
let (app, port) = fixture();
|
||||
let labels = program(&app);
|
||||
let view = project_view(app.project(), labels.0.diagram);
|
||||
let projected = view.ports.iter().find(|p| p.element == port).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == projected.owner).unwrap();
|
||||
let screen = |p: Position| point(p) + Vector::new(bounds().x, bounds().y);
|
||||
let start = screen(projected.position);
|
||||
let end = screen(projection::port_anchor_position(
|
||||
owner,
|
||||
PortAnchorSide::Top,
|
||||
0.3,
|
||||
));
|
||||
let mut state = State::default();
|
||||
let mut interactive = super::super::State::default();
|
||||
let mut messages = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
let event = canvas::Event::Mouse(event);
|
||||
passive(&labels, &mut state, event.clone(), end);
|
||||
if let Some(action) = labels.0.update(
|
||||
&mut interactive,
|
||||
&event,
|
||||
bounds(),
|
||||
mouse::Cursor::Available(end),
|
||||
) {
|
||||
let (message, _, _) = action.into_inner();
|
||||
messages.extend(message);
|
||||
}
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
|
||||
assert!(
|
||||
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,10 @@ pub fn sidebar(app: &App) -> View<'_, Message> {
|
||||
.style(style::card),
|
||||
tooltip::Position::Right,
|
||||
));
|
||||
content = content.push(outline);
|
||||
let id = entry.id;
|
||||
content = content.push(crate::context_menu::Area::new(outline, move |anchor| {
|
||||
crate::model_menu::open(id, anchor)
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
@@ -202,27 +205,30 @@ fn item<'a>(app: &'a App, id: Id, context: bool) -> View<'a, Message> {
|
||||
info = info.push(label(path));
|
||||
}
|
||||
}
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
crate::context_menu::Area::new(
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
move |anchor| crate::model_menu::open(id, anchor),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
+705
-52
@@ -14,6 +14,7 @@ use iced::{
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -31,6 +32,12 @@ pub struct Input {
|
||||
pub raw: String,
|
||||
pub editor: text_editor::Content,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
pub original: Id,
|
||||
@@ -38,7 +45,11 @@ pub struct Draft {
|
||||
baseline: Value,
|
||||
pub inputs: BTreeMap<String, Input>,
|
||||
pub exact_strings: bool,
|
||||
context: Option<Value>,
|
||||
pub semantic_groups: bool,
|
||||
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -64,6 +75,7 @@ impl Draft {
|
||||
.values()
|
||||
.find_map(|d| d.nodes.get(&selected).map(|n| (d.id, n)))?;
|
||||
let mut value = json!(node);
|
||||
value["port_anchors"] = json!(node.port_anchors);
|
||||
value["diagram"] = json!(diagram);
|
||||
(RecordKind::Node(diagram), value)
|
||||
};
|
||||
@@ -74,12 +86,18 @@ impl Draft {
|
||||
value,
|
||||
inputs: BTreeMap::new(),
|
||||
exact_strings: false,
|
||||
context: None,
|
||||
semantic_groups: false,
|
||||
constructor_drafts: BTreeMap::new(),
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
||||
match value {
|
||||
@@ -128,8 +146,32 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
pub fn materialize(&self) -> Result<Value, String> {
|
||||
self.materialize_inputs(|_| true)
|
||||
}
|
||||
/// Read-only context for dependent controls. Workflow proposals never replace
|
||||
/// the authored value or its input buffers and are not used by command().
|
||||
pub fn context_value(&self) -> Value {
|
||||
self.context
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
|
||||
}
|
||||
pub fn set_context(&mut self, context: Option<Value>) {
|
||||
self.context = context;
|
||||
}
|
||||
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
|
||||
self.materialize_inputs(|path| {
|
||||
scopes
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
})
|
||||
}
|
||||
fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result<Value, String> {
|
||||
let mut value = self.value.clone();
|
||||
for (path, input) in &self.inputs {
|
||||
if !include(path) {
|
||||
continue;
|
||||
}
|
||||
let Some(target) = value.pointer_mut(path) else {
|
||||
continue;
|
||||
};
|
||||
@@ -200,6 +242,166 @@ impl Draft {
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
|
||||
/// including an invalid value which this replacement deliberately removes.
|
||||
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
*self
|
||||
.value
|
||||
.pointer_mut(path)
|
||||
.ok_or("Property no longer exists.")? = value;
|
||||
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
/// Preserve each selected constructor branch, including incomplete raw input
|
||||
/// and editor state. No sibling fields are parsed or cleared by a switch.
|
||||
pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> {
|
||||
let current = self
|
||||
.value
|
||||
.pointer(path)
|
||||
.ok_or("Property no longer exists.")?
|
||||
.clone();
|
||||
if same_variant(¤t, &proposed) {
|
||||
return Ok(());
|
||||
}
|
||||
let constructor = path.ends_with("/DataType/definition")
|
||||
|| path.ends_with("/Port/contract")
|
||||
|| path.ends_with("/Occurrence/body")
|
||||
|| path.ends_with("/Event/body")
|
||||
|| path.ends_with("/message_kind");
|
||||
if !constructor {
|
||||
return self.shape_scoped(path, proposed);
|
||||
}
|
||||
// Input/output direction changes carry the item declaration. Read those
|
||||
// raw reference strings before stashing their editor buffers so an
|
||||
// incomplete reference cannot silently fall back to its stored UUID.
|
||||
let port_current = if path.ends_with("/Port/contract") {
|
||||
self.materialize_scopes(&[path.to_owned()])?
|
||||
.pointer(path)
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tag = |value: &Value| {
|
||||
value
|
||||
.as_str()
|
||||
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
};
|
||||
let previous_key = (path.to_owned(), tag(¤t));
|
||||
let next_key = (path.to_owned(), tag(&proposed));
|
||||
let mut inputs = BTreeMap::new();
|
||||
for key in self
|
||||
.inputs
|
||||
.keys()
|
||||
.filter(|key| within(key, path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
inputs.insert(key.clone(), self.inputs.remove(&key).unwrap());
|
||||
}
|
||||
self.constructor_drafts
|
||||
.insert(previous_key, (current.clone(), inputs));
|
||||
let saved = self.constructor_drafts.remove(&next_key);
|
||||
let (value, restored) = if let Some(saved) = saved {
|
||||
saved
|
||||
} else {
|
||||
let value = if path.ends_with("/Port/contract") {
|
||||
port_fields::switch_contract(
|
||||
port_current.as_ref().unwrap_or(¤t),
|
||||
proposed,
|
||||
&mut BTreeMap::new(),
|
||||
)
|
||||
} else {
|
||||
proposed
|
||||
};
|
||||
(value, BTreeMap::new())
|
||||
};
|
||||
self.shape_scoped(path, value)?;
|
||||
self.inputs.extend(restored);
|
||||
Ok(())
|
||||
}
|
||||
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
|
||||
let previous = std::mem::take(&mut self.inputs);
|
||||
self.rebuild();
|
||||
for (path, input) in previous {
|
||||
if let Some(path) = new_path(&path)
|
||||
&& self.inputs.contains_key(&path)
|
||||
{
|
||||
self.inputs.insert(path, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn array_scoped(
|
||||
&mut self,
|
||||
path: &str,
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.value.clone();
|
||||
let count = candidate
|
||||
.pointer(path)
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("List no longer exists.")?
|
||||
.len();
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild_retaining(|old| {
|
||||
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
|
||||
return Some(old.to_owned());
|
||||
};
|
||||
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
|
||||
let position = number.parse::<usize>().ok()?;
|
||||
let position = match index {
|
||||
Some(index) if direction == 0 && position == index => return None,
|
||||
Some(index) if direction == 0 && position > index => position - 1,
|
||||
Some(index) if direction != 0 => {
|
||||
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
|
||||
if position == index {
|
||||
target
|
||||
} else if position == target {
|
||||
index
|
||||
} else {
|
||||
position
|
||||
}
|
||||
}
|
||||
_ => position,
|
||||
};
|
||||
Some(if rest.is_empty() {
|
||||
format!("{path}/{position}")
|
||||
} else {
|
||||
format!("{path}/{position}/{rest}")
|
||||
})
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
self.value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.rebuild_retaining(|path| Some(path.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path_scoped(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let row = self
|
||||
.value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
self.rebuild_retaining(|old| {
|
||||
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
|
||||
.then(|| old.to_owned())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn array(
|
||||
&mut self,
|
||||
path: &str,
|
||||
@@ -207,34 +409,34 @@ impl Draft {
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
let items = candidate
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?;
|
||||
match index {
|
||||
None => items.push(if path.ends_with("literals") {
|
||||
json!("New value")
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
Some(i) if i < items.len() => {
|
||||
if direction == 0 {
|
||||
items.remove(i);
|
||||
} else {
|
||||
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
|
||||
items.swap(i, target);
|
||||
}
|
||||
}
|
||||
_ => return Err("List item no longer exists.".into()),
|
||||
}
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
let row = candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
@@ -309,6 +511,51 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
|
||||
fn within(path: &str, root: &str) -> bool {
|
||||
path == root
|
||||
|| path
|
||||
.strip_prefix(root)
|
||||
.is_some_and(|tail| tail.starts_with('/'))
|
||||
}
|
||||
fn edit_array(
|
||||
value: &mut Value,
|
||||
path: &str,
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let items = value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?;
|
||||
match index {
|
||||
None => items.push(if path.ends_with("literals") {
|
||||
json!("New value")
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"path": [], "field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
Some(i) if i < items.len() => {
|
||||
if direction == 0 {
|
||||
items.remove(i);
|
||||
} else {
|
||||
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
|
||||
items.swap(i, target);
|
||||
}
|
||||
}
|
||||
_ => return Err("List item no longer exists.".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn friendly(name: &str) -> String {
|
||||
match name {
|
||||
"id" => "UUID identity".into(),
|
||||
@@ -334,6 +581,8 @@ fn friendly(name: &str) -> String {
|
||||
"Named" => "Data type".into(),
|
||||
"body" => "Occurrence type".into(),
|
||||
"diagram" => "Diagram".into(),
|
||||
"StateMachine" => "State machine".into(),
|
||||
"machine" => "State machine context".into(),
|
||||
"uri" => "Artifact URI".into(),
|
||||
"definition" => "Data type definition".into(),
|
||||
"steps" | "occurrences" => "Ordered occurrences".into(),
|
||||
@@ -387,6 +636,7 @@ fn reference(path: &str) -> bool {
|
||||
| "represented"
|
||||
| "exchange"
|
||||
| "edge"
|
||||
| "port"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
@@ -404,6 +654,8 @@ fn reference(path: &str) -> bool {
|
||||
| "sa"
|
||||
| "Named"
|
||||
| "diagram"
|
||||
| "machine"
|
||||
| "supertype"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items"
|
||||
@@ -433,6 +685,7 @@ fn optional_reference(path: &str) -> bool {
|
||||
| "reply_to"
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "supertype"
|
||||
)
|
||||
}
|
||||
fn multiline(path: &str) -> bool {
|
||||
@@ -455,15 +708,22 @@ fn free_string(path: &str) -> bool {
|
||||
multiline(path)
|
||||
|| matches!(
|
||||
path.rsplit('/').next(),
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit")
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
|
||||
)
|
||||
|| path.split('/').rev().nth(1) == Some("literals")
|
||||
|| (path.contains("/default/")
|
||||
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
|
||||
}
|
||||
|
||||
// These are choices for the closed model, not a second validation boundary.
|
||||
// A current reference is always retained, even while other draft fields make it
|
||||
// incompatible. Applying the complete draft remains the core validator's job.
|
||||
fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec<Choice> {
|
||||
pub(crate) fn reference_choices(
|
||||
draft: &Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
current: Id,
|
||||
) -> Vec<Choice> {
|
||||
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
|
||||
let compatible = reference_ids(draft, project, path);
|
||||
let mut objects = compatible
|
||||
@@ -515,7 +775,18 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
|
||||
}
|
||||
|
||||
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let value = draft.context_value();
|
||||
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
|
||||
let owner = value
|
||||
.get("element")
|
||||
.and_then(|id| serde_json::from_value::<Id>(id.clone()).ok());
|
||||
return project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port && e.parent == owner)
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
}
|
||||
let element = (draft.kind == RecordKind::Element)
|
||||
.then(|| serde_json::from_value::<Element>(value.clone()).ok())
|
||||
.flatten()
|
||||
@@ -527,7 +798,12 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let layer = element
|
||||
.as_ref()
|
||||
.map(Element::layer)
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())))
|
||||
.or_else(|| {
|
||||
(draft.kind == RecordKind::Diagram)
|
||||
.then(|| serde_json::from_value::<Layer>(value["layer"].clone()).ok())
|
||||
.flatten()
|
||||
});
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
if field == "edge" {
|
||||
return project
|
||||
@@ -552,6 +828,8 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
};
|
||||
|
||||
if field == "diagram" {
|
||||
// Keep destinations selectable before retargeting the element. The
|
||||
// element picker below uses the draft destination's membership rules.
|
||||
return project.diagrams.keys().copied().collect();
|
||||
}
|
||||
if field == "owner" || list == "constrained" {
|
||||
@@ -637,21 +915,42 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.collect();
|
||||
}
|
||||
if field == "field" {
|
||||
let address_path = path
|
||||
.strip_suffix("/field")
|
||||
.and_then(|row| value.pointer(&format!("{row}/path")))
|
||||
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
return element
|
||||
.as_ref()
|
||||
.and_then(|e| match e.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id), ..
|
||||
exchange: Some(id),
|
||||
message_kind,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => project.relations.get(id),
|
||||
} => project.relations.get(id).and_then(|r| {
|
||||
let scope = address_path.split_last().map(|(&field, path)| {
|
||||
crate::data_values::FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
}
|
||||
});
|
||||
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.map(|r| validation::carried_fields(project, r).into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|choice| choice.key.path == address_path)
|
||||
.map(|choice| choice.key.field)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
|
||||
let scenario = element
|
||||
.as_ref()
|
||||
.and_then(|e| e.parent)
|
||||
@@ -673,18 +972,50 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| id.parse().ok())
|
||||
.unwrap_or(diagram);
|
||||
return project.diagrams.get(&diagram).is_some_and(|d| {
|
||||
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
|
||||
&& !d
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.id != draft.original && n.element == candidate.id)
|
||||
});
|
||||
return project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| placement_allowed(project, d, candidate, draft.original));
|
||||
}
|
||||
if !same_layer(candidate) {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"supertype" => element.as_ref().is_some_and(|owner| {
|
||||
if candidate.id == owner.id
|
||||
|| candidate.kind() != owner.kind()
|
||||
|| (candidate.kind() == Kind::DataType
|
||||
&& !data::is_record(project, candidate.id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let (
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind, mechanism, ..
|
||||
}),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: base_kind,
|
||||
mechanism: base_mechanism,
|
||||
..
|
||||
}),
|
||||
) = (owner.shared(), candidate.shared())
|
||||
&& (kind != base_kind
|
||||
|| (*base_mechanism != ExchangeMechanism::Unspecified
|
||||
&& mechanism != base_mechanism))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut next = Some(candidate.id);
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = next {
|
||||
if id == owner.id || !seen.insert(id) {
|
||||
return false;
|
||||
}
|
||||
next = project.elements.get(&id).and_then(data::supertype);
|
||||
}
|
||||
true
|
||||
}),
|
||||
"machine" => candidate.kind() == Kind::StateMachine,
|
||||
"parent" => element.as_ref().is_some_and(|child| {
|
||||
if candidate.id == draft.original
|
||||
|| candidate.id == child.id
|
||||
@@ -987,6 +1318,18 @@ fn group_label(path: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
|
||||
(diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
|
||||
&& diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
.is_none_or(|machine| projection::state_machine_contains(project, machine, element.id))
|
||||
&& !diagram
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.id != current && node.element == element.id)
|
||||
}
|
||||
|
||||
fn unit_body(path: &str, value: &Value) -> bool {
|
||||
value.is_string()
|
||||
&& matches!(
|
||||
@@ -998,16 +1341,46 @@ fn unit_body(path: &str, value: &Value) -> bool {
|
||||
/// Closed choices for the concrete model enums. No runtime schema/plugin layer.
|
||||
fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> {
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
let words: &[&str] = match field {
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"projection" => &[
|
||||
if path == "/projection" && kind == RecordKind::Diagram {
|
||||
let mut choices = [
|
||||
"Overview",
|
||||
"Decomposition",
|
||||
"Allocation",
|
||||
"Traceability",
|
||||
"FunctionalDataflow",
|
||||
"Capabilities",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (friendly(name), json!(name)))
|
||||
.collect::<Vec<_>>();
|
||||
choices.push((
|
||||
"State machine".into(),
|
||||
if value.get("StateMachine").is_some() {
|
||||
value.clone()
|
||||
} else {
|
||||
json!({"StateMachine": {"machine": Id::nil()}})
|
||||
},
|
||||
));
|
||||
return choices;
|
||||
}
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
||||
"storage" => &["Value", "Reference"],
|
||||
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
|
||||
"direction" if path.contains("DataField") => {
|
||||
&["Unspecified", "In", "Out", "InOut", "Return"]
|
||||
}
|
||||
"transmission" => &[
|
||||
"Unspecified",
|
||||
"Synchronous",
|
||||
"Asynchronous",
|
||||
"Unicast",
|
||||
"Multicast",
|
||||
"Broadcast",
|
||||
],
|
||||
"acquisition" => &["Unspecified", "Read", "Accept"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -1016,7 +1389,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
],
|
||||
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
|
||||
"phase" => &["Entry", "Do", "Exit"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
|
||||
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
|
||||
"kind" if path.contains("State") => &[
|
||||
"State",
|
||||
@@ -1082,11 +1455,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
if field == "definition" {
|
||||
return vec![
|
||||
("Boolean".into(), json!("Boolean")),
|
||||
("Integer".into(), json!("Integer")),
|
||||
("Real".into(), json!("Real")),
|
||||
("Text".into(), json!("String")),
|
||||
(
|
||||
"Integer".into(),
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Real".into(),
|
||||
json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Text".into(),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}}),
|
||||
),
|
||||
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
|
||||
("Record".into(), json!("Record")),
|
||||
("Record".into(), json!({"Record":{"supertype":null}})),
|
||||
];
|
||||
}
|
||||
if field == "type_ref" {
|
||||
@@ -1215,6 +1597,22 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
content.into()
|
||||
}
|
||||
|
||||
/// Render one closed property subtree for a workflow-owned typed draft.
|
||||
/// The caller routes messages to that draft and authorizes the selected scope.
|
||||
pub fn scoped<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
path: &str,
|
||||
label: &str,
|
||||
) -> View<'a, Message> {
|
||||
match draft.value.pointer(path) {
|
||||
Some(value) => property(draft, project, value, path, label, false),
|
||||
None => text("This property is not applicable to the current target.")
|
||||
.size(12)
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn property<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
@@ -1223,8 +1621,11 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = data_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
||||
if path.ends_with("/Port/legacy") {
|
||||
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
|
||||
let mut content = column![].spacing(4);
|
||||
if !value.is_null() {
|
||||
content = content.push(
|
||||
@@ -1238,6 +1639,29 @@ fn property<'a>(
|
||||
}
|
||||
return content.into();
|
||||
}
|
||||
if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant {
|
||||
let mut content = column![
|
||||
label_view(),
|
||||
text(
|
||||
"Retained generic association is part of this group. Change or clear it explicitly."
|
||||
)
|
||||
.size(11)
|
||||
]
|
||||
.spacing(5);
|
||||
if value.is_null() {
|
||||
content = content.push(button(text("Add generic association").size(11)).on_press(
|
||||
Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})),
|
||||
));
|
||||
} else {
|
||||
content = content
|
||||
.push(property(draft, project, value, path, label, true))
|
||||
.push(
|
||||
button(text("Clear generic association").size(11))
|
||||
.on_press(Message::FieldShape(path.into(), Value::Null)),
|
||||
);
|
||||
}
|
||||
return content.into();
|
||||
}
|
||||
let enum_choices = if skip_variant {
|
||||
vec![]
|
||||
} else {
|
||||
@@ -1495,10 +1919,233 @@ fn property<'a>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() {
|
||||
let mut p = Project::empty();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Function", None)
|
||||
.unwrap();
|
||||
let component = p
|
||||
.add_element(Kind::System, Layer::Sa, "Component", None)
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Item", None)
|
||||
.unwrap();
|
||||
let id = Id::new_v4();
|
||||
p.elements.insert(
|
||||
id,
|
||||
ports::make_port(
|
||||
&p,
|
||||
id,
|
||||
function,
|
||||
"Port",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![item],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut draft = Draft::from_project(&p, id).unwrap();
|
||||
draft.semantic_groups = true;
|
||||
let path = "/body/Sa/Shared/Port/contract";
|
||||
let input = format!("{path}/FunctionInput/incoming_items/0");
|
||||
draft.input(&input, "incomplete-id".into());
|
||||
let cursor = draft.inputs[&input].editor.cursor();
|
||||
draft
|
||||
.variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw,
|
||||
"incomplete-id"
|
||||
);
|
||||
assert!(serde_json::from_value::<Element>(draft.materialize().unwrap()).is_err());
|
||||
draft
|
||||
.variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}}))
|
||||
.unwrap();
|
||||
assert_eq!(draft.inputs[&input].raw, "incomplete-id");
|
||||
assert_eq!(draft.inputs[&input].editor.cursor(), cursor);
|
||||
let choices =
|
||||
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
|
||||
assert!(
|
||||
choices
|
||||
.iter()
|
||||
.any(|(_, v)| v.get("FunctionInput").is_some())
|
||||
);
|
||||
assert!(choices.iter().any(|(_, v)| *v == json!("Legacy")));
|
||||
assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some()));
|
||||
let mut context = draft.value.clone();
|
||||
context["parent"] = json!(component);
|
||||
draft.set_context(Some(context));
|
||||
let choices =
|
||||
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
|
||||
assert!(choices.iter().any(|(_, v)| v.get("Component").is_some()));
|
||||
assert!(
|
||||
!choices
|
||||
.iter()
|
||||
.any(|(_, v)| v.get("FunctionInput").is_some())
|
||||
);
|
||||
}
|
||||
|
||||
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
reference_ids(draft, project, path)
|
||||
}
|
||||
|
||||
fn state_diagram_form_fixture() -> Project {
|
||||
language::parse(
|
||||
r#"arcadia 3; project state_forms "State forms" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
oa_region nested "Nested" { parent @ready; }
|
||||
oa_state leaf "Leaf" { parent @nested; }
|
||||
oa_state_machine other "Other modes" { parent @crew; }
|
||||
oa_region other_region "Other region" { parent @other; }
|
||||
oa_state foreign "Foreign" { parent @other_region; }
|
||||
sa_system system "System" {}
|
||||
sa_state_machine sa_modes "System modes" { parent @system; }
|
||||
diagram overview "Overview" {}
|
||||
diagram states "States" {
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
diagram other_states "Other states" {
|
||||
projection { StateMachine { machine @other; }; };
|
||||
}
|
||||
diagram duplicate "Duplicate" {
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @states; element @ready; }
|
||||
node region_view { diagram @states; element @main; }
|
||||
node duplicate_view { diagram @duplicate; element @ready; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn alias_id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_projection_form_keeps_string_variants_and_requires_a_typed_context() {
|
||||
let project = state_diagram_form_fixture();
|
||||
let diagram = alias_id(&project, "overview");
|
||||
let machine = alias_id(&project, "modes");
|
||||
let mut draft = Draft::from_project(&project, diagram).unwrap();
|
||||
let choices = variants("/projection", &draft.value["projection"], draft.kind);
|
||||
assert_eq!(choices.len(), 7);
|
||||
for (_, value) in &choices[..6] {
|
||||
let decoded = serde_json::from_value::<ProjectionKind>(value.clone()).unwrap();
|
||||
assert!(value.is_string());
|
||||
assert_eq!(json!(decoded), *value);
|
||||
}
|
||||
draft.shape("/projection", choices[6].1.clone()).unwrap();
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
let path = "/projection/StateMachine/machine";
|
||||
assert!(reference(path));
|
||||
assert!(!optional_reference(path));
|
||||
assert_eq!(
|
||||
ids(&draft, &project, path)
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([machine, alias_id(&project, "other")])
|
||||
);
|
||||
draft.shape(path, json!(machine)).unwrap();
|
||||
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].projection.state_machine(),
|
||||
Some(machine)
|
||||
);
|
||||
assert_eq!(accepted.elements, project.elements);
|
||||
assert!(accepted.diagrams[&diagram].nodes.is_empty());
|
||||
assert_eq!(
|
||||
variants("/projection", &draft.value["projection"], draft.kind)[6].1,
|
||||
json!({"StateMachine": {"machine": machine}})
|
||||
);
|
||||
|
||||
draft.shape("/layer", json!("Sa")).unwrap();
|
||||
assert_eq!(
|
||||
ids(&draft, &project, path),
|
||||
vec![alias_id(&project, "sa_modes")]
|
||||
);
|
||||
assert!(
|
||||
reference_choices(&draft, &project, path, machine)
|
||||
.iter()
|
||||
.any(|choice| {
|
||||
choice.id == machine && choice.label.starts_with("Current choice needs review")
|
||||
})
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(path),
|
||||
Some(&json!(machine))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_placement_form_uses_draft_destination_membership_without_changing_other_views() {
|
||||
let project = state_diagram_form_fixture();
|
||||
let node = alias_id(&project, "ready_view");
|
||||
let mut draft = Draft::from_project(&project, node).unwrap();
|
||||
let offered = ids(&draft, &project, "/element");
|
||||
for alias in ["modes", "ready", "nested", "leaf"] {
|
||||
assert!(offered.contains(&alias_id(&project, alias)), "{alias}");
|
||||
}
|
||||
for alias in [
|
||||
"main",
|
||||
"other",
|
||||
"other_region",
|
||||
"foreign",
|
||||
"crew",
|
||||
"sa_modes",
|
||||
] {
|
||||
assert!(!offered.contains(&alias_id(&project, alias)), "{alias}");
|
||||
}
|
||||
assert_eq!(
|
||||
ids(&draft, &project, "/diagram")
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>(),
|
||||
project.diagrams.keys().copied().collect::<BTreeSet<_>>()
|
||||
);
|
||||
|
||||
// A destination must remain selectable before its compatible element;
|
||||
// the current element is retained visibly until the draft is complete.
|
||||
|
||||
draft
|
||||
.shape("/diagram", json!(alias_id(&project, "other_states")))
|
||||
.unwrap();
|
||||
let ready = alias_id(&project, "ready");
|
||||
assert!(!ids(&draft, &project, "/element").contains(&ready));
|
||||
assert!(
|
||||
reference_choices(&draft, &project, "/element", ready)
|
||||
.iter()
|
||||
.any(|choice| {
|
||||
choice.id == ready && choice.label.starts_with("Current choice needs review")
|
||||
})
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
draft
|
||||
.shape("/element", json!(alias_id(&project, "foreign")))
|
||||
.unwrap();
|
||||
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
accepted.diagrams[&alias_id(&project, "other_states")]
|
||||
.nodes
|
||||
.contains_key(&node)
|
||||
);
|
||||
|
||||
draft
|
||||
.shape("/diagram", json!(alias_id(&project, "overview")))
|
||||
.unwrap();
|
||||
assert!(ids(&draft, &project, "/element").contains(&alias_id(&project, "crew")));
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_allocation_offers_other_performers_without_counting_itself_twice() {
|
||||
let mut p = Project::empty();
|
||||
@@ -1869,6 +2516,8 @@ mod tests {
|
||||
Layer::Oa,
|
||||
SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
},
|
||||
);
|
||||
let mut draft = Draft::from_project(&p, item).unwrap();
|
||||
@@ -1885,8 +2534,12 @@ mod tests {
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(p.elements[&item].layer(), Layer::Oa);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,10 +42,14 @@ pub(super) fn variants(
|
||||
_value: &Value,
|
||||
) -> Option<Vec<(String, Value)>> {
|
||||
if path.ends_with("/Port/contract") {
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let current = draft.context_value();
|
||||
let sa = current.pointer("/body/Sa").is_some();
|
||||
if !sa {
|
||||
return Some(vec![]);
|
||||
return Some(if draft.semantic_groups {
|
||||
vec![("Legacy generic port".into(), json!("Legacy"))]
|
||||
} else {
|
||||
vec![]
|
||||
});
|
||||
}
|
||||
let owner = current
|
||||
.get("parent")
|
||||
@@ -69,6 +73,9 @@ pub(super) fn variants(
|
||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||
));
|
||||
}
|
||||
if draft.semantic_groups {
|
||||
choices.push(("Legacy generic port".into(), json!("Legacy")));
|
||||
}
|
||||
return Some(choices);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/kind") {
|
||||
@@ -80,7 +87,7 @@ pub(super) fn variants(
|
||||
);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/orientation") {
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let current = draft.context_value();
|
||||
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
|
||||
let orientations: &[&str] =
|
||||
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
|
||||
@@ -144,13 +151,7 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
|
||||
}
|
||||
let declared: Vec<_> = interfaces
|
||||
.iter()
|
||||
.filter_map(|id| project.elements.get(id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.flat_map(|id| data::interface_items(project, *id))
|
||||
.collect();
|
||||
items_satisfy(project, items, &declared)
|
||||
}
|
||||
@@ -714,8 +715,12 @@ mod tests {
|
||||
interface,
|
||||
);
|
||||
assert!(reference_ids(&draft, &project, target).is_empty());
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Topic {
|
||||
Self::Source => "Textual editing & Git",
|
||||
Self::Recovery => "Checks & error recovery",
|
||||
Self::Files => "Files & keyboard shortcuts",
|
||||
Self::Server => "REST API & automation",
|
||||
Self::Server => "Hosted models & REST API",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,9 +299,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Route connections around the model",
|
||||
&[
|
||||
"Select an architecture connection. Drag a line segment to add a pinned bend, or choose Add bend; drag its circles to shape a detour. Select a circle and choose Remove bend to release that pin. Drag the connection label independently. Escape cancels a gesture; Undo reverses one completed drag.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Reset routing clears the selected connection’s pins and label position in this view. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -333,7 +333,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Include, extend or specialize a capability",
|
||||
&[
|
||||
"Select a capability in the diagram or explorer, then open Modeling actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.",
|
||||
"Select a capability in the diagram or explorer, then open Actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.",
|
||||
"Extend capability connects this extending capability to the extended capability. Enter the condition under which the extension adds behavior. An empty condition remains editable and appears in Model checks.",
|
||||
"Specialize capability connects a specific capability to a more general one. Inclusion and specialization cannot form cycles.",
|
||||
"The relationship preview names both ends and generates a readable name. Apply adds one undoable change; Cancel preserves the model. Include and extend labels remain visible in the diagram.",
|
||||
@@ -368,10 +368,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Create a typed exchange",
|
||||
&[
|
||||
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
|
||||
"Select the source, choose + Relationship, then Interaction for OA activities or Functional exchange for SA functions. Choose the compatible target and apply the relationship.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
|
||||
"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.",
|
||||
"For a participant boundary, create compatible ports and a Boundary exchange. Exchange allocation associates that boundary exchange with the functional exchange it carries.",
|
||||
"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(
|
||||
@@ -380,7 +380,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Use engineering payloads",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
|
||||
"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.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
@@ -467,18 +467,26 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.",
|
||||
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal",
|
||||
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal diagram draw reconnect local internal timed fold unfold placement pan zoom undo",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Build a state machine",
|
||||
&[
|
||||
"In Behavior & scenarios, create a State machine and select it. Use + Region to give its state structure an owner.",
|
||||
"Use + State for conditions such as Nominal and Degraded, and + Initial for the starting pseudostate. Select a region and use Create element for other supported state kinds.",
|
||||
"Select a source state and choose + Relationship → State transition. Pick a compatible target in the same state machine.",
|
||||
"Edit the transition's Trigger, Guard and Effect in Properties. These distinguish what initiates the transition, what must be true, and what follows.",
|
||||
"Review the rendered state view and numbered transition list. Model checks calls out unfinished structures so they can be corrected during authoring.",
|
||||
"In Behavior & scenarios, create or select a State machine, then choose Create diagram. This places its existing regions and vertices in a saved view. + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
|
||||
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Navigate and recover a view",
|
||||
"Middle-button drag pans; scroll or use −/+ to zoom, and Fit frames the diagram. Right-click a container for Fold contents or Unfold contents, or choose Placement properties for exact coordinates and size. Hide from this diagram removes only its placement; Show an element not yet in this view restores missing elements. Escape cancels a drawing gesture. Undo restores each completed gesture or menu edit, and Model checks identifies unfinished structures.",
|
||||
),
|
||||
explain(
|
||||
"One saved diagram across editors",
|
||||
"Each state view stores a reference to its state machine and its own placements. Text, JSON and REST edit those same diagram records, including the persisted machine context; saving and reopening retains them. Merely opening the behavior page does not create a diagram. State-view edits preserve other diagram layouts.",
|
||||
),
|
||||
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.",
|
||||
@@ -502,23 +510,40 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
|
||||
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
|
||||
keywords: "data interface port direction input output inout return contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum bounds default unit specialization inheritance association storage reference recursive path transmission acquisition protocol",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Define a reusable payload",
|
||||
&[
|
||||
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
|
||||
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
|
||||
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
|
||||
"Open Data & interfaces and create an Exchange item. Choose Information, Material or Energy for nature; choose Flow, Event, Operation or Shared data for mechanism. Keep Unspecified when the engineering decision is still open.",
|
||||
"Create a Data type for a reusable definition. Choose Boolean, Integer, Real, String, Enumeration or Record. Numeric bounds are inclusive; String bounds count Unicode characters. A typed default must satisfy those bounds or enumeration literals. Units annotate quantities without converting them.",
|
||||
"Create fields within an Exchange item or Record type. Set Value type, minimum/maximum count, unit and optional default. A default can be absent, explicitly empty when minimum is zero, or a list of typed scalar values. A type default describes one element; it does not create a required collection.",
|
||||
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Operation parameters and message values",
|
||||
"An Operation's direct fields are parameters. In and InOut supply call values; Out, InOut and Return supply reply values, from the provider's perspective. There is at most one Return parameter. Qualified paths distinguish origin.latitude from destination.latitude when both use the same Coordinate record. Nested fields inherit the parameter direction. Message expressions are annotations, not evaluated values.",
|
||||
),
|
||||
explain(
|
||||
"Reuse, ownership and references",
|
||||
"A Record or Exchange item can specialize one compatible base. Inherited fields keep their original owner and identity; edit the base to change them. Value fields compose a payload. Reference fields refer to separately identified Record objects and stop payload expansion. Optional recursive values and reference cycles are valid; a mandatory value cycle warns that no finite complete value exists. Data associations describe named reference ends, multiplicities and navigation between Record types without creating contained fields.",
|
||||
),
|
||||
walkthrough(
|
||||
"Allocate an item to interfaces",
|
||||
&[
|
||||
"Create an Interface and allocate an Exchange item. Select its allocation row to edit transmission, acquisition and order; Open item navigates to the shared payload definition.",
|
||||
"Two interfaces may allocate the same item with different protocols. Attach CAD, code or document evidence to the allocation when it concerns that particular contract.",
|
||||
"Removing an allocation keeps the shared item and other allocations. If a live boundary depends on it, first revise that boundary contract. Undo restores an accepted removal.",
|
||||
"An Interface may specialize one base and inherit its allocations. Duplicate allocation of an already inherited item is rejected. A specialized item satisfies its base contract; a base item does not satisfy a more specific contract.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Connect participant boundaries",
|
||||
&[
|
||||
"In System Analysis, select a System or System Actor and choose Add port in Model explorer or Properties. Name it, choose Standard or Flow, then Create port. Standard ports describe interfaces; Flow ports have their own input/output orientation. Functions use separate input/output port actions.",
|
||||
"Use Interface provision and Interface requirement relationships to state who provides or needs the contract.",
|
||||
"Select the component port and choose Connect boundary exchange. Choose the other participant's compatible port; owner names and aliases distinguish repeated port names. Create the matching port first if none exists. Exchange orientation is separate from Flow port orientation. Connect carried functional exchanges using Exchange allocation.",
|
||||
"Select a visible component port and choose Draw boundary exchange, then click or drag to a highlighted compatible port. The diagram palette offers the same tool. If the endpoints are outside the diagram, Configure boundary exchange opens the form with owner names and aliases. Create the matching port first if needed. Exchange orientation is separate from Flow port orientation. Exchange allocation associates carried functional exchanges with the boundary connection.",
|
||||
"Link the detailed interface-control document, CAD connector or schematic through an Artifact reference when an external artifact supplies additional evidence.",
|
||||
],
|
||||
),
|
||||
@@ -613,7 +638,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
&[
|
||||
"Open Textual model and choose a source file, or choose Source in Properties to locate the selected record in its owning file. Switching files preserves each draft.",
|
||||
"Open Textual model and expand folders to choose a source file, or choose Actions > Open source in Properties to locate the selected record. Switching files preserves each draft. Collapsed folders show modified-file and diagnostic counts; Locate reveals the active file. Right-click a file for its actions.",
|
||||
"Use Find / go to, Ctrl+F or Ctrl+G to navigate. Literal search is case sensitive. Enter/F3 selects the next match, Shift+F3 selects the previous match, and results wrap. Go to line accepts the displayed one-based line number.",
|
||||
"Edit the draft. Syntax highlighting distinguishes source structure; live lint reports errors with line and column information.",
|
||||
"Choose Apply all to parse and validate all reachable included files. A successful application is one model change; graphical views and Properties then reflect it. Finish or revert source drafts before editing graphical properties.",
|
||||
@@ -715,7 +740,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Open and migration",
|
||||
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
|
||||
"Open validates a complete candidate before replacing the project. Versions 1, 2 and 3 migrate explicitly to version 4; Model checks shows preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source3 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.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
|
||||
@@ -726,16 +751,42 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Server => {
|
||||
const {
|
||||
Article {
|
||||
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
|
||||
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
|
||||
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
|
||||
sections: &[
|
||||
example(
|
||||
"Start a local service",
|
||||
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
|
||||
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
|
||||
"From the repository root, run the command below in a terminal and leave it running. The default address is loopback 127.0.0.1:8787, with platform-local storage. docs/server.md also covers installed executables, authentication and Linux deployment.",
|
||||
"cargo run --locked -p oa-server --bin arcadia-server\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
|
||||
),
|
||||
walkthrough(
|
||||
"Build a safe editing client",
|
||||
"Open or publish a hosted model",
|
||||
&[
|
||||
"Choose Hosted models... in the top bar. Enter the service root, such as http://127.0.0.1:8787, without /api/v1. Enter the configured bearer token if required, then Connect / refresh.",
|
||||
"Choose Publish as new to upload the current source project, including its files and comments, as a separate hosted resource. Its acknowledgement switches the current Save destination to that resource.",
|
||||
"To open an existing resource, search the Hosted model catalog, select a model and choose Open selected. More models loads another page. Unsaved work uses the normal save/discard/cancel prompt.",
|
||||
"Edit diagrams, properties or Textual model, then choose Save to server or press Ctrl+S. Valid drafts are applied; invalid drafts remain available to correct or revert. Edits and Undo stay local until Save.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Review another client's changes",
|
||||
&[
|
||||
"Check server reads the current revision without replacing local content. A stale Save returns 412 and automatically checks the server for review; local work is retained.",
|
||||
"Review the changed file list. View server text opens a read-only file; Back to review returns to the list. There is no automatic merge.",
|
||||
"Reload server version adopts the reviewed source project as one undoable local change after checking unapplied drafts. Undo restores the previous accepted local version without changing the server. Reconcile your intended changes before saving again.",
|
||||
"After an unknown write outcome, use Check previous write when offered. If publication may already have succeeded, inspect the refreshed catalog before publishing another copy.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Return to a local file",
|
||||
"Choose Save as local... or Ctrl+Shift+S, then save an .arcadia entry to retain included files and comments. A successful source-project save makes that file the current destination and leaves the hosted resource unchanged. JSON exports the model only and retains the hosted destination and unsaved state. Canceling or a failed save also retains the hosted destination. Forget connection becomes available after leaving the hosted session.",
|
||||
),
|
||||
explain(
|
||||
"Connection credentials",
|
||||
"The desktop keeps credentials in memory only. Use HTTPS remotely; HTTP is allowed only for localhost or a loopback IP. Put the token in its own field, never the service URL. URLs with credentials, queries or fragments are rejected, and redirects are not followed. To update a token, enter it and Connect / refresh for the same service, then retry the check or Save.",
|
||||
),
|
||||
walkthrough(
|
||||
"Automate model edits",
|
||||
&[
|
||||
"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.",
|
||||
@@ -744,10 +795,6 @@ fn article(topic: Topic) -> Article {
|
||||
"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(
|
||||
"Desktop files and hosted models",
|
||||
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
|
||||
),
|
||||
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.",
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Optional HTTP transport. It never adopts a document or persists credentials.
|
||||
use oa_core::{
|
||||
Id, Project,
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle},
|
||||
};
|
||||
use reqwest::{Client, Method, Url, header};
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use std::{fmt, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
// The service permits two 8 MiB representations plus envelope framing. Keep
|
||||
// transport/parser budgets finite even when connecting to a misconfigured host.
|
||||
const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024;
|
||||
const MAX_PROBLEM_BYTES: usize = 256 * 1024;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(40);
|
||||
const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
|
||||
max_files: 256,
|
||||
max_include_depth: 32,
|
||||
max_include_edges: 1_024,
|
||||
model: oa_core::language::ParseLimits {
|
||||
max_source_bytes: 8 * 1024 * 1024,
|
||||
max_graph_objects: 5_000,
|
||||
max_records: 20_000,
|
||||
max_step_references: 50_000,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Secret(pub String);
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Secret([redacted])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Connection {
|
||||
base: Url,
|
||||
client: Client,
|
||||
token: Secret,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Connection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Do not expose headers, credentials, or user-supplied URL text.
|
||||
f.debug_struct("Connection").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Remote {
|
||||
pub resource_id: Id,
|
||||
pub revision: u64,
|
||||
pub etag: String,
|
||||
pub compiled: Arc<CompiledSourceProject>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Catalog {
|
||||
pub items: Vec<Summary>,
|
||||
pub next_after: Option<Id>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Summary {
|
||||
pub resource_id: Id,
|
||||
pub revision: u64,
|
||||
pub model_id: Id,
|
||||
pub name: String,
|
||||
pub elements: usize,
|
||||
pub relations: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Failure {
|
||||
pub detail: String,
|
||||
pub code: String,
|
||||
pub status: Option<u16>,
|
||||
/// A mutation may have committed. Read current state before another write.
|
||||
pub uncertain: bool,
|
||||
/// Publication was acknowledged, but its accepted source could not be read.
|
||||
pub published: Option<Id>,
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn local(code: &str, detail: &str) -> Self {
|
||||
Self {
|
||||
detail: detail.into(),
|
||||
code: code.into(),
|
||||
status: None,
|
||||
uncertain: false,
|
||||
published: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Failure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Failure {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
resource_id: Id,
|
||||
revision: u64,
|
||||
model: Project,
|
||||
source_project: SourceBundle,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AcceptedSource {
|
||||
revision: u64,
|
||||
source_project: SourceBundle,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Problem {
|
||||
code: String,
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
issues: Vec<ProblemIssue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProblemIssue {
|
||||
message: String,
|
||||
path: Option<String>,
|
||||
line: Option<usize>,
|
||||
column: Option<usize>,
|
||||
}
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
etag: Option<String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// `base` is the service root, optionally beneath a reverse-proxy path.
|
||||
/// Requests append `api/v1/`; credentials belong in `token`, never the URL.
|
||||
pub fn new(base: &str, token: Secret) -> Result<Self, Failure> {
|
||||
Self::with_timeout(base, token, REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
|
||||
let invalid = || {
|
||||
Failure::local(
|
||||
"invalid_endpoint",
|
||||
"Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.",
|
||||
)
|
||||
};
|
||||
let base = base.trim();
|
||||
if base.contains('\\')
|
||||
|| base.chars().any(char::is_control)
|
||||
|| base.split_once("://").is_some_and(|(_, rest)| {
|
||||
rest.split(['/', '?', '#'])
|
||||
.next()
|
||||
.is_some_and(|authority| authority.contains('@'))
|
||||
})
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut base = Url::parse(base).map_err(|_| invalid())?;
|
||||
let host = base.host_str().ok_or_else(invalid)?;
|
||||
let loopback = host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_matches(['[', ']'])
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback());
|
||||
if !(base.scheme() == "https" || (base.scheme() == "http" && loopback))
|
||||
|| !base.username().is_empty()
|
||||
|| base.password().is_some()
|
||||
|| base.query().is_some()
|
||||
|| base.fragment().is_some()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if !base.path().ends_with('/') {
|
||||
// set_path accepts the existing URL's encoded path without decoding
|
||||
// it; this preserves an optional reverse-proxy prefix exactly.
|
||||
base.set_path(&format!("{}/", base.path()));
|
||||
}
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
header::ACCEPT,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if !token.0.is_empty() {
|
||||
if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) {
|
||||
return Err(Failure::local(
|
||||
"invalid_token",
|
||||
"The bearer token must contain printable ASCII without whitespace.",
|
||||
));
|
||||
}
|
||||
let mut value =
|
||||
header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| {
|
||||
Failure::local(
|
||||
"invalid_token",
|
||||
"The bearer token is not a valid HTTP credential.",
|
||||
)
|
||||
})?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert(header::AUTHORIZATION, value);
|
||||
}
|
||||
let mut builder = Client::builder()
|
||||
.default_headers(headers)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.retry(reqwest::retry::never())
|
||||
.connect_timeout(Duration::from_secs(10).min(timeout))
|
||||
.timeout(timeout)
|
||||
.user_agent("Arcadia-Studio/hosted-desktop");
|
||||
if loopback {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
let client = builder.build().map_err(|_| {
|
||||
Failure::local(
|
||||
"client_unavailable",
|
||||
"The HTTP client could not be initialized.",
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
base,
|
||||
client,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base(&self) -> &str {
|
||||
self.base.as_str()
|
||||
}
|
||||
|
||||
fn endpoint(&self, path: &str) -> Url {
|
||||
// All path suffixes are literals or UUIDs, never user-provided paths.
|
||||
self.base
|
||||
.join(&format!("api/v1/{path}"))
|
||||
.expect("validated hierarchical base")
|
||||
}
|
||||
|
||||
fn redact(&self, text: String) -> String {
|
||||
let text = if self.token.0.is_empty() {
|
||||
text
|
||||
} else {
|
||||
text.replace(&self.token.0, "[redacted]")
|
||||
};
|
||||
// Limit user-visible diagnostics independently of the response budget.
|
||||
text.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.take(8_192)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure {
|
||||
Failure {
|
||||
detail: detail.into(),
|
||||
code: "invalid_response".into(),
|
||||
status: Some(reply.status),
|
||||
uncertain: mutation,
|
||||
published: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
|
||||
serde_json::from_slice(&reply.body).map_err(|_| {
|
||||
self.invalid_reply(
|
||||
reply,
|
||||
mutation,
|
||||
"The service returned an invalid JSON response. Local work is unchanged.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn etag(&self, reply: &Reply, mutation: bool) -> Result<String, Failure> {
|
||||
reply
|
||||
.etag
|
||||
.as_deref()
|
||||
.filter(|value| valid_etag(value))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| {
|
||||
self.invalid_reply(
|
||||
reply,
|
||||
mutation,
|
||||
"The service did not return a valid strong ETag. Local work is unchanged.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
bundle: Option<SourceBundle>,
|
||||
etag: Option<&str>,
|
||||
) -> Result<Reply, Failure> {
|
||||
let mutation = method != Method::GET;
|
||||
let expected_status = if method == Method::POST { 201 } else { 200 };
|
||||
let mut request = self.client.request(method, url);
|
||||
if let Some(bundle) = bundle {
|
||||
let bytes = serde_json::to_vec(&bundle).map_err(|_| {
|
||||
Failure::local(
|
||||
"invalid_source_project",
|
||||
"The source project could not be encoded for the service.",
|
||||
)
|
||||
})?;
|
||||
if bytes.len() > 8 * 1024 * 1024 {
|
||||
return Err(Failure::local(
|
||||
"request_too_large",
|
||||
"The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.",
|
||||
));
|
||||
}
|
||||
request = request
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(bytes);
|
||||
}
|
||||
if let Some(etag) = etag {
|
||||
if !valid_etag(etag) {
|
||||
return Err(Failure::local(
|
||||
"invalid_etag",
|
||||
"A captured strong server ETag is required to save. Check the server version first.",
|
||||
));
|
||||
}
|
||||
request = request.header(header::IF_MATCH, etag);
|
||||
}
|
||||
let mut response = request.send().await.map_err(|error| Failure {
|
||||
detail: if error.is_timeout() {
|
||||
"The service request timed out. Check the server before retrying a write."
|
||||
} else {
|
||||
"The service could not be reached or the connection was interrupted. Check the URL, service and credentials."
|
||||
}.into(),
|
||||
code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(),
|
||||
status: None,
|
||||
uncertain: mutation && !error.is_connect() && !error.is_builder(),
|
||||
published: None,
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let limit = if response.status().is_success() {
|
||||
MAX_RESPONSE_BYTES
|
||||
} else {
|
||||
MAX_PROBLEM_BYTES
|
||||
};
|
||||
let uncertain =
|
||||
mutation && (response.status().is_success() || response.status().is_server_error());
|
||||
let oversized = || Failure {
|
||||
detail:
|
||||
"The service response exceeds the desktop transport limit. Local work is unchanged."
|
||||
.into(),
|
||||
code: "response_too_large".into(),
|
||||
status: Some(status),
|
||||
uncertain,
|
||||
published: None,
|
||||
};
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > limit as u64)
|
||||
{
|
||||
return Err(oversized());
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| Failure {
|
||||
detail:
|
||||
"The service response was interrupted. Check the server before retrying a write."
|
||||
.into(),
|
||||
code: "response_interrupted".into(),
|
||||
status: Some(status),
|
||||
uncertain,
|
||||
published: None,
|
||||
})? {
|
||||
if chunk.len() > limit.saturating_sub(body.len()) {
|
||||
return Err(oversized());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
if status != expected_status {
|
||||
let mut failure = Failure {
|
||||
detail: format!("The service returned HTTP {status}. Local work is unchanged."),
|
||||
code: if (300..400).contains(&status) {
|
||||
"redirect_refused"
|
||||
} else {
|
||||
"http_error"
|
||||
}
|
||||
.into(),
|
||||
status: Some(status),
|
||||
uncertain,
|
||||
published: None,
|
||||
};
|
||||
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
|
||||
failure.uncertain |= mutation && problem.code == "outcome_unknown";
|
||||
// These service failures explicitly precede admission/commit.
|
||||
// Other 5xx responses remain uncertain, including storage I/O.
|
||||
if matches!(
|
||||
(status, problem.code.as_str()),
|
||||
(503, "overloaded") | (507, "store_capacity")
|
||||
) {
|
||||
failure.uncertain = false;
|
||||
}
|
||||
if !problem.code.is_empty()
|
||||
&& problem.code.len() <= 80
|
||||
&& problem
|
||||
.code
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
failure.code = self.redact(problem.code);
|
||||
}
|
||||
let mut detail = problem.detail;
|
||||
for issue in problem.issues.into_iter().take(20) {
|
||||
detail.push('\n');
|
||||
if let Some(path) = issue.path {
|
||||
detail.push_str(&path);
|
||||
if let Some(line) = issue.line {
|
||||
detail.push_str(&format!(":{line}"));
|
||||
if let Some(column) = issue.column {
|
||||
detail.push_str(&format!(":{column}"));
|
||||
}
|
||||
}
|
||||
detail.push_str(": ");
|
||||
}
|
||||
detail.push_str(&issue.message);
|
||||
}
|
||||
failure.detail = self.redact(detail);
|
||||
}
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(Reply { status, etag, body })
|
||||
}
|
||||
|
||||
pub async fn catalog(&self, query: &str, after: Option<Id>) -> Result<Catalog, Failure> {
|
||||
let mut url = self.endpoint("projects");
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.append_pair("limit", "100");
|
||||
if !query.is_empty() {
|
||||
pairs.append_pair("q", query);
|
||||
}
|
||||
if let Some(after) = after {
|
||||
pairs.append_pair("after", &after.to_string());
|
||||
}
|
||||
}
|
||||
let reply = self.request(Method::GET, url, None, None).await?;
|
||||
let catalog: Catalog = self.decode(&reply, false)?;
|
||||
let mut previous = after;
|
||||
for item in &catalog.items {
|
||||
if item.resource_id.is_nil()
|
||||
|| item.model_id.is_nil()
|
||||
|| item.revision == 0
|
||||
|| previous.is_some_and(|id| item.resource_id <= id)
|
||||
{
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The service returned an invalid project catalog.",
|
||||
));
|
||||
}
|
||||
previous = Some(item.resource_id);
|
||||
}
|
||||
if catalog.items.len() > 100
|
||||
|| catalog
|
||||
.next_after
|
||||
.is_some_and(|id| Some(id) != previous || catalog.items.is_empty())
|
||||
{
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The service returned invalid catalog pagination.",
|
||||
));
|
||||
}
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub async fn open(&self, id: Id) -> Result<Remote, Failure> {
|
||||
let reply = self
|
||||
.request(
|
||||
Method::GET,
|
||||
self.endpoint(&format!("projects/{id}")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let envelope: Envelope = self.decode(&reply, false)?;
|
||||
let etag = self.etag(&reply, false)?;
|
||||
if envelope.resource_id != id
|
||||
|| id.is_nil()
|
||||
|| envelope.revision == 0
|
||||
|| envelope.model.id.is_nil()
|
||||
{
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The service returned a different project identity or invalid revision.",
|
||||
));
|
||||
}
|
||||
let compiled = self.compile(envelope.source_project, &reply, false).await?;
|
||||
if compiled.decoded.project != envelope.model {
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The hosted source and model disagree. The document was not opened.",
|
||||
));
|
||||
}
|
||||
Ok(Remote {
|
||||
resource_id: id,
|
||||
revision: envelope.revision,
|
||||
etag,
|
||||
compiled,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn publish(&self, bundle: SourceBundle) -> Result<Remote, Failure> {
|
||||
let reply = self
|
||||
.request(
|
||||
Method::POST,
|
||||
self.endpoint("projects/import-source-project"),
|
||||
Some(bundle),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let receipt: Summary = self.decode(&reply, true)?;
|
||||
if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 {
|
||||
return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again."));
|
||||
}
|
||||
let published = Some(receipt.resource_id);
|
||||
let etag = self.etag(&reply, true).map_err(|mut failure| {
|
||||
failure.published = published;
|
||||
failure
|
||||
})?;
|
||||
let remote = self.open(receipt.resource_id).await.map_err(|mut failure| {
|
||||
failure.uncertain = true;
|
||||
failure.published = published;
|
||||
failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail);
|
||||
failure
|
||||
})?;
|
||||
if remote.revision != receipt.revision
|
||||
|| remote.etag != etag
|
||||
|| remote.compiled.decoded.project.id != receipt.model_id
|
||||
{
|
||||
let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again.");
|
||||
failure.published = published;
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result<Remote, Failure> {
|
||||
let reply = self
|
||||
.request(
|
||||
Method::PUT,
|
||||
self.endpoint(&format!("projects/{id}/source-project")),
|
||||
Some(bundle),
|
||||
Some(etag),
|
||||
)
|
||||
.await?;
|
||||
let accepted: AcceptedSource = self.decode(&reply, true)?;
|
||||
let etag = self.etag(&reply, true)?;
|
||||
if id.is_nil() || accepted.revision == 0 {
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
true,
|
||||
"The service returned an invalid saved revision.",
|
||||
));
|
||||
}
|
||||
let compiled = self.compile(accepted.source_project, &reply, true).await?;
|
||||
Ok(Remote {
|
||||
resource_id: id,
|
||||
revision: accepted.revision,
|
||||
etag,
|
||||
compiled,
|
||||
})
|
||||
}
|
||||
|
||||
async fn compile(
|
||||
&self,
|
||||
bundle: SourceBundle,
|
||||
reply: &Reply,
|
||||
mutation: bool,
|
||||
) -> Result<Arc<CompiledSourceProject>, Failure> {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
|
||||
Ok::<_, Vec<source_project::SourceDiagnostic>>((compiled, bundle))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
self.invalid_reply(
|
||||
reply,
|
||||
mutation,
|
||||
"The hosted source could not be checked. Local work is unchanged.",
|
||||
)
|
||||
})?;
|
||||
match result {
|
||||
Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)),
|
||||
Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")),
|
||||
Err(issues) => {
|
||||
let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged.");
|
||||
for issue in issues.into_iter().take(10) {
|
||||
failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message));
|
||||
}
|
||||
failure.detail = self.redact(failure.detail);
|
||||
Err(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_etag(value: &str) -> bool {
|
||||
(2..=1_024).contains(&value.len())
|
||||
&& value.starts_with('"')
|
||||
&& value.ends_with('"')
|
||||
&& value.as_bytes()[1..value.len() - 1]
|
||||
.iter()
|
||||
.all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "client_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,609 @@
|
||||
use super::*;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
struct TestServer {
|
||||
base: String,
|
||||
stop: Option<oneshot::Sender<()>>,
|
||||
task: JoinHandle<()>,
|
||||
_directory: Option<tempfile::TempDir>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
async fn fixture(router: Router) -> Self {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (stop, done) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self {
|
||||
base,
|
||||
stop: Some(stop),
|
||||
task,
|
||||
_directory: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn production(token: Option<String>, prefix: bool) -> Self {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let store = Arc::new(
|
||||
tokio::task::spawn_blocking(move || oa_server::store::Store::open(path))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let router = oa_server::app(store, token);
|
||||
let router = if prefix {
|
||||
Router::new().nest("/teams/engineering", router)
|
||||
} else {
|
||||
router
|
||||
};
|
||||
let mut server = Self::fixture(router).await;
|
||||
server._directory = Some(directory);
|
||||
if prefix {
|
||||
server.base.push_str("/teams/engineering");
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
fn connection(&self) -> Connection {
|
||||
Connection::new(&self.base, Secret::default()).unwrap()
|
||||
}
|
||||
|
||||
async fn finish(mut self) {
|
||||
self.stop.take().unwrap().send(()).unwrap();
|
||||
(&mut self.task).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn authored() -> SourceBundle {
|
||||
let bundle = SourceBundle {
|
||||
entry: "harbor.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()),
|
||||
("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()),
|
||||
]),
|
||||
};
|
||||
source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||
.unwrap()
|
||||
.bundle
|
||||
}
|
||||
|
||||
fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value {
|
||||
let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project;
|
||||
json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() {
|
||||
for base in [
|
||||
"http://example.com",
|
||||
"http://192.168.1.4",
|
||||
"ftp://localhost",
|
||||
"not a url",
|
||||
"https://user:secret@example.com",
|
||||
"https://@example.com",
|
||||
"https://user@example.com",
|
||||
"https://example.com/?token=secret",
|
||||
"https://example.com/#secret",
|
||||
"https://example.com\\hidden",
|
||||
"https://example.com/\nsecret",
|
||||
] {
|
||||
let failure = Connection::new(base, Secret("secret".into())).unwrap_err();
|
||||
assert_eq!(failure.code, "invalid_endpoint");
|
||||
assert!(!failure.detail.contains("secret"));
|
||||
assert!(!failure.uncertain);
|
||||
}
|
||||
for base in [
|
||||
"http://localhost:8787",
|
||||
"http://127.0.0.2:8787",
|
||||
"http://[::1]:8787",
|
||||
"https://example.com/prefix",
|
||||
] {
|
||||
assert!(Connection::new(base, Secret::default()).is_ok(), "{base}");
|
||||
}
|
||||
let client = Connection::new(
|
||||
"https://example.com/teams/space%20name",
|
||||
Secret("private-token".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(client.base(), "https://example.com/teams/space%20name/");
|
||||
assert_eq!(
|
||||
client.endpoint("projects").as_str(),
|
||||
"https://example.com/teams/space%20name/api/v1/projects"
|
||||
);
|
||||
assert!(!format!("{client:?}").contains("private-token"));
|
||||
assert_eq!(
|
||||
format!("{:?}", Secret("private-token".into())),
|
||||
"Secret([redacted])"
|
||||
);
|
||||
assert_eq!(
|
||||
Connection::new("http://localhost", Secret("bad\nheader".into()))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"invalid_token"
|
||||
);
|
||||
for etag in [
|
||||
"*",
|
||||
"W/\"weak\"",
|
||||
"unquoted",
|
||||
"\"one\",\"two\"",
|
||||
"\"new\nline\"",
|
||||
] {
|
||||
assert!(!valid_etag(etag), "{etag}");
|
||||
}
|
||||
assert!(valid_etag("\"opaque:tag/+,=?\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() {
|
||||
let server = TestServer::production(None, false).await;
|
||||
let client = server.connection();
|
||||
assert!(client.catalog("", None).await.unwrap().items.is_empty());
|
||||
let bundle = authored();
|
||||
let original = client.publish(bundle.clone()).await.unwrap();
|
||||
assert_ne!(original.resource_id, original.compiled.decoded.project.id);
|
||||
assert_eq!(original.compiled.bundle, bundle);
|
||||
assert_eq!(original.revision, 1);
|
||||
let catalog = client.catalog("Harbor &", None).await.unwrap();
|
||||
assert_eq!(catalog.items.len(), 1);
|
||||
assert_eq!(catalog.items[0].resource_id, original.resource_id);
|
||||
assert_eq!(
|
||||
catalog.items[0].model_id,
|
||||
original.compiled.decoded.project.id
|
||||
);
|
||||
assert_eq!(catalog.items[0].name, "Harbor & rescue");
|
||||
assert_eq!(
|
||||
(catalog.items[0].elements, catalog.items[0].relations),
|
||||
(2, 0)
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.catalog("?q=missing&limit=1000", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.catalog("", Some(original.resource_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
let same = client
|
||||
.save(original.resource_id, &original.etag, bundle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(same.etag, original.etag);
|
||||
assert_eq!(same.revision, original.revision);
|
||||
let mut changed = bundle.clone();
|
||||
changed
|
||||
.files
|
||||
.get_mut("work/operations.arcadia")
|
||||
.unwrap()
|
||||
.push_str("// Reviewed by the team\r\n");
|
||||
let saved = client
|
||||
.save(original.resource_id, &original.etag, changed.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.resource_id, original.resource_id);
|
||||
assert_eq!(saved.revision, 2);
|
||||
assert_ne!(saved.etag, original.etag);
|
||||
assert_eq!(saved.compiled.bundle, changed);
|
||||
assert_eq!(
|
||||
saved.compiled.decoded.project,
|
||||
original.compiled.decoded.project
|
||||
);
|
||||
let conflict = client
|
||||
.save(original.resource_id, &original.etag, bundle)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(conflict.status, Some(412));
|
||||
assert_eq!(conflict.code, "revision_conflict");
|
||||
assert!(!conflict.uncertain);
|
||||
assert_eq!(
|
||||
client
|
||||
.open(original.resource_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.compiled
|
||||
.bundle,
|
||||
changed
|
||||
);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_production_auth_proxy_prefix_and_located_rejection() {
|
||||
let token = "hosted-test-token-with-at-least-32-characters";
|
||||
let server = TestServer::production(Some(token.into()), true).await;
|
||||
let unauthorized = server.connection().catalog("", None).await.unwrap_err();
|
||||
assert_eq!(
|
||||
(unauthorized.status, unauthorized.code.as_str()),
|
||||
(Some(401), "unauthorized")
|
||||
);
|
||||
let client = Connection::new(&server.base, Secret(token.into())).unwrap();
|
||||
let original = client.publish(authored()).await.unwrap();
|
||||
assert_eq!(
|
||||
client.catalog("Harbor &", None).await.unwrap().items.len(),
|
||||
1
|
||||
);
|
||||
let mut invalid = original.compiled.bundle.clone();
|
||||
invalid
|
||||
.files
|
||||
.get_mut("work/operations.arcadia")
|
||||
.unwrap()
|
||||
.push_str("this is not valid source;");
|
||||
let rejected = client
|
||||
.save(original.resource_id, &original.etag, invalid)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(rejected.status, Some(422));
|
||||
assert!(!rejected.uncertain);
|
||||
assert!(rejected.detail.contains("work/operations.arcadia:"));
|
||||
assert_eq!(
|
||||
client.open(original.resource_id).await.unwrap().etag,
|
||||
original.etag
|
||||
);
|
||||
let missing = client.open(Id::new_v4()).await.unwrap_err();
|
||||
assert_eq!(missing.status, Some(404));
|
||||
assert!(!missing.uncertain);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() {
|
||||
let redirected = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&redirected);
|
||||
let destination = TestServer::fixture(Router::new().fallback(move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
StatusCode::OK
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let location = destination.base.clone();
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let seen = Arc::clone(&attempts);
|
||||
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
|
||||
let location = location.clone();
|
||||
let seen = Arc::clone(&seen);
|
||||
async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) }
|
||||
})).route("/api/v1/projects", get(|| async {
|
||||
(StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]})))
|
||||
}))).await;
|
||||
let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap();
|
||||
let failure = client.publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.code, "redirect_refused");
|
||||
assert!(!failure.uncertain);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(redirected.load(Ordering::SeqCst), 0);
|
||||
let failure = client.catalog("", None).await.unwrap_err();
|
||||
assert!(failure.detail.contains("[redacted]"));
|
||||
assert!(!format!("{failure:?}").contains("private-test-token"));
|
||||
server.finish().await;
|
||||
destination.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() {
|
||||
let id = Id::new_v4();
|
||||
let stable = authored();
|
||||
let mut mismatched = envelope(stable.clone(), id, 1);
|
||||
mismatched["model"]["name"] = json!("different model");
|
||||
let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1);
|
||||
// Keep this independently useful even if the model's schema changes.
|
||||
wrong_id["revision"] = json!(1);
|
||||
let mut unstable = envelope(stable.clone(), id, 1);
|
||||
unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}});
|
||||
for (body, etag) in [
|
||||
(mismatched, Some("\"opaque\"")),
|
||||
(wrong_id, Some("\"opaque\"")),
|
||||
(envelope(stable.clone(), id, 0), Some("\"opaque\"")),
|
||||
(envelope(stable, id, 1), None),
|
||||
(unstable, Some("\"opaque\"")),
|
||||
] {
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/{id}",
|
||||
get(move || {
|
||||
let body = body.clone();
|
||||
async move {
|
||||
let mut response = Json(body).into_response();
|
||||
if let Some(etag) = etag {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::ETAG, etag.parse().unwrap());
|
||||
}
|
||||
response
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
let failure = server.connection().open(id).await.unwrap_err();
|
||||
assert_eq!(failure.code, "invalid_response");
|
||||
assert!(!failure.uncertain);
|
||||
server.finish().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() {
|
||||
let id = Id::new_v4();
|
||||
let etag = "\"opaque:tag/+,=?\"";
|
||||
let bundle = authored();
|
||||
let response_bundle = bundle.clone();
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/{id}/source-project",
|
||||
put(
|
||||
move |headers: header::HeaderMap, Json(body): Json<SourceBundle>| {
|
||||
let expected_bundle = response_bundle.clone();
|
||||
async move {
|
||||
assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag);
|
||||
assert_eq!(body, expected_bundle);
|
||||
(
|
||||
[(header::ETAG, "\"accepted:new-tag\"")],
|
||||
Json(json!({"revision":8,"changed":false,"source_project":body})),
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
))
|
||||
.await;
|
||||
let client = server.connection();
|
||||
let saved = client.save(id, etag, bundle.clone()).await.unwrap();
|
||||
assert_eq!(saved.etag, "\"accepted:new-tag\"");
|
||||
assert_eq!(saved.resource_id, id);
|
||||
assert_eq!(saved.revision, 8);
|
||||
assert_eq!(saved.compiled.bundle, bundle.clone());
|
||||
assert_eq!(
|
||||
client.save(id, "*", bundle).await.unwrap_err().code,
|
||||
"invalid_etag"
|
||||
);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_publication_retains_known_resource_after_failed_followup() {
|
||||
let id = Id::new_v4();
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&attempts);
|
||||
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
(StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0})))
|
||||
}
|
||||
})).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await;
|
||||
let failure = server.connection().publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.published, Some(id));
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(failure.status, Some(503));
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
/// Reads the entire request before simulating a lost/malformed response. The
|
||||
/// counter proves the production client never repeats an admitted publication.
|
||||
async fn raw_fixture(reply: Vec<u8>) -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&attempts);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0; 4096];
|
||||
loop {
|
||||
let read = stream.read(&mut buffer).await.unwrap();
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("content-length:")
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = stream.write_all(&reply).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
});
|
||||
(base, attempts, task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_lost_publication_response_is_uncertain_without_retry() {
|
||||
let (base, attempts, task) = raw_fixture(Vec::new()).await;
|
||||
let client = Connection::new(&base, Secret::default()).unwrap();
|
||||
let failure = client.publish(authored()).await.unwrap_err();
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(failure.published, None);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
|
||||
let declared = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
MAX_RESPONSE_BYTES + 1
|
||||
)
|
||||
.into_bytes();
|
||||
let mut chunked =
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec();
|
||||
let chunk = vec![b' '; 64 * 1024];
|
||||
for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) {
|
||||
chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
|
||||
chunked.extend_from_slice(&chunk);
|
||||
chunked.extend_from_slice(b"\r\n");
|
||||
}
|
||||
chunked.extend_from_slice(b"0\r\n\r\n");
|
||||
for reply in [declared, chunked] {
|
||||
let (base, _, task) = raw_fixture(reply).await;
|
||||
let failure = Connection::new(&base, Secret::default())
|
||||
.unwrap()
|
||||
.catalog("", None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.code, "response_too_large");
|
||||
assert!(!failure.uncertain);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
|
||||
let received = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&received);
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/import-source-project",
|
||||
post(move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
StatusCode::CREATED
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
let client =
|
||||
Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30))
|
||||
.unwrap();
|
||||
let failure = client.publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.code, "timeout");
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(received.load(Ordering::SeqCst), 1);
|
||||
let base = server.base.clone();
|
||||
server.finish().await;
|
||||
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.catalog("", None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure.code.as_str(),
|
||||
"connection_failed" | "timeout"
|
||||
));
|
||||
assert!(!failure.uncertain);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_publication_race_keeps_receipt_identity_for_review() {
|
||||
let id = Id::new_v4();
|
||||
let bundle = authored();
|
||||
let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project
|
||||
.id;
|
||||
let mut changed = bundle.clone();
|
||||
changed
|
||||
.files
|
||||
.get_mut("work/operations.arcadia")
|
||||
.unwrap()
|
||||
.push_str("// Concurrent external review\r\n");
|
||||
let head = envelope(changed, id, 2);
|
||||
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move {
|
||||
(StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0})))
|
||||
})).route("/api/v1/projects/{id}", get(move || {
|
||||
let head = head.clone();
|
||||
async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) }
|
||||
}))).await;
|
||||
let failure = server.connection().publish(bundle).await.unwrap_err();
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(failure.published, Some(id));
|
||||
assert!(failure.detail.contains("changed before"));
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() {
|
||||
for (status, code, uncertain) in [
|
||||
(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false),
|
||||
(StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false),
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", false),
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true),
|
||||
(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true),
|
||||
] {
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/import-source-project",
|
||||
post(move || async move {
|
||||
(
|
||||
status,
|
||||
Json(json!({"code":code,"detail":"Service-specific actionable detail"})),
|
||||
)
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
let failure = server.connection().publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.status, Some(status.as_u16()));
|
||||
assert_eq!(failure.code, code);
|
||||
assert_eq!(failure.uncertain, uncertain, "{code}");
|
||||
assert_eq!(failure.detail, "Service-specific actionable detail");
|
||||
server.finish().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() {
|
||||
let mut bundle = authored();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str(&" ".repeat(8 * 1024 * 1024));
|
||||
let failure = Connection::new("http://127.0.0.1:1", Secret::default())
|
||||
.unwrap()
|
||||
.publish(bundle)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.code, "request_too_large");
|
||||
assert!(!failure.uncertain);
|
||||
assert!(failure.published.is_none());
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Optional hosted transport state. The Document remains the local editing history.
|
||||
pub mod client;
|
||||
pub mod view;
|
||||
|
||||
use client::{Catalog, Connection, Failure, Remote, Secret, Summary};
|
||||
use oa_core::{Id, source_project::CompiledSourceProject};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Show,
|
||||
Close,
|
||||
Base(String),
|
||||
Token(Secret),
|
||||
Query(String),
|
||||
Connect,
|
||||
More,
|
||||
Select(Id),
|
||||
OpenSelected,
|
||||
Publish,
|
||||
Save,
|
||||
Check,
|
||||
Reload,
|
||||
ReviewFile(String),
|
||||
ReviewAction(iced::widget::text_editor::Action),
|
||||
ReviewBack,
|
||||
Local,
|
||||
Forget,
|
||||
Completed(Id, Id, Result<Outcome, Failure>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
Remote(Remote),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Operation {
|
||||
Catalog(bool),
|
||||
Open(Id),
|
||||
Publish,
|
||||
Save,
|
||||
Check,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub connection: Connection,
|
||||
pub resource_id: Id,
|
||||
pub revision: u64,
|
||||
pub etag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub id: Id,
|
||||
pub document: Id,
|
||||
pub revision: u64,
|
||||
pub operation: Operation,
|
||||
pub connection: Connection,
|
||||
pub resource_id: Option<Id>,
|
||||
pub etag: Option<String>,
|
||||
pub snapshot: Arc<CompiledSourceProject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Review {
|
||||
pub connection: Connection,
|
||||
pub remote: Remote,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub visible: bool,
|
||||
pub base: String,
|
||||
pub token: Secret,
|
||||
pub query: String,
|
||||
pub catalog: Vec<Summary>,
|
||||
pub catalog_stale: bool,
|
||||
pub next: Option<Id>,
|
||||
pub selected: Option<Id>,
|
||||
pub connection: Option<Connection>,
|
||||
pub session: Option<Session>,
|
||||
pub request: Option<Request>,
|
||||
pub uncertain: Option<Request>,
|
||||
pub review: Option<Review>,
|
||||
pub review_path: Option<String>,
|
||||
pub review_editor: iced::widget::text_editor::Content,
|
||||
pub error: Option<Failure>,
|
||||
pub open_target: Option<(Connection, Id)>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
visible: false,
|
||||
base: "http://127.0.0.1:8787".into(),
|
||||
token: Secret::default(),
|
||||
query: String::new(),
|
||||
catalog: vec![],
|
||||
catalog_stale: true,
|
||||
next: None,
|
||||
selected: None,
|
||||
connection: None,
|
||||
session: None,
|
||||
request: None,
|
||||
uncertain: None,
|
||||
review: None,
|
||||
review_path: None,
|
||||
review_editor: iced::widget::text_editor::Content::new(),
|
||||
error: None,
|
||||
open_target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn invalidate_catalog(&mut self) {
|
||||
self.catalog_stale = true;
|
||||
self.next = None;
|
||||
}
|
||||
|
||||
pub fn catalog_message(&self) -> Option<&'static str> {
|
||||
if self.connection.is_none() {
|
||||
Some("Connect / refresh to load models for this service.")
|
||||
} else if self.catalog_stale {
|
||||
Some("Catalog may be out of date. Connect / refresh to update these results.")
|
||||
} else if self.catalog.is_empty() {
|
||||
Some("No models found. Publish the current model or change the search.")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep explicit connection settings in memory, but never carry a resource
|
||||
/// binding or in-flight result into another local document.
|
||||
pub fn leave_document(&mut self) {
|
||||
self.visible = false;
|
||||
self.session = None;
|
||||
self.request = None;
|
||||
self.uncertain = None;
|
||||
self.review = None;
|
||||
self.review_path = None;
|
||||
self.review_editor = iced::widget::text_editor::Content::new();
|
||||
self.error = None;
|
||||
self.open_target = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Hosted dialog. Main-header integration is a small caller-owned hook.
|
||||
use super::{Event, Operation, client::Secret};
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
style,
|
||||
views::{action, label},
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill, Font,
|
||||
widget::{self, button, column, container, row, scrollable, text, text_editor, text_input},
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||
if !app.hosted.visible
|
||||
|| app.pending.is_some()
|
||||
|| app.file_prompt.is_some()
|
||||
|| app.workflow.is_some()
|
||||
|| app.delete_pending
|
||||
{
|
||||
return base;
|
||||
}
|
||||
widget::stack![
|
||||
base,
|
||||
widget::opaque(
|
||||
container(
|
||||
container(dialog(app))
|
||||
.padding(22)
|
||||
.width((app.window_size.width - 64.).clamp(680., 1000.))
|
||||
.height((app.window_size.height - 80.).max(400.))
|
||||
.style(style::card)
|
||||
)
|
||||
.center(Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
fn control<'a>(title: &'a str, event: Event, enabled: bool) -> widget::Button<'a, Message> {
|
||||
button(text(title).size(12))
|
||||
.padding([9, 12])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(enabled.then_some(Message::Hosted(event)))
|
||||
}
|
||||
|
||||
fn dialog(app: &App) -> Element<'_, Message> {
|
||||
let state = &app.hosted;
|
||||
let enabled = !app.busy;
|
||||
if let Some(path) = &state.review_path {
|
||||
return column![
|
||||
text("Server source - read only").size(22),
|
||||
text(path).size(13),
|
||||
text_editor(&state.review_editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(13)
|
||||
.height(Fill)
|
||||
.on_action(|action| Message::Hosted(Event::ReviewAction(action))),
|
||||
control("Back to review", Event::ReviewBack, enabled),
|
||||
]
|
||||
.spacing(12)
|
||||
.into();
|
||||
}
|
||||
let mut content = column![
|
||||
label("SERVICE ADDRESS"),
|
||||
text_input("http://127.0.0.1:8787", &state.base)
|
||||
.padding(10)
|
||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
|
||||
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
|
||||
text_input("Optional for a loopback service", &state.token.0)
|
||||
.secure(true)
|
||||
.padding(10)
|
||||
.on_input_maybe(
|
||||
enabled.then_some(|value| Message::Hosted(Event::Token(Secret(value))))
|
||||
),
|
||||
row![
|
||||
control("Connect / refresh", Event::Connect, enabled),
|
||||
control(
|
||||
"Forget connection",
|
||||
Event::Forget,
|
||||
enabled && state.session.is_none()
|
||||
),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(8);
|
||||
if let Some(session) = &state.session {
|
||||
content = content.push(container(column![
|
||||
text("Current hosted model").size(16),
|
||||
text(session.connection.base()).size(12),
|
||||
text(format!("Resource {} - revision {}", session.resource_id, session.revision)).size(12),
|
||||
label("Edits and Undo stay local. Save sends the complete authored source project."),
|
||||
row![
|
||||
control("Check server", Event::Check, enabled),
|
||||
control("Save as local...", Event::Local, enabled),
|
||||
].spacing(8),
|
||||
].spacing(8)).padding(12).style(style::panel));
|
||||
}
|
||||
if let Some(error) = &state.error {
|
||||
content = content.push(
|
||||
container(
|
||||
column![
|
||||
text(if error.uncertain {
|
||||
"The write outcome is unknown"
|
||||
} else {
|
||||
"Hosted action needs attention"
|
||||
})
|
||||
.size(16),
|
||||
text(&error.detail).size(13),
|
||||
label("Your local model and source history are retained."),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::panel),
|
||||
);
|
||||
}
|
||||
if let Some(failed) = &state.uncertain {
|
||||
if failed.operation == Operation::Publish {
|
||||
content = content.push(text("A previous publication may already exist. Inspect the catalog before publishing another copy.").size(13));
|
||||
}
|
||||
if failed.resource_id.is_some() {
|
||||
content = content.push(control("Check previous write", Event::Check, enabled));
|
||||
}
|
||||
}
|
||||
if let Some(review) = &state.review {
|
||||
let remote = &review.remote;
|
||||
let local = &app.document.bundle().files;
|
||||
let server = &remote.compiled.bundle.files;
|
||||
let paths: BTreeSet<_> = local.keys().chain(server.keys()).collect();
|
||||
let mut changes = column![
|
||||
text(format!("Review server revision {}", remote.revision)).size(17),
|
||||
label(format!("{} - resource {}", review.connection.base(), remote.resource_id)),
|
||||
label("Reload replaces accepted local content as one undoable change. Unapplied drafts are checked first."),
|
||||
].spacing(8);
|
||||
if app.document.bundle().entry != remote.compiled.bundle.entry {
|
||||
changes = changes.push(
|
||||
text(format!(
|
||||
"Entry: {} -> {}",
|
||||
app.document.bundle().entry,
|
||||
remote.compiled.bundle.entry
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
for path in paths {
|
||||
if local.get(path) != server.get(path) {
|
||||
let change = match (local.contains_key(path), server.contains_key(path)) {
|
||||
(false, true) => "Added on server",
|
||||
(true, false) => "Removed on server",
|
||||
_ => "Changed",
|
||||
};
|
||||
changes = changes.push(
|
||||
row![
|
||||
text(format!("{change}: {path}")).size(12).width(Fill),
|
||||
control(
|
||||
"View server text",
|
||||
Event::ReviewFile(path.clone()),
|
||||
enabled && server.contains_key(path)
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
}
|
||||
}
|
||||
changes = changes.push(control("Reload server version", Event::Reload, enabled));
|
||||
content = content.push(container(changes).padding(12).style(style::panel));
|
||||
}
|
||||
content = content.push(text("Hosted model catalog").size(17)).push(
|
||||
row![
|
||||
text_input("Search model names", &state.query)
|
||||
.padding(9)
|
||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
|
||||
.on_submit(Message::Hosted(Event::Connect)),
|
||||
control("Search", Event::Connect, enabled),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
if let Some(message) = state.catalog_message() {
|
||||
content = content.push(label(message));
|
||||
}
|
||||
for item in &state.catalog {
|
||||
let selected = state.selected == Some(item.resource_id);
|
||||
content = content.push(
|
||||
button(
|
||||
column![
|
||||
text(&item.name).size(14),
|
||||
label(format!(
|
||||
"{} - revision {} - {} objects / {} links",
|
||||
item.resource_id, item.revision, item.elements, item.relations
|
||||
)),
|
||||
]
|
||||
.spacing(4),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.style(if selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press_maybe(enabled.then_some(Message::Hosted(Event::Select(item.resource_id)))),
|
||||
);
|
||||
}
|
||||
if !state.catalog_stale && state.next.is_some() {
|
||||
content = content.push(control("More models", Event::More, enabled));
|
||||
}
|
||||
let publish_label = if state
|
||||
.uncertain
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.operation == Operation::Publish)
|
||||
{
|
||||
"Publish another copy"
|
||||
} else {
|
||||
"Publish as new"
|
||||
};
|
||||
column![
|
||||
text("Hosted models").size(24),
|
||||
label("Optional shared storage for desktop, scripts and engineering integrations."),
|
||||
scrollable(content.spacing(12)).height(Fill),
|
||||
text(if app.busy { app.status.as_str() } else { "Publishing creates a separate hosted resource. Existing resources change only through Save to server." }).size(12),
|
||||
row![
|
||||
control("Close", Event::Close, enabled),
|
||||
control(publish_label, Event::Publish, enabled),
|
||||
control("Open selected", Event::OpenSelected, enabled && state.selected.is_some() && state.connection.is_some()),
|
||||
control("Save to server", Event::Save, enabled && state.session.is_some() && state.review.is_none()),
|
||||
].spacing(8),
|
||||
].spacing(12).into()
|
||||
}
|
||||
|
||||
// Kept here so a caller can integrate the feature without duplicating event wiring.
|
||||
pub fn header_action() -> widget::Button<'static, Message> {
|
||||
action("Hosted models...", Message::Hosted(Event::Show))
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
mod app;
|
||||
mod behavior;
|
||||
mod connection_edit;
|
||||
mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -8,11 +13,16 @@ mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod hosted;
|
||||
mod model_menu;
|
||||
mod navigation;
|
||||
mod port_edit;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_navigation;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,871 @@
|
||||
//! Contextual actions reuse the ordinary guarded application commands.
|
||||
use crate::{
|
||||
app::{App, Message, Page},
|
||||
context_menu::{Entry, Menu},
|
||||
port_workflow, route_edit,
|
||||
};
|
||||
use iced::Point;
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub anchor: Point,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
pub fn object(object: Id, anchor: Point) -> Self {
|
||||
Self {
|
||||
anchor,
|
||||
object,
|
||||
placement: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Open(Request),
|
||||
Invoke(u64, Box<Message>),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct Opened {
|
||||
pub token: u64,
|
||||
pub request: Request,
|
||||
document: Id,
|
||||
revision: u64,
|
||||
epoch: u64,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
diagram: Option<Id>,
|
||||
}
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
if !app.project().contains(request.object)
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != current_canvas(app))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
self.document == app.canvas_document
|
||||
&& self.revision == app.document.revision()
|
||||
&& self.epoch == app.canvas_epoch
|
||||
&& self.page == app.page
|
||||
&& self.layer == app.layer
|
||||
&& self.diagram == current_diagram(app)
|
||||
&& app.project().contains(self.request.object)
|
||||
&& self
|
||||
.request
|
||||
.canvas
|
||||
.is_none_or(|c| Some(c) == current_canvas(app))
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
.as_ref()
|
||||
.is_none_or(|path| app.source_workspace.files.contains_key(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context()
|
||||
} else {
|
||||
app.route_context()
|
||||
}
|
||||
}
|
||||
|
||||
fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context().map(|context| context.diagram)
|
||||
} else {
|
||||
app.active_diagram
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(object: Id, anchor: Point) -> Message {
|
||||
Message::Menu(Event::Open(Request::object(object, anchor)))
|
||||
}
|
||||
|
||||
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
crate::context_menu::Area::left(
|
||||
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
||||
move |anchor| {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
Message::Menu(Event::Open(request))
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
||||
let request = &opened.request;
|
||||
let p = app.project();
|
||||
let id = request.object;
|
||||
let clean = !app.busy
|
||||
&& (request.source_file.is_some()
|
||||
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
|
||||
let item = |caption: &str, message: Message, enabled: bool| {
|
||||
if clean && enabled {
|
||||
Entry::action(
|
||||
caption,
|
||||
Message::Menu(Event::Invoke(opened.token, Box::new(message))),
|
||||
)
|
||||
} else {
|
||||
Entry::disabled(caption)
|
||||
}
|
||||
};
|
||||
if let Some(path) = &request.source_file {
|
||||
let buffer = app.source_workspace.files.get(path)?;
|
||||
let entries = vec![
|
||||
Entry::title(path),
|
||||
item("Open file", Message::SourceFile(path.clone()), true),
|
||||
item("Reveal active file", Message::SourceTreeLocate, true),
|
||||
Entry::separator(),
|
||||
item(
|
||||
"Close detached buffer",
|
||||
Message::SourceCloseFile(path.clone()),
|
||||
!app.source_workspace.reachable.contains(path) && !buffer.dirty(),
|
||||
),
|
||||
Entry::title(if app.source_workspace.reachable.contains(path) {
|
||||
"Included files remain part of the project"
|
||||
} else if buffer.dirty() {
|
||||
"Apply or revert this file before closing"
|
||||
} else {
|
||||
"Closing leaves the file on disk"
|
||||
}),
|
||||
];
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
{
|
||||
return Some(menu);
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
|
||||
if request.canvas.is_some() && app.active_diagram == Some(id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Fit diagram", Message::Fit, true),
|
||||
item(
|
||||
"Arrange elements",
|
||||
Message::Command(Command::Layout(id)),
|
||||
true,
|
||||
),
|
||||
item("Export SVG…", Message::Export, true),
|
||||
Entry::separator(),
|
||||
item(
|
||||
&format!("Create {} at root", app.create_kind.label()),
|
||||
Message::Add(false),
|
||||
validation::can_parent(p, app.create_kind, app.layer, None),
|
||||
),
|
||||
item("Browse existing elements…", Message::Explore(None), true),
|
||||
]);
|
||||
} else {
|
||||
if request.route_point.is_some()
|
||||
|| request.bend.is_some()
|
||||
|| (request.canvas.is_some() && p.relations.contains_key(&id))
|
||||
{
|
||||
let diagram = app.active_diagram.and_then(|d| p.diagrams.get(&d));
|
||||
let view = diagram.map(|d| projection::project_view(p, d.id));
|
||||
let projected = view
|
||||
.as_ref()
|
||||
.and_then(|v| v.edges.iter().find(|e| e.id == id));
|
||||
let editable = diagram.zip(projected).is_some_and(|(d, e)| {
|
||||
projection::edge_endpoint_placements(p, d, id)
|
||||
.is_none_or(|(s, t)| s == e.source && t == e.target)
|
||||
});
|
||||
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
|
||||
if let Some(point) = request.route_point {
|
||||
entries.push(
|
||||
item(
|
||||
"Add bend here",
|
||||
Message::Route(route_edit::Event::AddBendAt(point)),
|
||||
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
||||
)
|
||||
.shortcut("Insert"),
|
||||
);
|
||||
} else {
|
||||
entries.push(
|
||||
item(
|
||||
"Add bend",
|
||||
Message::Route(route_edit::Event::AddBend),
|
||||
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
||||
)
|
||||
.shortcut("Insert"),
|
||||
);
|
||||
}
|
||||
if request.bend.is_some() {
|
||||
entries.push(
|
||||
item(
|
||||
"Remove bend",
|
||||
Message::Route(route_edit::Event::RemoveBend),
|
||||
editable,
|
||||
)
|
||||
.shortcut("Delete"),
|
||||
);
|
||||
}
|
||||
entries.push(item(
|
||||
"Reset routing",
|
||||
Message::Route(route_edit::Event::Reset),
|
||||
editable && route.is_some(),
|
||||
));
|
||||
if !editable {
|
||||
entries.push(Entry::title("Expand endpoint containers to edit routing"));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
entries.push(item("Properties", Message::EditProperties(id), true));
|
||||
entries.push(item("Open source", Message::SourceForSelection, true));
|
||||
entries.push(item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
true,
|
||||
));
|
||||
if let Some(e) = p.elements.get(&id) {
|
||||
if e.kind() == Kind::Port
|
||||
&& app.page == Page::Diagram
|
||||
&& let Some(context) = app.route_context()
|
||||
&& let Some(port) = projection::project_view(p, context.diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == id)
|
||||
{
|
||||
let manual = p.diagrams[&context.diagram].nodes[&port.owner]
|
||||
.port_anchors
|
||||
.iter()
|
||||
.any(|anchor| anchor.port == id);
|
||||
entries.push(item(
|
||||
"Reset automatic position",
|
||||
Message::PortAnchor(crate::port_edit::Event::Reset(context, port.owner, id)),
|
||||
manual,
|
||||
));
|
||||
}
|
||||
if app.page == Page::Diagram {
|
||||
for tool in crate::connection_tool::Tool::for_layer(e.layer()) {
|
||||
if tool.can_start(p, id) {
|
||||
entries.push(item(
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(
|
||||
p, diagram,
|
||||
))
|
||||
.any(|element| element == id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(owner) = e.parent {
|
||||
entries.push(item("Open owner", Message::Select(owner), true));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
for action in port_workflow::actions(p, id) {
|
||||
if matches!(
|
||||
action,
|
||||
port_workflow::Action::Flow | port_workflow::Action::Standard
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let caption = match action {
|
||||
port_workflow::Action::Component => "Add port…",
|
||||
port_workflow::Action::Input => "Add input port…",
|
||||
port_workflow::Action::Output => "Add output port…",
|
||||
_ => action.label(),
|
||||
};
|
||||
entries.push(item(caption, Message::PortAction(action), true));
|
||||
}
|
||||
if let Some(PortContract::Component { kind, orientation }) = port_contract(e) {
|
||||
entries.push(item(
|
||||
"Configure boundary exchange…",
|
||||
Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
*kind == ComponentPortKind::Flow
|
||||
&& *orientation == ComponentPortOrientation::In,
|
||||
),
|
||||
true,
|
||||
));
|
||||
}
|
||||
let work = if e.layer() == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
if e.kind().is_performer() || e.kind().is_work() {
|
||||
entries.push(item(
|
||||
if e.kind().is_performer() {
|
||||
"Create allocated work"
|
||||
} else {
|
||||
"Create child work"
|
||||
},
|
||||
Message::CreateContextElement(work, true),
|
||||
true,
|
||||
));
|
||||
} else if validation::can_parent(p, app.create_kind, e.layer(), Some(id))
|
||||
&& app.create_kind != Kind::Port
|
||||
{
|
||||
entries.push(item(
|
||||
&format!("Create child {}", app.create_kind.label()),
|
||||
Message::CreateContextElement(app.create_kind, true),
|
||||
true,
|
||||
));
|
||||
}
|
||||
for (caption, kind, incoming) in relation_actions(e.kind()) {
|
||||
entries.push(item(
|
||||
caption,
|
||||
Message::AddRelationKind(*kind, *incoming),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if let Some(node) = request.placement.and_then(|node| {
|
||||
app.active_diagram
|
||||
.and_then(|d| p.diagrams.get(&d))?
|
||||
.nodes
|
||||
.get(&node)
|
||||
}) {
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item(
|
||||
if node.collapsed {
|
||||
"Unfold contents"
|
||||
} else {
|
||||
"Fold contents"
|
||||
},
|
||||
Message::ToggleCollapse,
|
||||
true,
|
||||
));
|
||||
entries.push(item("Placement properties", Message::Select(node.id), true));
|
||||
entries.push(item("Hide from this diagram", Message::HideSelected, true));
|
||||
} else if e.kind() != Kind::Port
|
||||
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
|
||||
{
|
||||
entries.push(item(
|
||||
"Show in this diagram",
|
||||
Message::PlaceSelected,
|
||||
d.layer == e.layer() && d.projection.includes(e.kind()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(r) = p.relations.get(&id) {
|
||||
for action in port_workflow::actions(p, id) {
|
||||
entries.push(item(action.label(), Message::PortAction(action), true));
|
||||
}
|
||||
entries.push(item(
|
||||
"Open source endpoint",
|
||||
Message::Select(r.source()),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Open target endpoint",
|
||||
Message::Select(r.target()),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if p.layer_of(id).is_some() {
|
||||
entries.push(item("Create relationship…", Message::AddRelation, true));
|
||||
entries.push(item(
|
||||
"Create counterpart / review prose…",
|
||||
Message::StartTransition,
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Review semantics…",
|
||||
Message::StartSemanticReview(id),
|
||||
p.traces.values().any(|t| t.oa == id || t.sa == id),
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Attach artifact reference…",
|
||||
Message::AddArtifact,
|
||||
true,
|
||||
));
|
||||
if id != p.id {
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
})
|
||||
}
|
||||
|
||||
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
|
||||
match kind {
|
||||
Kind::OperationalMission | Kind::Mission => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
RelationKind::MissionCapability,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant…",
|
||||
RelationKind::MissionInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
Kind::Capability | Kind::SystemCapability => &[
|
||||
(
|
||||
"Include capability…",
|
||||
RelationKind::CapabilityInclude,
|
||||
false,
|
||||
),
|
||||
("Extend capability…", RelationKind::CapabilityExtend, false),
|
||||
(
|
||||
"Specialize capability…",
|
||||
RelationKind::CapabilityGeneralization,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant or work…",
|
||||
RelationKind::CapabilityInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn choose(app: &App, caption: &str) -> Message {
|
||||
popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
|
||||
}
|
||||
#[test]
|
||||
fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
|
||||
use crate::behavior::state_edit;
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let architecture = project.ensure_diagram(Layer::Oa);
|
||||
project.place(architecture, state, Position { x: 80., y: 90. });
|
||||
let first = state_edit::create_view(&project, machine).unwrap();
|
||||
let first_id = first.diagram;
|
||||
project = execute_batch(&project, &[first.command]).unwrap();
|
||||
let second = state_edit::create_view(&project, machine).unwrap();
|
||||
let second_id = second.diagram;
|
||||
project = execute_batch(&project, &[second.command]).unwrap();
|
||||
let placement = project.diagrams[&first_id]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == state)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut app = App::with_project(project.clone());
|
||||
send(&mut app, Message::Select(machine));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
app.behavior.diagram.diagram = Some(first_id);
|
||||
app.active_diagram = Some(architecture);
|
||||
let open_state = |app: &mut App| {
|
||||
let mut request = Request::object(state, Point::ORIGIN);
|
||||
request.canvas = app.state_context();
|
||||
request.placement = Some(placement);
|
||||
send(app, Message::Menu(Event::Open(request)));
|
||||
};
|
||||
|
||||
// A canvas menu from Architecture is stale on the state page even when
|
||||
// the architecture diagram itself remains the active architecture view.
|
||||
let mut wrong = Request::object(state, Point::ORIGIN);
|
||||
wrong.canvas = app.route_context();
|
||||
assert!(Opened::new(&app, wrong, 1).is_none());
|
||||
open_state(&mut app);
|
||||
let fold = choose(&app, "Fold contents");
|
||||
send(&mut app, fold);
|
||||
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
|
||||
assert_eq!(app.selected, state, "Folding retains semantic selection");
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(
|
||||
app.project().diagrams[&architecture],
|
||||
project.diagrams[&architecture]
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&second_id],
|
||||
project.diagrams[&second_id]
|
||||
);
|
||||
assert_eq!(app.project().elements, project.elements);
|
||||
open_state(&mut app);
|
||||
let unfold = choose(&app, "Unfold contents");
|
||||
send(&mut app, unfold);
|
||||
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
send(&mut app, Message::Undo);
|
||||
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
|
||||
open_state(&mut app);
|
||||
let stale = choose(&app, "Fold contents");
|
||||
app.behavior.diagram.diagram = Some(second_id);
|
||||
send(&mut app, stale);
|
||||
assert_eq!(app.project(), &project);
|
||||
app.behavior.diagram.diagram = Some(first_id);
|
||||
open_state(&mut app);
|
||||
let hide = choose(&app, "Hide from this diagram");
|
||||
send(&mut app, hide);
|
||||
assert!(
|
||||
!app.project().diagrams[&first_id]
|
||||
.nodes
|
||||
.contains_key(&placement)
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&architecture],
|
||||
project.diagrams[&architecture]
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&second_id],
|
||||
project.diagrams[&second_id]
|
||||
);
|
||||
assert_eq!(app.project().elements, project.elements);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
|
||||
// Placement properties deliberately select a placement record, but an
|
||||
// accepted numeric edit must keep the Behavior view's own context.
|
||||
let source_before = app.document.bundle().clone();
|
||||
open_state(&mut app);
|
||||
let properties = choose(&app, "Placement properties");
|
||||
send(&mut app, properties);
|
||||
assert_eq!(app.selected, placement);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().kind,
|
||||
crate::forms::RecordKind::Node(first_id)
|
||||
);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/position/x".into(), x.to_string()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
send(&mut app, Message::Apply);
|
||||
assert!(!app.draft_dirty());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(machine));
|
||||
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
||||
assert_eq!(
|
||||
app.active_diagram,
|
||||
Some(architecture),
|
||||
"Placement Apply must not replace the Architecture view"
|
||||
);
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&first_id)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&placement)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = x;
|
||||
assert_eq!(app.project(), &expected);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source_before);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let entry = project
|
||||
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(entry));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
send(&mut app, open(entry, Point::ORIGIN));
|
||||
let start = choose(&app, "New transition with details…");
|
||||
send(&mut app, start);
|
||||
assert_eq!(app.behavior.pending_owner(), Some(machine));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Behavior(crate::behavior::Event::States(
|
||||
crate::behavior::states::Event::Cancel,
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied entry".into()),
|
||||
);
|
||||
let before = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
send(&mut app, open(entry, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_targets_actor_without_changing_selection_until_action() {
|
||||
let mut app = App::new();
|
||||
let actor = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::SystemActor)
|
||||
.unwrap()
|
||||
.id;
|
||||
let selected = app.selected;
|
||||
let model = app.project().clone();
|
||||
send(&mut app, open(actor, Point::new(240., 180.)));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.project(), &model);
|
||||
let action = choose(&app, "Add port…");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.selected, actor);
|
||||
assert!(matches!(
|
||||
&app.workflow,
|
||||
Some(crate::workflow::Workflow::Ports(_))
|
||||
));
|
||||
assert!(app.model_menu.is_none());
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&model,
|
||||
"Opening the existing creation workflow does not create a port"
|
||||
);
|
||||
send(&mut app, Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert_eq!(app.project().elements.len(), model.elements.len() + 1);
|
||||
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
|
||||
assert_eq!(app.project().elements[&app.selected].kind(), Kind::Port);
|
||||
}
|
||||
#[test]
|
||||
fn menu_preserves_pending_properties_and_rejects_delayed_mutations() {
|
||||
let mut app = App::new();
|
||||
let target = app.project().elements.keys().next().copied().unwrap();
|
||||
let selected = app.selected;
|
||||
let model = app.project().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied project title".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let draft = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
send(&mut app, open(target, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Menu(Event::Invoke(token, Box::new(Message::Delete))),
|
||||
);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), draft);
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
#[test]
|
||||
fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() {
|
||||
let mut app = App::new();
|
||||
let id = app.selected;
|
||||
send(&mut app, open(id, Point::ORIGIN));
|
||||
let old = choose(&app, "Open source");
|
||||
send(&mut app, open(id, Point::new(200., 80.)));
|
||||
let current = app.model_menu.as_ref().unwrap().token;
|
||||
send(&mut app, old);
|
||||
assert_eq!(app.model_menu.as_ref().unwrap().token, current);
|
||||
let stale = choose(&app, "Open source");
|
||||
app.canvas_epoch += 1;
|
||||
send(&mut app, stale);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.model_menu.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn pending_source_allows_file_navigation_but_disables_model_actions() {
|
||||
let mut app = App::new();
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
)),
|
||||
);
|
||||
assert!(app.source_dirty());
|
||||
let original = app.source_editor.text();
|
||||
let file = app.source_workspace.active.clone();
|
||||
let mut request = Request::object(app.project().id, Point::ORIGIN);
|
||||
request.source_file = Some(file.clone());
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let action = choose(&app, "Open file");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.source_editor.text(), original);
|
||||
assert_eq!(app.source_workspace.active, file);
|
||||
let id = app.project().id;
|
||||
send(&mut app, open(id, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
}
|
||||
#[test]
|
||||
fn add_bend_here_uses_context_point_and_one_source_aware_history_change() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Activity, "Send", 0., 0.);
|
||||
let b = p.add(Kind::Activity, "Receive", 650., 0.);
|
||||
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let mut app = App::with_project(p);
|
||||
let original = app.document.bundle().clone();
|
||||
let point = Position { x: 420., y: -80. };
|
||||
let mut request = Request::object(edge, Point::new(540., 360.));
|
||||
request.canvas = app.route_context();
|
||||
request.route_point = Some(point);
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let action = choose(&app, "Add bend here");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.selected, edge);
|
||||
assert_eq!(app.selected_bend, Some((edge, 0)));
|
||||
assert_eq!(
|
||||
app.project().diagrams[&diagram].routes[0].waypoints,
|
||||
vec![point]
|
||||
);
|
||||
let changed = app.document.bundle().clone();
|
||||
assert_ne!(original, changed);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
|
||||
let mut p = Project::empty();
|
||||
let parent = p.add(Kind::Activity, "Respond", 0., 0.);
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess", Some(parent))
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let parent_node = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == parent)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent_node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let mut app = App::with_project(p);
|
||||
let original = app.document.bundle().clone();
|
||||
send(&mut app, Message::Explore(Some(child)));
|
||||
send(&mut app, open(child, Point::new(90., 300.)));
|
||||
let action = choose(&app, "Show in this diagram");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.selected, child);
|
||||
assert!(
|
||||
app.selected_node
|
||||
.is_some_and(|node| app.project().diagrams[&diagram].nodes[&node].element == child)
|
||||
);
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&parent_node].collapsed);
|
||||
assert!(
|
||||
projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.element == child)
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&original,
|
||||
"One Undo restores both placement and folded ancestors"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user