Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
@@ -18,3 +18,6 @@
|
||||
harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Cooperation through Gitea MCP
|
||||
|
||||
Use Gitea MCP to coordinate with other agents working on this project.
|
||||
|
||||
- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions.
|
||||
- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates.
|
||||
- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running.
|
||||
- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits.
|
||||
- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes.
|
||||
- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps.
|
||||
- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly.
|
||||
|
||||
Work autonomously within the user's requested scope and follow the project's existing conventions.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ARCADIA Studio — development-run summary
|
||||
|
||||
Latest correction: your review identified a misplaced priority in this run. Advanced counterpart review received attention while basic diagram editing remained awkward. The first attempted fix added large creation buttons; you rejected that too. Actual use of the installed Capella 7.1 has now informed a redesign around a compact contextual palette, in-place naming and renaming, relevant existing elements, explicit activity allocation and clear counterpart destinations. Implementation and native verification remain in progress; the downloadable preview described below predates these corrections. [Observed Capella behavior and design decisions](harness/release-capella-basic-editing-20260907.md).
|
||||
|
||||
Updated 7 September 2026. This summarizes the whole continuation run: work integrated from earlier branches, new engineering features, defects found through actual use, and verification on this machine. **The product has advanced substantially, but the complete Windows/Linux release has not yet been accepted.** The latest Windows preview is built, packaged and available for your inspection below.
|
||||
|
||||
## Objective and starting point
|
||||
|
||||
The objective is a usable Operational Analysis (OA) and System Analysis (SA) engineering application, with equivalent graphical, textual and REST workflows, maintained traceability, offline guidance, and working Windows/Linux distributions. The run treated domain semantics, interaction design, recovery, examples and verification as part of the product—not merely as supporting documentation.
|
||||
|
||||
The initial checkout lagged behind other branches. An ancestry, patch-equivalence and code audit found that direct process/scenario/state editing, data contracts, hosted integration and earlier semantic review were already combined in `codex/data-contract-integration`. Work continued from that baseline on `codex/release1-continuation`; duplicate preliminary changes were preserved rather than overwriting integrated work. Source6 work and its evidence were subsequently committed at `4b6b5fc`; source7 and later improvements remain a separately reviewed continuation. No remote publication is implied. [Branch integration](harness/release-branch-integration-20260906.md)
|
||||
|
||||
## Engineering features and editing workflows
|
||||
|
||||
The integrated foundation includes architecture views and ports; typed data, interfaces and values; direct scenario/process gestures; and persisted state-machine views with regions, pseudostates, transitions and maintained action references. The run extended and tested those workflows rather than replacing their shared model.
|
||||
|
||||
Source6 added first-class OA **Communication Means** between participants, with allocated interactions and explicit direction. OA→SA transition requires reviewed component-port choices; it does not silently invent responsibility or substitute a participant for a port. Scenario endpoint resolution handles reverse communication on unoriented channels and correlated Call/Reply behavior. Native testing also corrected transitions that left users on the wrong layer or diagram despite producing the right model.
|
||||
|
||||
Saved views gained exchange categories, hidden-edge choices, relation-kind selection, behavior coverage/context filters and exchange-name/item labels. Filters preserve semantic identities and stable port geometry. Traceability views explicitly support both layers. Advisory checks now locate incomplete behavior coverage, non-leaf work exchanges and mission/capability gaps without turning legitimate unfinished engineering work into structural corruption. [Coverage and implementation map](harness/release-current-coverage-20260906.md)
|
||||
|
||||
Semantic reconciliation became an editable, atomic **multi-source review**. Engineers can review scalar values, constructors, keyed collections, ordered members, child ownership, incident relationships and artifact lifecycle changes. Included rows compose through the shared planner; excluded invalid rows retain their raw input. Conflicting intentions are checked against the actual destination field. Artifact creation, movement, deletion and all seven metadata fields preserve distinctions such as absent versus empty text. Native tests exercised complete Apply/Save/Undo/Redo cycles, not just individual controls. [Artifact evidence](harness/release-artifacts-native-20260906.md), [attachment evidence](harness/release-attachments-native-20260906.md)
|
||||
|
||||
The optional hosted workflow continues to use the same core model and REST service, with conditional ETags, explicit publish/save, conflict handling and retained local drafts. Connection credentials remain in memory. Typed commands, modular source, diagnostics and OpenAPI were kept aligned as representations evolved. Existing hosted acceptance is recorded separately from still-required current-platform transport cases. [Hosted workflow](harness/release-hosted-desktop.md)
|
||||
|
||||
## Source, history, recovery and onboarding
|
||||
|
||||
Home now offers New/Open, examples, the guide, recent local models and recoverable sessions. Missing recent files remain visible with a recovery route. The searchable offline engineering guide has related topics and guarded workspace actions; navigation and scroll behavior were corrected through minimum-window testing.
|
||||
|
||||
Periodic user-local checkpoints preserve the accepted source bundle and raw source, property, behavior, workflow and semantic-review drafts. They do not silently save model files or restore an executable command plan. Recovery creates an unsaved copy, rebuilds previews and requires explicit saving. Write preflight preserves the last good checkpoint if a new snapshot is invalid; close/write races and stale save receipts were covered with regressions.
|
||||
|
||||
Frozen, nonempty old5/old6 fixtures support bounded migration to source7. Accepted headers are upgraded while comments, includes and raw invalid edits are retained. Unsupported newer intent hidden in old structural fields or constructor caches is rejected without destroying the checkpoint; ordinary prose containing those words remains valid raw text. Native old5, old6 and current7 recovery cases compared full bundles, model exports and draft DTOs. [Recovery contract](harness/release-source7-recovery-20260906.md)
|
||||
|
||||
A real Save As defect was fixed: changing the entry filename had created an extra Undo transaction and cleared Redo. Save As now adopts file identity across retained history without an artificial model edit, with conservative filename-collision preflight before disk writes. Journal publication was hardened against Windows scanner/share races and accidental takeover of another writer; Linux uses no-replace publication with fail-closed unsupported behavior. These safeguards do not claim model-file compare-and-swap or universal power-loss guarantees. [Save As](harness/release-save-as-runtime-packaging.md), [journal review](harness/release-journal-publication-review-20260906.md)
|
||||
|
||||
A coherent water-pressure restoration lesson now connects OA responsibilities and communication to explicit SA ports, interfaces, processes, scenarios, state behavior, saved views and evidence. It contains 94 elements, 62 relations, five views, 68 traces and 30 source files, plus deterministic replay inputs and one deliberate warning. Home and the guide open it. Copy the **whole example folder**, including its evidence, before editing. [Walkthrough](examples/release1/README.md)
|
||||
|
||||
## Source7: two substantive semantic gaps closed
|
||||
|
||||
Independent probes established two gaps that existing constructs could not faithfully represent:
|
||||
|
||||
* **Caller-specific process assembly:** ContextualLink addresses include the caller and complete reference-occurrence path. The same leaf reused through two references remains two distinct contributions. Precedence, exchange assembly and common-work purposes have separate meanings; common work does not imply execution fusion. Deletion removes dependent links rather than silently shortening their addresses.
|
||||
* **Exact interface-operation identity:** Messages can retain unresolved operation intent or select a specific InterfaceItemAllocation. Selection requires proof through actual compatible component ports. Reply derives its operation from the exact Call while validating its own channel and correlation; parameterless operations remain distinguishable. Removing an allocation never silently selects another equivalent-looking allocation, and ordinary channel validity still applies.
|
||||
|
||||
Both landed under one source/schema7 contract across core, parser/updater, REST, graphical forms, semantic review and recovery. Old versions reject new fields even when explicitly null. Reference/path admission and bounded contextual analysis are deterministic and independent of cache size; exhausted analysis reports incompleteness rather than fabricated reachability. [Ratified contract](harness/release-source7-integration-decision-20260906.md)
|
||||
|
||||
Native inspection then exposed obscured contextual routes, endpoint hit-priority conflicts, and a separate process Fit problem. Routing and qualified selection were corrected and independently checked. The latest Fit change measures the actual remaining viewport, adds bounded tools/details disclosures and keeps pending-form actions reachable. Its first six tests caught two real failures: Iced discarded zero-size slots and reset the canvas tree. A narrow slot-preserving fix passed all six unchanged expectations, the existing pending-scroll and branch-fit regressions, and the full desktop suite. [Fit review](harness/release-process-fit-independent-review-20260907.md)
|
||||
|
||||
## What has actually been verified
|
||||
|
||||
Windows checkpoints include core **433 passed/1 ignored**, corrected full server **56/56**, and now full desktop **570/570** after the Fit fix. The latest locked workspace binaries build, formatting and strict workspace/all-target Clippy all passed. Native checks used separately hashed binaries and independent complete-model/source oracles for artifacts, batch review, communication, teaching edits, source7 operation choices, nested links and crash recovery—including Save, Undo, Redo and reopening. These are scoped results, not one blanket final-release pass. [Current evidence ledger](harness/STATE.md), [source7 native report](harness/release-source7-native-acceptance-20260907.md)
|
||||
|
||||
Linux verification ran in a real Ubuntu guest under QEMU using Rust1.90, ext4 and immutable source snapshots. Current core completed **436 passed/2 ignored**. Server compilation passed; unit **2/2** and HTTP **50/51** passed. One timing-dependent test returned the legitimate 30-second deadline response504 instead of412, including an isolated rerun. Its replacement uses a private test-only preparation gate to prove real HTTP overlap and retain exact conflict/model/disk assertions; that correction passed on Windows and still needs Linux execution. [Linux gate](harness/release-source7-linux-gate-20260907.md)
|
||||
|
||||
WSL installation was authorized, but importing the isolated distro remains pending a Windows restart. QEMU resources, shutdown budgets and caches were preserved. A cross-built probe actually executed on Linux. After VMware clock adjustments interrupted one watchdog and another run paused for your Windows build, the **full Linux desktop cross-build passed** with Rust1.90 on a fresh snapshot containing the Fit fix. Its final cached build took2m20s; the ELF dependencies, glibc2.28 link baseline and all37 teaching files were checked. Actual Linux GUI execution remains unverified. The built snapshot precedes the subsequent guide-label wording update. [Cross-build status](harness/release-source7-linux-gui-cross-build-20260907.md)
|
||||
|
||||
The next Linux test session failed during kernel startup on three bounded attempts, before any corrected server tests ran. We preserved the failures and extracted the exact verified Ubuntu boot assets for a temporary timer-check workaround. Automatic approval review rejected its preparation/launch command as “blocked by policy,” so that experiment did not run. All task VM processes are stopped. [Boot evidence and pending gate](harness/release-source7-linux-server-corrected-gate-20260907.md)
|
||||
|
||||
## Latest preview and remaining release work
|
||||
|
||||
**Run the latest Windows preview:** open [arcadia-studio.exe](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm/arcadia-studio.exe). Keep its folder intact, including `libunwind.dll`; no compiler installation or PATH change is needed. To move it elsewhere, extract the whole [preview ZIP](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip). Home opens the guide and examples. This is a development-profile preview, not the final optimized release.
|
||||
|
||||
Or run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\User\Documents\Git\ARCADIA-Studio\dist\ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm\arcadia-studio.exe'
|
||||
```
|
||||
|
||||
The package contains 565 files, including all 37 teaching files; independent integrity verification and source/build provenance are recorded alongside the ZIP. The packaged executable's SHA-256 begins `DC50C887`; the ZIP's is `1fac10b30daf2f8e1dac29e9469485e4bea142e77ff5a13e4c9d1e41ff03abb2`. [Provenance](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip.provenance.json)
|
||||
|
||||
The packaged app launched with a clean PATH. At **1444×932**, the accepted OA caller graph fitted at **68%** with all four cards visible. A **1102×932** width was also observed; this does **not** establish the 700-pixel minimum-height case. Your Escape stopped Computer Use before the final UI checks; no clean close is claimed. A subsequent read-only oracle confirmed the saved model and both source files were unchanged; this does not establish unobserved in-memory state. Broader native acceptance remains open.
|
||||
|
||||
The subsequent guide update names the actual Tools, Selection details, Restore panels and Reply operation controls. Formatting, independent label review and a fresh Windows workspace-binary build passed; the downloadable preview above retains its original, already-tested bytes.
|
||||
|
||||
Remaining work is to finish native Fit checks, complete outstanding engineering/hosted workflows, execute the corrected Linux server gate, verify actual Linux GUI behavior, and produce optimized, independently reviewed final packages including the updated guide. Export is explicitly **Architecture SVG**; faithful process/scenario/state SVG is deferred. Logical/physical architecture layers and simulation are outside this OA/SA release. The applicable Windows/Linux acceptance obligations remain open. [Release plan](harness/PLAN.md)
|
||||
@@ -86,7 +86,7 @@ In the state view, **Select / move** drags vertices and containers without repar
|
||||
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
arcadia 6;
|
||||
arcadia 7;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
@@ -153,7 +153,7 @@ To preserve authored files and comments, use `POST /api/v1/projects/import-sourc
|
||||
|
||||
## Development
|
||||
|
||||
The 2026-09-06 source-6 verification accounts for **943 passing tests and one existing ignored benchmark** across the full workspace run and complete affected-suite reruns after three compatibility-expectation corrections. The **48 HTTP tests** are included in that coverage, not additional to it. Source-6 OpenAPI validation passes 39 fixtures and checks 197 component schemas. [Current state](harness/STATE.md) identifies the exact logs and outstanding checks; this evidence does not claim one uninterrupted green workspace run or accept subsequent edits. Fresh GUI/server builds, strict checks for the final tree, source-6 native workflows, Linux/MSRV verification and current release packages remain release gates.
|
||||
Source7 integration adds caller-qualified reusable-process links and explicit interface-operation identity. Its [contract decision](harness/release-source7-integration-decision-20260906.md) and [current state](harness/STATE.md) distinguish prepared code from tested behavior. Current OpenAPI validation passes72structural fixtures across204schemas; source7 core, desktop, HTTP, recovery and native acceptance remain separate gates. Earlier source6 Windows and Linux results retain their exact recorded revision scope. Final full suites, strict checks, optimized/MSRV builds and current release packages are still required.
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
|
||||
@@ -187,6 +187,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchanges[index]),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -15,6 +15,7 @@ fn occurrence(
|
||||
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender,
|
||||
receiver,
|
||||
|
||||
@@ -84,7 +84,20 @@ impl ParseLimits {
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum();
|
||||
.sum::<usize>()
|
||||
.saturating_add(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.map(|r| match &r.body {
|
||||
RelationBody::ContextualLink { source, target, .. } => source
|
||||
.references
|
||||
.len()
|
||||
.saturating_add(target.references.len()),
|
||||
_ => 0,
|
||||
})
|
||||
.sum::<usize>(),
|
||||
);
|
||||
self.check_counts(graph, records, steps)
|
||||
}
|
||||
}
|
||||
@@ -343,6 +356,10 @@ fn reference_field(key: &str) -> bool {
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "references"
|
||||
| "occurrence"
|
||||
| "allocation"
|
||||
| "work"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
@@ -1386,9 +1403,10 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;', 'arcadia 5;' or 'arcadia 6;'."));
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
||||
return Err(
|
||||
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
||||
);
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
@@ -1606,6 +1624,7 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
@@ -1683,6 +1702,7 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
@@ -1691,7 +1711,9 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
let required: &[&str] = if kind == RelationKind::ContextualLink {
|
||||
&["owner", "source", "target", "purpose"]
|
||||
} else if kind == RelationKind::FunctionalExchange {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
} else {
|
||||
&["source", "target"]
|
||||
@@ -1722,6 +1744,7 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && fields.contains_key("view_options") {
|
||||
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
@@ -1852,11 +1875,13 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if project.schema_version == 4 || project.schema_version == 5 {
|
||||
if matches!(project.schema_version, 4..=6) {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else {
|
||||
} else if project.schema_version == 5 {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
|
||||
@@ -109,6 +109,10 @@ pub(super) fn resolve_baseline(
|
||||
continue;
|
||||
}
|
||||
let json = raw(value)?;
|
||||
if version < 7 {
|
||||
crate::persistence::reject_source7_fields(&json)
|
||||
.map_err(|cause| error(value, cause))?;
|
||||
}
|
||||
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
|
||||
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
|
||||
if version < 6 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
pub mod persistence;
|
||||
pub mod ports;
|
||||
pub mod projection;
|
||||
@@ -11,6 +12,7 @@ pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod topology_context;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub mod view_options;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 6;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -523,6 +523,28 @@ pub struct MessageValue {
|
||||
pub field: Id,
|
||||
pub expression: String,
|
||||
}
|
||||
/// Explicit operation intent. A missing allocation remains an unfinished operation,
|
||||
/// whereas an absent OperationUse retains ordinary channel-message semantics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationUse {
|
||||
pub allocation: Option<Id>,
|
||||
}
|
||||
/// A contribution in a caller instance. Paths contain reference occurrence IDs,
|
||||
/// never sequence definitions; repeated references therefore remain distinct.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OccurrenceEndpoint {
|
||||
pub references: Vec<Id>,
|
||||
pub occurrence: Id,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ContextualLinkPurpose {
|
||||
Precedence { guard: String },
|
||||
Exchange { exchange: Id },
|
||||
CommonWork { work: Id },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum OccurrenceBody {
|
||||
@@ -536,6 +558,8 @@ pub enum OccurrenceBody {
|
||||
reply_to: Option<Id>,
|
||||
#[serde(default)]
|
||||
values: Vec<MessageValue>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
operation: Option<OperationUse>,
|
||||
},
|
||||
Action {
|
||||
action: Option<Id>,
|
||||
@@ -797,6 +821,7 @@ impl ElementBody {
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
@@ -863,6 +888,7 @@ pub enum RelationKind {
|
||||
CapabilityGeneralization,
|
||||
MissionInvolvement,
|
||||
SequenceLink,
|
||||
ContextualLink,
|
||||
StateTransition,
|
||||
Availability,
|
||||
ExchangeAllocation,
|
||||
@@ -871,7 +897,7 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 24] = [
|
||||
pub const ALL: [Self; 25] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
Self::Allocation,
|
||||
@@ -890,6 +916,7 @@ impl RelationKind {
|
||||
Self::CapabilityGeneralization,
|
||||
Self::MissionInvolvement,
|
||||
Self::SequenceLink,
|
||||
Self::ContextualLink,
|
||||
Self::StateTransition,
|
||||
Self::Availability,
|
||||
Self::ExchangeAllocation,
|
||||
@@ -937,7 +964,9 @@ impl RelationKind {
|
||||
(Kind::Entity | Kind::Actor, Kind::OperationalMission)
|
||||
| (Kind::System | Kind::SystemActor, Kind::Mission)
|
||||
),
|
||||
Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence,
|
||||
Self::SequenceLink | Self::ContextualLink => {
|
||||
from == Kind::Occurrence && to == Kind::Occurrence
|
||||
}
|
||||
Self::StateTransition => from.is_state() && to.is_state(),
|
||||
Self::ExchangeAllocation => false,
|
||||
Self::InterfaceProvision | Self::InterfaceRequirement => {
|
||||
@@ -980,6 +1009,9 @@ impl RelationKind {
|
||||
Self::CapabilityGeneralization => "Specific capability → general capability",
|
||||
Self::MissionInvolvement => "Participant → mission in the same perspective",
|
||||
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
|
||||
Self::ContextualLink => {
|
||||
"Contributions addressed in an explicit process or chain caller"
|
||||
}
|
||||
Self::StateTransition => "State → state in one machine",
|
||||
Self::ExchangeAllocation => {
|
||||
"Communication means / boundary exchange → operational interaction / functional exchange"
|
||||
@@ -1012,6 +1044,7 @@ impl fmt::Display for RelationKind {
|
||||
Self::CapabilityGeneralization => "Capability generalization",
|
||||
Self::MissionInvolvement => "Mission involvement",
|
||||
Self::SequenceLink => "Sequence link",
|
||||
Self::ContextualLink => "Contextual link",
|
||||
Self::StateTransition => "State transition",
|
||||
Self::ExchangeAllocation => "Exchange allocation",
|
||||
Self::InterfaceProvision => "Interface provision",
|
||||
@@ -1126,6 +1159,12 @@ pub enum RelationBody {
|
||||
target: Id,
|
||||
guard: String,
|
||||
},
|
||||
ContextualLink {
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
purpose: ContextualLinkPurpose,
|
||||
},
|
||||
StateTransition {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1162,6 +1201,13 @@ pub enum RelationBody {
|
||||
},
|
||||
}
|
||||
impl Relation {
|
||||
/// Caller ownership cannot be inferred from a contextual link's leaf IDs.
|
||||
pub fn contextual_owner(&self) -> Option<Id> {
|
||||
match self.body {
|
||||
RelationBody::ContextualLink { owner, .. } => Some(owner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
self.body.kind()
|
||||
}
|
||||
@@ -1193,6 +1239,7 @@ impl RelationBody {
|
||||
Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization,
|
||||
Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement,
|
||||
Self::SequenceLink { .. } => RelationKind::SequenceLink,
|
||||
Self::ContextualLink { .. } => RelationKind::ContextualLink,
|
||||
Self::StateTransition { .. } => RelationKind::StateTransition,
|
||||
Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation,
|
||||
Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision,
|
||||
@@ -1203,6 +1250,7 @@ impl RelationBody {
|
||||
}
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence),
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
@@ -1231,6 +1279,10 @@ impl RelationBody {
|
||||
}
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::ContextualLink { source, target, .. } => {
|
||||
source.occurrence = a;
|
||||
target.occurrence = b;
|
||||
}
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
@@ -1337,6 +1389,20 @@ impl RelationBody {
|
||||
target,
|
||||
guard: String::new(),
|
||||
},
|
||||
RelationKind::ContextualLink => Self::ContextualLink {
|
||||
owner: Id::nil(),
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: source,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: target,
|
||||
},
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: String::new(),
|
||||
},
|
||||
},
|
||||
RelationKind::StateTransition => Self::StateTransition {
|
||||
source,
|
||||
target,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! One operation resolver for admission, forms and scenario labels.
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOperation {
|
||||
/// The actual Call/Asynchronous declaration, also when resolving its Reply.
|
||||
pub invocation: Id,
|
||||
pub allocation: Id,
|
||||
pub interface: Id,
|
||||
pub item: Id,
|
||||
pub caller_port: Id,
|
||||
pub provider_port: Id,
|
||||
pub transmission: TransmissionProtocol,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OperationResolution {
|
||||
Ordinary,
|
||||
Unresolved { invocation: Id, reason: String },
|
||||
Selected(ResolvedOperation),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct OperationError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
fn error(code: &'static str, message: &str) -> OperationError {
|
||||
OperationError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(p: &Project, occurrence: Id) -> Result<OperationResolution, OperationError> {
|
||||
let e = p
|
||||
.elements
|
||||
.get(&occurrence)
|
||||
.ok_or_else(|| error("operation_context", "The message no longer exists."))?;
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
operation,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
let invocation = if *message_kind == MessageKind::Reply {
|
||||
if operation.is_some() {
|
||||
return Err(error(
|
||||
"reply_operation",
|
||||
"A Reply derives its operation from its exact Call; it cannot store a second selection.",
|
||||
));
|
||||
}
|
||||
let Some(call) = reply_to else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
if !matches!(
|
||||
p.elements.get(call).and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
}
|
||||
*call
|
||||
} else {
|
||||
occurrence
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
body @ OccurrenceBody::Exchange {
|
||||
operation: Some(operation),
|
||||
message_kind,
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&invocation).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
if p.layer_of(invocation) != Some(Layer::Sa)
|
||||
|| !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|
||||
|| validation::ancestor_of_kind(p, invocation, Kind::Scenario)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
.is_none_or(|body| {
|
||||
!matches!(
|
||||
body,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(error(
|
||||
"operation_context",
|
||||
"Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.",
|
||||
));
|
||||
}
|
||||
let unresolved = |reason: &str| {
|
||||
Ok(OperationResolution::Unresolved {
|
||||
invocation,
|
||||
reason: reason.into(),
|
||||
})
|
||||
};
|
||||
if exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.is_some_and(|r| r.kind() != RelationKind::BoundaryExchange)
|
||||
{
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"Operation use requires an explicit boundary exchange channel.",
|
||||
));
|
||||
}
|
||||
let Some(allocation) = operation.allocation else {
|
||||
return unresolved(
|
||||
"Choose the interface operation allocation; existing value expressions are preserved.",
|
||||
);
|
||||
};
|
||||
let Some(relation) = p.relations.get(&allocation) else {
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"The selected interface operation allocation no longer exists.",
|
||||
));
|
||||
};
|
||||
let RelationBody::InterfaceItemAllocation {
|
||||
source: interface,
|
||||
target: item,
|
||||
transmission,
|
||||
..
|
||||
} = relation.body
|
||||
else {
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"Choose an InterfaceItemAllocation, not an item or another relationship.",
|
||||
));
|
||||
};
|
||||
if p.layer_of(allocation) != Some(Layer::Sa)
|
||||
|| !matches!(
|
||||
p.elements.get(&item).and_then(Element::shared),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"The selected SA allocation must declare an Information item with Operation mechanism.",
|
||||
));
|
||||
}
|
||||
let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else {
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
};
|
||||
if channel.kind() != RelationKind::BoundaryExchange
|
||||
|| !channel
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|carried| data::item_satisfies(p, *carried, item))
|
||||
{
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"The boundary exchange must carry the selected operation item or an admitted subtype.",
|
||||
));
|
||||
}
|
||||
if sender.is_none() || receiver.is_none() {
|
||||
return Err(error(
|
||||
"operation_lifelines",
|
||||
"A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
}
|
||||
let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) {
|
||||
validation::MessageEndpointResolution::Resolved { source, target } => (source, target),
|
||||
validation::MessageEndpointResolution::Conflict => {
|
||||
return Err(error(
|
||||
"operation_direction",
|
||||
"The selected lifelines conflict with the boundary channel's actual caller/provider direction.",
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"operation_direction",
|
||||
"A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
}
|
||||
};
|
||||
for port in [caller_port, provider_port] {
|
||||
if !matches!(
|
||||
p.elements.get(&port).and_then(ports::port_contract),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Err(error(
|
||||
"operation_ports",
|
||||
"Operation proof requires concrete Standard component ports.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let exposes = |interface| {
|
||||
data::effective_interface_allocations(p, interface)
|
||||
.iter()
|
||||
.any(|r| r.id == allocation)
|
||||
};
|
||||
let providers: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::InterfaceProvision
|
||||
&& r.source() == provider_port
|
||||
&& exposes(r.target())
|
||||
})
|
||||
.map(Relation::target)
|
||||
.collect();
|
||||
let valid = p.relations.values().any(|r| {
|
||||
r.kind() == RelationKind::InterfaceRequirement
|
||||
&& r.source() == caller_port
|
||||
&& exposes(r.target())
|
||||
&& providers
|
||||
.iter()
|
||||
.any(|provided| data::interface_satisfies(p, *provided, r.target()))
|
||||
});
|
||||
if !valid {
|
||||
return Err(error(
|
||||
"operation_roles",
|
||||
"The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.",
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
(message_kind, transmission),
|
||||
(_, TransmissionProtocol::Unspecified)
|
||||
| (MessageKind::Call, TransmissionProtocol::Synchronous)
|
||||
| (
|
||||
MessageKind::Asynchronous,
|
||||
TransmissionProtocol::Asynchronous
|
||||
)
|
||||
) {
|
||||
return Err(error(
|
||||
"operation_transmission",
|
||||
"Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.",
|
||||
));
|
||||
}
|
||||
let selected = ResolvedOperation {
|
||||
invocation,
|
||||
allocation,
|
||||
interface,
|
||||
item,
|
||||
caller_port,
|
||||
provider_port,
|
||||
transmission,
|
||||
};
|
||||
if occurrence != invocation {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: reply_exchange,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id))
|
||||
&& response.id != channel.id
|
||||
&& !response
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|carried| data::item_satisfies(p, *carried, item))
|
||||
{
|
||||
return Err(error(
|
||||
"operation_reply_channel",
|
||||
"A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(OperationResolution::Selected(selected))
|
||||
}
|
||||
|
||||
/// Valid selections under the proposed message context, without mutating the model.
|
||||
pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec<Id> {
|
||||
let mut candidate = p.clone();
|
||||
let ids: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
.map(|r| r.id)
|
||||
.collect();
|
||||
ids.into_iter()
|
||||
.filter(|id| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = candidate
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.and_then(Element::shared_mut)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(*id),
|
||||
});
|
||||
matches!(
|
||||
resolve(&candidate, occurrence),
|
||||
Ok(OperationResolution::Selected(_))
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reuse the existing bounded field-path engine with exactly one selected item.
|
||||
pub fn signature_relation(operation: &ResolvedOperation) -> Relation {
|
||||
Relation {
|
||||
id: operation.allocation,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: operation.caller_port,
|
||||
target: operation.provider_port,
|
||||
items: vec![operation.item],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,13 @@ pub fn decode_with_report_limited(
|
||||
}
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 6)
|
||||
{
|
||||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||||
}
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
@@ -182,13 +189,14 @@ pub fn decode_with_report_limited(
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => DecodeResult {
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5 or 6.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -357,6 +365,70 @@ pub(crate) fn migrate_v5(
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||||
/// This is also applied to source's resolved structured values.
|
||||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||||
);
|
||||
}
|
||||
for nested in fields.values() {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for nested in values {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn migrate_v6(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(6)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
|
||||
@@ -87,8 +87,15 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
if let Some(id) = operation
|
||||
.as_mut()
|
||||
.and_then(|operation| operation.allocation.as_mut())
|
||||
{
|
||||
visit(id);
|
||||
}
|
||||
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
@@ -150,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(&mut target);
|
||||
r.body.set_endpoints(source, target);
|
||||
match &mut r.body {
|
||||
RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} => {
|
||||
visit(owner);
|
||||
for id in source.references.iter_mut().chain(&mut target.references) {
|
||||
visit(id);
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Exchange { exchange } => visit(exchange),
|
||||
ContextualLinkPurpose::CommonWork { work } => visit(work),
|
||||
ContextualLinkPurpose::Precedence { .. } => {}
|
||||
}
|
||||
}
|
||||
RelationBody::StateTransition {
|
||||
triggers, effects, ..
|
||||
} => {
|
||||
@@ -274,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
});
|
||||
if removed.contains(&r.source())
|
||||
|| removed.contains(&r.target())
|
||||
|| (r.kind() == RelationKind::ContextualLink
|
||||
&& relation_references(r).iter().any(|id| removed.contains(id)))
|
||||
|| port_removed
|
||||
|| work_allocation_removed
|
||||
{
|
||||
@@ -444,8 +469,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
if let Some(operation) = operation {
|
||||
clear(&mut operation.allocation);
|
||||
}
|
||||
clear(exchange);
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
|
||||
@@ -81,6 +81,35 @@ impl Own {
|
||||
}
|
||||
let kind = enum_tag(value)?.to_string();
|
||||
prefix.push(kind.clone());
|
||||
let mut whole = whole;
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = project.elements.get(&id).and_then(Element::shared)
|
||||
&& project.layer_of(id) == Some(Layer::Sa)
|
||||
&& crate::validation::ancestor_of_kind(project, id, Kind::Scenario)
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
.is_some_and(|body| {
|
||||
matches!(
|
||||
body,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut operation_path = prefix.clone();
|
||||
operation_path.extend(["body".into(), "Exchange".into()]);
|
||||
if let Some(body) = at_mut(&mut whole, &operation_path).and_then(Value::as_object_mut) {
|
||||
body.entry("operation").or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
whole,
|
||||
prefix,
|
||||
@@ -163,6 +192,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Occurrence(OccurrenceFacet::ReplyTo) => {
|
||||
("Occurrence", &["body", "Exchange", "reply_to"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Operation) => {
|
||||
("Occurrence", &["body", "Exchange", "operation"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
|
||||
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
|
||||
("Occurrence", &["body", "Action", "lifeline"])
|
||||
@@ -192,8 +224,12 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
|
||||
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
|
||||
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
|
||||
F::Relation(RelationFacet::Source) => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]),
|
||||
F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]),
|
||||
F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]),
|
||||
F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]),
|
||||
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
|
||||
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
|
||||
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
|
||||
@@ -926,6 +962,7 @@ mod coverage {
|
||||
] {
|
||||
add(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: Some(id),
|
||||
receiver: Some(id),
|
||||
|
||||
@@ -171,6 +171,31 @@ impl Historical for LifelineBinding {
|
||||
w.at("local", |w| self.local.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for OperationUse {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.at("allocation", |w| self.allocation.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for OccurrenceEndpoint {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH {
|
||||
w.error("historical contextual path depth exceeded");
|
||||
}
|
||||
w.at("references", |w| self.references.walk(w));
|
||||
w.at("occurrence", |w| self.occurrence.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for ContextualLinkPurpose {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))),
|
||||
Self::Exchange { exchange } => {
|
||||
w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w)))
|
||||
}
|
||||
Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for DataValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
|
||||
@@ -518,7 +518,11 @@ pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector)
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
|
||||
facet,
|
||||
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
|
||||
F::Occurrence(
|
||||
OccurrenceFacet::MessageKind
|
||||
| OccurrenceFacet::TimerExpression
|
||||
| OccurrenceFacet::Operation
|
||||
)
|
||||
),
|
||||
F::Event(EventFacet::Constructor) => matches!(
|
||||
facet,
|
||||
|
||||
@@ -112,6 +112,7 @@ macro_rules! scalar_families {
|
||||
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
|
||||
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
|
||||
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
|
||||
Operation: Option<OperationUse>,
|
||||
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
|
||||
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
|
||||
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
|
||||
@@ -129,6 +130,8 @@ macro_rules! scalar_families {
|
||||
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
|
||||
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
|
||||
Guard: String, Effect: String, StateActionPhase: StateActionKind
|
||||
, ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint,
|
||||
ContextPurpose: ContextualLinkPurpose
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -660,6 +660,7 @@ fn apply_command(
|
||||
.unwrap();
|
||||
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(*exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -1553,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
|
||||
return element.parent;
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
return Some(relation.source());
|
||||
return Some(
|
||||
relation
|
||||
.contextual_owner()
|
||||
.unwrap_or_else(|| relation.source()),
|
||||
);
|
||||
}
|
||||
if let Some(artifact) = project.artifact_links.get(&id) {
|
||||
return Some(artifact.owner);
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Caller-qualified process/chain addresses. Cache residency never affects admission.
|
||||
use crate::*;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
};
|
||||
|
||||
mod analysis;
|
||||
pub use analysis::*;
|
||||
pub const MAX_REFERENCE_DEPTH: usize = 64;
|
||||
pub const MAX_ANALYSIS_NODES: usize = 50_000;
|
||||
pub const MAX_ANALYSIS_EDGES: usize = 100_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContextAddress {
|
||||
pub owner: Id,
|
||||
pub endpoint: OccurrenceEndpoint,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOccurrence {
|
||||
pub address: ContextAddress,
|
||||
pub defining_sequence: Id,
|
||||
pub work: Option<Id>,
|
||||
pub exchange: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct ContextError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
fn failure(code: &'static str, message: &str) -> ContextError {
|
||||
ContextError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
struct Record {
|
||||
parent: Option<Id>,
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
reference: Option<Id>,
|
||||
work: Option<Id>,
|
||||
exchange: Option<Id>,
|
||||
contribution: bool,
|
||||
}
|
||||
pub struct TopologyIndex<'a> {
|
||||
project: &'a Project,
|
||||
records: BTreeMap<Id, Record>,
|
||||
children: BTreeMap<Id, Vec<Id>>,
|
||||
link_definitions: BTreeMap<Id, Vec<Id>>,
|
||||
contextual_callers: BTreeSet<Id>,
|
||||
cache: RefCell<BTreeMap<ContextAddress, ResolvedOccurrence>>,
|
||||
cache_capacity: usize,
|
||||
}
|
||||
impl<'a> TopologyIndex<'a> {
|
||||
pub fn new(project: &'a Project) -> Self {
|
||||
Self::with_cache_capacity(project, 4096)
|
||||
}
|
||||
pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self {
|
||||
let records: BTreeMap<_, _> = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| {
|
||||
let (reference, work, exchange, contribution) = match e.shared() {
|
||||
Some(SharedBody::Occurrence { body, .. }) => match body {
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
(*sequence, None, None, true)
|
||||
}
|
||||
OccurrenceBody::Action { action, .. } => (None, *action, None, true),
|
||||
OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true),
|
||||
OccurrenceBody::Control { .. } => (None, None, None, true),
|
||||
_ => (None, None, None, false),
|
||||
},
|
||||
_ => (None, None, None, false),
|
||||
};
|
||||
(
|
||||
e.id,
|
||||
Record {
|
||||
parent: e.parent,
|
||||
layer: e.layer(),
|
||||
kind: e.kind(),
|
||||
reference,
|
||||
work,
|
||||
exchange,
|
||||
contribution,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut children: BTreeMap<_, _> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| e.steps().map(|steps| (e.id, steps.clone())))
|
||||
.collect();
|
||||
let mut child_sets: BTreeMap<_, BTreeSet<_>> = children
|
||||
.iter()
|
||||
.map(|(owner, children)| (*owner, children.iter().copied().collect()))
|
||||
.collect();
|
||||
for (id, record) in &records {
|
||||
if record.contribution
|
||||
&& let Some(parent) = record.parent
|
||||
{
|
||||
let list = children.entry(parent).or_default();
|
||||
if child_sets.entry(parent).or_default().insert(*id) {
|
||||
list.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (owner, list) in &mut children {
|
||||
list.retain(|id| {
|
||||
records
|
||||
.get(id)
|
||||
.is_some_and(|r| r.parent == Some(*owner) && r.contribution)
|
||||
});
|
||||
}
|
||||
let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut contextual_callers = BTreeSet::new();
|
||||
for relation in project.relations.values() {
|
||||
let owner = relation.contextual_owner().or_else(|| {
|
||||
if relation.kind() == RelationKind::SequenceLink {
|
||||
records.get(&relation.source()).and_then(|r| r.parent)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(owner) = owner {
|
||||
link_definitions.entry(owner).or_default().push(relation.id);
|
||||
}
|
||||
if let Some(owner) = relation.contextual_owner() {
|
||||
contextual_callers.insert(owner);
|
||||
}
|
||||
}
|
||||
let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for record in records.values() {
|
||||
if let (Some(parent), Some(reference)) = (record.parent, record.reference) {
|
||||
callers.entry(reference).or_default().push(parent);
|
||||
}
|
||||
}
|
||||
let mut pending: Vec<_> = contextual_callers.iter().copied().collect();
|
||||
while let Some(sequence) = pending.pop() {
|
||||
for caller in callers.get(&sequence).into_iter().flatten() {
|
||||
if contextual_callers.insert(*caller) {
|
||||
pending.push(*caller);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project,
|
||||
records,
|
||||
children,
|
||||
link_definitions,
|
||||
contextual_callers,
|
||||
cache: RefCell::new(BTreeMap::new()),
|
||||
cache_capacity,
|
||||
}
|
||||
}
|
||||
/// Resolve a caller path while an endpoint leaf is still being edited.
|
||||
pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result<Id, ContextError> {
|
||||
if references.len() > MAX_REFERENCE_DEPTH {
|
||||
return Err(failure(
|
||||
"context_depth",
|
||||
"A contextual path exceeds 64 references.",
|
||||
));
|
||||
}
|
||||
let root = self.records.get(&owner).ok_or_else(|| {
|
||||
failure(
|
||||
"context_owner",
|
||||
"The caller must be an existing process or functional chain.",
|
||||
)
|
||||
})?;
|
||||
if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) {
|
||||
return Err(failure(
|
||||
"context_owner",
|
||||
"The caller must be a process or functional chain.",
|
||||
));
|
||||
}
|
||||
let mut current = owner;
|
||||
let mut visited = BTreeSet::from([owner]);
|
||||
for step in references {
|
||||
let reference = self.records.get(step).ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"A reference occurrence in the endpoint path no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if reference.parent != Some(current) || reference.layer != root.layer {
|
||||
return Err(failure(
|
||||
"context_path",
|
||||
"Each reference occurrence must belong directly to the preceding caller context.",
|
||||
));
|
||||
}
|
||||
let next = reference.reference.ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"Each path component must be a resolved sequence-reference occurrence.",
|
||||
)
|
||||
})?;
|
||||
let target = self.records.get(&next).ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"A referenced process or chain no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if target.layer != root.layer || target.kind != root.kind {
|
||||
return Err(failure(
|
||||
"context_path",
|
||||
"References must resolve to sequences of the same kind and layer.",
|
||||
));
|
||||
}
|
||||
if !visited.insert(next) {
|
||||
return Err(failure(
|
||||
"context_cycle",
|
||||
"A contextual endpoint cannot traverse recursive sequence reuse.",
|
||||
));
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
pub fn resolve(
|
||||
&self,
|
||||
owner: Id,
|
||||
endpoint: &OccurrenceEndpoint,
|
||||
) -> Result<ResolvedOccurrence, ContextError> {
|
||||
if endpoint.references.len() > MAX_REFERENCE_DEPTH {
|
||||
return Err(failure(
|
||||
"context_depth",
|
||||
"A contextual endpoint exceeds the 64-reference depth limit.",
|
||||
));
|
||||
}
|
||||
let address = ContextAddress {
|
||||
owner,
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
if let Some(cached) = self.cache.borrow().get(&address) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let current = self.resolve_scope(owner, &endpoint.references)?;
|
||||
let root = &self.records[&owner];
|
||||
let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| {
|
||||
failure(
|
||||
"context_leaf",
|
||||
"The endpoint contribution no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution {
|
||||
return Err(failure(
|
||||
"context_leaf",
|
||||
"The endpoint must be a direct contribution of its resolved process or chain.",
|
||||
));
|
||||
}
|
||||
let resolved = ResolvedOccurrence {
|
||||
address: address.clone(),
|
||||
defining_sequence: current,
|
||||
work: leaf.work,
|
||||
exchange: leaf.exchange,
|
||||
};
|
||||
if self.cache_capacity > 0 {
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
if cache.len() >= self.cache_capacity {
|
||||
cache.pop_first();
|
||||
}
|
||||
cache.insert(address, resolved.clone());
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
/// Validate a caller-owned semantic connection, independent of graphical expansion.
|
||||
pub fn validate_link(
|
||||
&self,
|
||||
owner: Id,
|
||||
source: &OccurrenceEndpoint,
|
||||
target: &OccurrenceEndpoint,
|
||||
purpose: &ContextualLinkPurpose,
|
||||
) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> {
|
||||
let a = self.resolve(owner, source)?;
|
||||
let b = self.resolve(owner, target)?;
|
||||
if a.address == b.address {
|
||||
return Err(failure(
|
||||
"context_self",
|
||||
"A contextual link needs two distinct occurrence addresses.",
|
||||
));
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
if source.references.is_empty() && target.references.is_empty() {
|
||||
return Err(failure(
|
||||
"context_direct_precedence",
|
||||
"Use a SequenceLink for precedence between two direct contributions.",
|
||||
));
|
||||
}
|
||||
if !validation::valid_text(guard) {
|
||||
return Err(failure(
|
||||
"invalid_text",
|
||||
"The precedence guard contains invalid text.",
|
||||
));
|
||||
}
|
||||
let previous = a.exchange.and_then(|id| self.project.relations.get(&id));
|
||||
let next = b.exchange.and_then(|id| self.project.relations.get(&id));
|
||||
let mismatch = match (a.work, previous, b.work, next) {
|
||||
(Some(work), _, _, Some(exchange)) => work != exchange.source(),
|
||||
(_, Some(exchange), Some(work), _) => exchange.target() != work,
|
||||
(_, Some(previous), _, Some(next)) => previous.target() != next.source(),
|
||||
_ => false,
|
||||
};
|
||||
if mismatch {
|
||||
return Err(failure(
|
||||
"sequence_exchange_endpoint",
|
||||
"Adjacent work/exchange contributions must agree with the referenced exchange endpoints.",
|
||||
));
|
||||
}
|
||||
}
|
||||
ContextualLinkPurpose::Exchange { exchange } => {
|
||||
let relation = self.project.relations.get(exchange).ok_or_else(|| {
|
||||
failure("context_exchange", "Choose an existing work exchange.")
|
||||
})?;
|
||||
let expected = if self.project.layer_of(owner) == Some(Layer::Oa) {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
};
|
||||
if relation.kind() != expected
|
||||
|| self.project.layer_of(*exchange) != self.project.layer_of(owner)
|
||||
|| a.work != Some(relation.source())
|
||||
|| b.work != Some(relation.target())
|
||||
{
|
||||
return Err(failure(
|
||||
"context_exchange",
|
||||
"The selected same-layer exchange must connect the exact work referenced by the source and target Actions.",
|
||||
));
|
||||
}
|
||||
}
|
||||
ContextualLinkPurpose::CommonWork { work } => {
|
||||
if !self.project.elements.get(work).is_some_and(|e| {
|
||||
e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner)
|
||||
}) || a.work != Some(*work)
|
||||
|| b.work != Some(*work)
|
||||
{
|
||||
return Err(failure(
|
||||
"context_common_work",
|
||||
"Both Actions must reference the exact same existing work in this layer.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
/// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree.
|
||||
pub fn contributions(&self, sequence: Id) -> Vec<Id> {
|
||||
self.children.get(&sequence).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "analysis_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ReferenceBoundary {
|
||||
Entry,
|
||||
Exit,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContextVertex {
|
||||
pub address: ContextAddress,
|
||||
pub boundary: Option<ReferenceBoundary>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContextEdgeKind {
|
||||
Authored,
|
||||
SyntheticBoundary,
|
||||
LegacyOrder,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextEdge {
|
||||
pub relation: Option<Id>,
|
||||
pub instance: Vec<Id>,
|
||||
pub source: ContextVertex,
|
||||
pub target: ContextVertex,
|
||||
pub guard: String,
|
||||
pub kind: ContextEdgeKind,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextAssembly {
|
||||
pub relation: Id,
|
||||
pub instance: Vec<Id>,
|
||||
pub source: ContextAddress,
|
||||
pub target: ContextAddress,
|
||||
pub purpose: ContextualLinkPurpose,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextNotice {
|
||||
pub address: ContextAddress,
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
pub relations: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AnalysisLimits {
|
||||
pub nodes: usize,
|
||||
pub edges: usize,
|
||||
}
|
||||
impl Default for AnalysisLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nodes: MAX_ANALYSIS_NODES,
|
||||
edges: MAX_ANALYSIS_EDGES,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextAnalysis {
|
||||
pub nodes: BTreeMap<ContextAddress, ResolvedOccurrence>,
|
||||
pub precedence: Vec<ContextEdge>,
|
||||
pub assembly: Vec<ContextAssembly>,
|
||||
pub entries: Vec<ContextVertex>,
|
||||
pub exits: Vec<ContextVertex>,
|
||||
pub notices: Vec<ContextNotice>,
|
||||
/// A partial expansion must never be used for complete reachability conclusions.
|
||||
pub complete: bool,
|
||||
}
|
||||
struct Builder<'a, 'p> {
|
||||
index: &'a TopologyIndex<'p>,
|
||||
root: Id,
|
||||
limits: AnalysisLimits,
|
||||
result: ContextAnalysis,
|
||||
definitions: BTreeMap<Id, Vec<&'p Relation>>,
|
||||
examined_edges: usize,
|
||||
exhausted: bool,
|
||||
}
|
||||
impl TopologyIndex<'_> {
|
||||
pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis {
|
||||
self.analyze_counted(owner, limits).0
|
||||
}
|
||||
fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) {
|
||||
let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for (owner, relations) in &self.link_definitions {
|
||||
definitions.insert(
|
||||
*owner,
|
||||
relations
|
||||
.iter()
|
||||
.filter_map(|id| self.project.relations.get(id))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let mut b = Builder {
|
||||
index: self,
|
||||
root: owner,
|
||||
limits,
|
||||
result: ContextAnalysis {
|
||||
complete: true,
|
||||
..ContextAnalysis::default()
|
||||
},
|
||||
definitions,
|
||||
examined_edges: 0,
|
||||
exhausted: false,
|
||||
};
|
||||
(b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new());
|
||||
if b.result.complete {
|
||||
b.cardinality();
|
||||
b.connectivity();
|
||||
}
|
||||
(b.result, b.examined_edges)
|
||||
}
|
||||
pub fn has_contextual_links(&self, owner: Id) -> bool {
|
||||
self.contextual_callers.contains(&owner)
|
||||
}
|
||||
}
|
||||
impl Builder<'_, '_> {
|
||||
fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress {
|
||||
ContextAddress {
|
||||
owner: self.root,
|
||||
endpoint: OccurrenceEndpoint {
|
||||
references: path.to_vec(),
|
||||
occurrence,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex {
|
||||
let reference = self
|
||||
.index
|
||||
.project
|
||||
.elements
|
||||
.get(&address.endpoint.occurrence)
|
||||
.and_then(Element::shared)
|
||||
.is_some_and(|s| {
|
||||
matches!(
|
||||
s,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
ContextVertex {
|
||||
address,
|
||||
boundary: reference.then_some(if source {
|
||||
ReferenceBoundary::Exit
|
||||
} else {
|
||||
ReferenceBoundary::Entry
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) {
|
||||
self.result.complete = false;
|
||||
if !self
|
||||
.result
|
||||
.notices
|
||||
.iter()
|
||||
.any(|n| n.address == address && n.code == code)
|
||||
{
|
||||
self.result.notices.push(ContextNotice {
|
||||
address,
|
||||
code,
|
||||
message: message.into(),
|
||||
relations: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reserve work before resolving an authored endpoint, not merely before storing
|
||||
// its output. Repeated malformed or filtered endpoints consume the same budget.
|
||||
fn reserve_edge(&mut self, address: ContextAddress) -> bool {
|
||||
if self.exhausted {
|
||||
return false;
|
||||
}
|
||||
if self.examined_edges >= self.limits.edges {
|
||||
self.exhausted = true;
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_limit",
|
||||
"Contextual edge limit reached; connectivity analysis is incomplete.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.examined_edges += 1;
|
||||
true
|
||||
}
|
||||
fn edge(&mut self, edge: ContextEdge) -> bool {
|
||||
if !self.reserve_edge(edge.source.address.clone()) {
|
||||
return false;
|
||||
}
|
||||
self.result.precedence.push(edge);
|
||||
true
|
||||
}
|
||||
fn expand(
|
||||
&mut self,
|
||||
sequence: Id,
|
||||
prefix: &[Id],
|
||||
active: &mut BTreeSet<Id>,
|
||||
) -> (Vec<ContextVertex>, Vec<ContextVertex>) {
|
||||
if self.exhausted {
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) {
|
||||
self.incomplete(
|
||||
self.address(prefix, sequence),
|
||||
"context_analysis_incomplete",
|
||||
"Recursive or over-depth reuse has no proven boundary path.",
|
||||
);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let children = self.index.contributions(sequence);
|
||||
if children.is_empty() {
|
||||
self.incomplete(
|
||||
self.address(prefix, sequence),
|
||||
"context_analysis_incomplete",
|
||||
"An empty or unresolved sequence has no proven boundary path.",
|
||||
);
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let start = self.result.precedence.len();
|
||||
for id in &children {
|
||||
let address = self.address(prefix, *id);
|
||||
if self.result.nodes.len() >= self.limits.nodes {
|
||||
self.exhausted = true;
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_limit",
|
||||
"Contextual node limit reached; connectivity analysis is incomplete.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
match self.index.resolve(self.root, &address.endpoint) {
|
||||
Ok(resolved) => {
|
||||
self.result.nodes.insert(address.clone(), resolved);
|
||||
}
|
||||
Err(e) => {
|
||||
self.incomplete(address, "context_analysis_incomplete", &e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let reference = self.index.project.elements[id]
|
||||
.shared()
|
||||
.and_then(|s| match s {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => Some(*sequence),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(reference) = reference {
|
||||
let Some(callee) = reference else {
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_incomplete",
|
||||
"An unresolved reference has no proven boundary path.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut path = prefix.to_vec();
|
||||
path.push(*id);
|
||||
let (entries, exits) = self.expand(callee, &path, active);
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if entries.is_empty() || exits.is_empty() {
|
||||
self.incomplete(
|
||||
address.clone(),
|
||||
"context_analysis_incomplete",
|
||||
"No referenced entry or exit is proven; no bypass is inferred.",
|
||||
);
|
||||
}
|
||||
for target in entries {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(address.clone(), false),
|
||||
target,
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::SyntheticBoundary,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for source in exits {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source,
|
||||
target: self.vertex(address.clone(), true),
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::SyntheticBoundary,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let relations = self.definitions.get(&sequence).cloned().unwrap_or_default();
|
||||
for r in &relations {
|
||||
if !self.reserve_edge(self.address(prefix, r.source())) {
|
||||
break;
|
||||
}
|
||||
let (source, target, purpose) = match &r.body {
|
||||
RelationBody::SequenceLink {
|
||||
source,
|
||||
target,
|
||||
guard,
|
||||
} => (
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: *source,
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: *target,
|
||||
},
|
||||
ContextualLinkPurpose::Precedence {
|
||||
guard: guard.clone(),
|
||||
},
|
||||
),
|
||||
RelationBody::ContextualLink {
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
..
|
||||
} => (source.clone(), target.clone(), purpose.clone()),
|
||||
_ => continue,
|
||||
};
|
||||
let qualify = |mut endpoint: OccurrenceEndpoint| {
|
||||
let mut path = prefix.to_vec();
|
||||
path.extend(endpoint.references);
|
||||
endpoint.references = path;
|
||||
ContextAddress {
|
||||
owner: self.root,
|
||||
endpoint,
|
||||
}
|
||||
};
|
||||
let source = qualify(source);
|
||||
let target = qualify(target);
|
||||
if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target)
|
||||
{
|
||||
self.incomplete(
|
||||
source,
|
||||
"context_analysis_incomplete",
|
||||
"A link endpoint is unresolved in this bounded analysis.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
self.result.precedence.push(ContextEdge {
|
||||
relation: Some(r.id),
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(source, true),
|
||||
target: self.vertex(target, false),
|
||||
guard,
|
||||
kind: ContextEdgeKind::Authored,
|
||||
})
|
||||
}
|
||||
purpose => {
|
||||
self.result.assembly.push(ContextAssembly {
|
||||
relation: r.id,
|
||||
instance: prefix.to_vec(),
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if relations.is_empty()
|
||||
&& children.iter().all(|id| {
|
||||
matches!(
|
||||
self.index.project.elements[id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
{
|
||||
for pair in children.windows(2) {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(self.address(prefix, pair[0]), true),
|
||||
target: self.vertex(self.address(prefix, pair[1]), false),
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::LegacyOrder,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
// Intrinsic boundaries are determined before any enclosing caller links.
|
||||
let edges = &self.result.precedence[start..];
|
||||
let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect();
|
||||
let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect();
|
||||
let entries = children
|
||||
.iter()
|
||||
.map(|id| self.vertex(self.address(prefix, *id), false))
|
||||
.filter(|v| !targets.contains(v))
|
||||
.collect();
|
||||
let exits = children
|
||||
.iter()
|
||||
.map(|id| self.vertex(self.address(prefix, *id), true))
|
||||
.filter(|v| !sources.contains(v))
|
||||
.collect();
|
||||
active.remove(&sequence);
|
||||
(entries, exits)
|
||||
}
|
||||
fn cardinality(&mut self) {
|
||||
// Index authored incidence once; synthetic boundaries never count as controls.
|
||||
let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for edge in &self.result.precedence {
|
||||
if edge.kind == ContextEdgeKind::Authored {
|
||||
incoming.entry(&edge.target.address).or_default().push(edge);
|
||||
outgoing.entry(&edge.source.address).or_default().push(edge);
|
||||
}
|
||||
}
|
||||
for address in self.result.nodes.keys() {
|
||||
let ins = incoming.get(address).cloned().unwrap_or_default();
|
||||
let outs = outgoing.get(address).cloned().unwrap_or_default();
|
||||
let before = ins.len();
|
||||
let after = outs.len();
|
||||
let control = match self.index.project.elements[&address.endpoint.occurrence].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control { kind },
|
||||
..
|
||||
}) => Some(*kind),
|
||||
_ => None,
|
||||
};
|
||||
let issue = match control {
|
||||
Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) }
|
||||
None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())),
|
||||
};
|
||||
if let Some((code, message)) = issue {
|
||||
self.result.notices.push(ContextNotice {
|
||||
address: address.clone(),
|
||||
code,
|
||||
message,
|
||||
relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(),
|
||||
});
|
||||
}
|
||||
if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) {
|
||||
for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) {
|
||||
self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn connectivity(&mut self) {
|
||||
let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for edge in &self.result.precedence {
|
||||
forward
|
||||
.entry(edge.source.clone())
|
||||
.or_default()
|
||||
.push(edge.target.clone());
|
||||
reverse
|
||||
.entry(edge.target.clone())
|
||||
.or_default()
|
||||
.push(edge.source.clone());
|
||||
}
|
||||
let visit = |starts: &[ContextVertex],
|
||||
graph: &BTreeMap<ContextVertex, Vec<ContextVertex>>| {
|
||||
let mut pending = starts.to_vec();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(vertex) = pending.pop() {
|
||||
if seen.insert(vertex.clone()) {
|
||||
pending.extend(graph.get(&vertex).into_iter().flatten().cloned());
|
||||
}
|
||||
}
|
||||
seen
|
||||
};
|
||||
let from_start = visit(&self.result.entries, &forward);
|
||||
let to_end = visit(&self.result.exits, &reverse);
|
||||
for address in self.result.nodes.keys() {
|
||||
let entry = self.vertex(address.clone(), false);
|
||||
let exit = self.vertex(address.clone(), true);
|
||||
for (missing, code, message) in [
|
||||
(
|
||||
!from_start.contains(&entry),
|
||||
"unreachable_contribution",
|
||||
"No path from a structural caller entry reaches this contribution in this context.",
|
||||
),
|
||||
(
|
||||
!to_end.contains(&exit),
|
||||
"nonterminating_contribution",
|
||||
"No structural caller exit is reachable from this contribution in this context; review the intended loop.",
|
||||
),
|
||||
(
|
||||
self.result.nodes.len() > 1
|
||||
&& !reverse.contains_key(&entry)
|
||||
&& !forward.contains_key(&exit),
|
||||
"isolated_contribution",
|
||||
"This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.",
|
||||
),
|
||||
] {
|
||||
if missing {
|
||||
self.result.notices.push(ContextNotice {
|
||||
address: address.clone(),
|
||||
code,
|
||||
message: message.into(),
|
||||
relations: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::*;
|
||||
|
||||
fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn repeated(references: usize, links: usize) -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let root = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None)
|
||||
.unwrap();
|
||||
let procedure = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None)
|
||||
.unwrap();
|
||||
let mut leaves = Vec::new();
|
||||
for name in ["A", "B"] {
|
||||
let action = p
|
||||
.add_element(Kind::Function, Layer::Sa, name, None)
|
||||
.unwrap();
|
||||
leaves.push(occurrence(
|
||||
&mut p,
|
||||
procedure,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(action),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in 0..links {
|
||||
p.relate(
|
||||
RelationKind::SequenceLink,
|
||||
"Authored A to B",
|
||||
leaves[0],
|
||||
leaves[1],
|
||||
);
|
||||
}
|
||||
for _ in 0..references {
|
||||
occurrence(
|
||||
&mut p,
|
||||
root,
|
||||
"Reuse",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(procedure),
|
||||
},
|
||||
);
|
||||
}
|
||||
(p, root, procedure)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() {
|
||||
// Only ~3,000 possible contribution nodes, but a million authored edge
|
||||
// instances. The edge budget must stop actual work before later references.
|
||||
let (p, root, _) = repeated(1_000, 1_000);
|
||||
let limits = AnalysisLimits::default();
|
||||
let index = TopologyIndex::with_cache_capacity(&p, 1);
|
||||
let (first, attempts) = index.analyze_counted(root, limits);
|
||||
assert_eq!(attempts, limits.edges);
|
||||
assert_eq!(first.precedence.len(), limits.edges);
|
||||
assert!(!first.complete);
|
||||
assert!(first.entries.is_empty() && first.exits.is_empty());
|
||||
assert!(
|
||||
first.nodes.len() < 1_000,
|
||||
"later caller references must not be expanded"
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
first
|
||||
.notices
|
||||
.iter()
|
||||
.all(|n| n.code == "context_analysis_limit")
|
||||
);
|
||||
let (warm, warm_attempts) = index.analyze_counted(root, limits);
|
||||
assert_eq!(warm_attempts, attempts);
|
||||
assert_eq!(warm.nodes, first.nodes);
|
||||
assert_eq!(warm.precedence, first.precedence);
|
||||
assert_eq!(warm.notices, first.notices);
|
||||
drop(warm);
|
||||
let (uncached, uncached_attempts) =
|
||||
TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits);
|
||||
assert_eq!(uncached_attempts, attempts);
|
||||
assert_eq!(uncached.precedence, first.precedence);
|
||||
assert_eq!(uncached.notices, first.notices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_authored_edges_consume_work_budget_without_stored_output() {
|
||||
let (mut p, root, _) = repeated(20, 100);
|
||||
let missing = Id::nil();
|
||||
for relation in p.relations.values_mut() {
|
||||
relation.body.set_endpoints(relation.source(), missing);
|
||||
}
|
||||
let (result, attempts) = TopologyIndex::new(&p).analyze_counted(
|
||||
root,
|
||||
AnalysisLimits {
|
||||
nodes: 1_000,
|
||||
edges: 17,
|
||||
},
|
||||
);
|
||||
assert_eq!(attempts, 17);
|
||||
assert!(result.precedence.is_empty());
|
||||
assert_eq!(result.nodes.len(), 3);
|
||||
assert!(!result.complete);
|
||||
assert!(result.entries.is_empty() && result.exits.is_empty());
|
||||
assert_eq!(
|
||||
result
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() {
|
||||
let (p, root, _) = repeated(1, 2);
|
||||
let index = TopologyIndex::new(&p);
|
||||
let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 });
|
||||
assert!(complete.complete);
|
||||
assert_eq!(count, 4); // two authored edges plus entry and exit connectors
|
||||
assert_eq!(
|
||||
complete
|
||||
.precedence
|
||||
.iter()
|
||||
.filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
for limits in [
|
||||
AnalysisLimits { nodes: 3, edges: 3 },
|
||||
AnalysisLimits {
|
||||
nodes: 1,
|
||||
edges: 10,
|
||||
},
|
||||
] {
|
||||
let (partial, count) = index.analyze_counted(root, limits);
|
||||
assert!(!partial.complete);
|
||||
assert!(partial.entries.is_empty() && partial.exits.is_empty());
|
||||
assert!(count <= limits.edges);
|
||||
assert_eq!(
|
||||
partial
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,12 @@ pub struct CommunicationEndpointDecision {
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationOmission {
|
||||
pub source: Id,
|
||||
pub rationale: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionRequest {
|
||||
pub direction: TransitionDirection,
|
||||
pub sources: Vec<Id>,
|
||||
@@ -91,6 +97,8 @@ pub struct TransitionRequest {
|
||||
pub destination_diagram: Option<Id>,
|
||||
#[serde(default)]
|
||||
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub operation_omissions: Vec<OperationOmission>,
|
||||
}
|
||||
impl TransitionRequest {
|
||||
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
|
||||
@@ -103,6 +111,7 @@ impl TransitionRequest {
|
||||
resolutions: vec![],
|
||||
destination_diagram: None,
|
||||
communication_endpoints: vec![],
|
||||
operation_omissions: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
| RelationBody::MissionInvolvement { .. }
|
||||
| RelationBody::MissionCapability { .. }
|
||||
| RelationBody::SequenceLink { .. }
|
||||
| RelationBody::ContextualLink { .. }
|
||||
| RelationBody::ChainInvolvement { .. }
|
||||
| RelationBody::BoundaryExchange { .. }
|
||||
| RelationBody::InterfaceProvision { .. }
|
||||
@@ -500,7 +510,16 @@ pub fn plan_transition(
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
let mut dependencies = e.clone();
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = dependencies.shared_mut()
|
||||
{
|
||||
*operation = None;
|
||||
}
|
||||
selected.extend(crate::references::element_references(&dependencies));
|
||||
// Allocations are owned by interfaces. Independent data
|
||||
// associations travel only when both endpoint types are
|
||||
// already required; they do not expand the dictionary.
|
||||
@@ -523,12 +542,13 @@ pub fn plan_transition(
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
selected.extend(project.relations.values().filter_map(|relation| {
|
||||
(relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id))
|
||||
(relation.contextual_owner() == Some(id)
|
||||
|| (relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id)))
|
||||
.then_some(relation.id)
|
||||
}));
|
||||
}
|
||||
@@ -594,6 +614,67 @@ pub fn plan_transition(
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
let mut operation_omissions = BTreeSet::new();
|
||||
for decision in &request.operation_omissions {
|
||||
if request.direction != TransitionDirection::SaToOa
|
||||
|| !selected.contains(&decision.source)
|
||||
|| !matches!(
|
||||
project
|
||||
.elements
|
||||
.get(&decision.source)
|
||||
.and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
|| decision.rationale.trim().is_empty()
|
||||
|| !validation::valid_text(&decision.rationale)
|
||||
|| !operation_omissions.insert(decision.source)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(decision.source),
|
||||
None,
|
||||
None,
|
||||
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
|
||||
);
|
||||
} else {
|
||||
plan.warnings.push(format!(
|
||||
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
|
||||
project.named(decision.source),
|
||||
decision.source,
|
||||
decision.rationale
|
||||
));
|
||||
}
|
||||
}
|
||||
if request.direction == TransitionDirection::SaToOa {
|
||||
for source in &selected {
|
||||
if matches!(
|
||||
project.elements.get(source).and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) && !operation_omissions.contains(source)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::new();
|
||||
for mapping in &request.mappings {
|
||||
if choices.insert(mapping.source, mapping).is_some() {
|
||||
@@ -787,6 +868,15 @@ pub fn plan_transition(
|
||||
let mut created = e.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& operation_omissions.contains(source)
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = created.shared_mut()
|
||||
{
|
||||
*operation = None;
|
||||
}
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
|
||||
@@ -182,6 +182,7 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let coverage = coverage::Index::new(p);
|
||||
let topology_index = crate::topology_context::TopologyIndex::new(p);
|
||||
if p.schema_version != SCHEMA_VERSION {
|
||||
error(
|
||||
&mut out,
|
||||
@@ -383,7 +384,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
"Add ordered occurrences.",
|
||||
);
|
||||
}
|
||||
validate_sequence(p, e, steps, &mut out);
|
||||
validate_sequence(p, e, steps, &topology_index, &mut out);
|
||||
}
|
||||
if let Some(shared) = e.shared() {
|
||||
match shared {
|
||||
@@ -600,7 +601,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
validate_relation(p, r, &topology_index, &mut out);
|
||||
crate::data::validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
@@ -950,10 +951,57 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
|
||||
fn validate_sequence(
|
||||
p: &Project,
|
||||
e: &Element,
|
||||
steps: &[Id],
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return;
|
||||
}
|
||||
if topology.has_contextual_links(e.id) {
|
||||
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
|
||||
for notice in analysis.notices {
|
||||
let breadcrumb = notice
|
||||
.address
|
||||
.endpoint
|
||||
.references
|
||||
.iter()
|
||||
.chain(std::iter::once(¬ice.address.endpoint.occurrence))
|
||||
.map(|id| format!("{} ({id})", p.named(*id)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
notice.code,
|
||||
"steps",
|
||||
format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message),
|
||||
);
|
||||
}
|
||||
if analysis.complete {
|
||||
for (code, label, count) in [
|
||||
("sequence_starts", "start", analysis.entries.len()),
|
||||
("sequence_ends", "end", analysis.exits.len()),
|
||||
] {
|
||||
if count != 1 {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
code,
|
||||
"steps",
|
||||
format!(
|
||||
"Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.",
|
||||
e.name
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let links: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
@@ -1554,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return;
|
||||
@@ -1766,6 +1815,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
let operation = crate::operations::resolve(p, e.id);
|
||||
let signature = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Selected(selected)) => {
|
||||
if selected.transmission == TransmissionProtocol::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"operation_transmission_unspecified",
|
||||
"operation",
|
||||
"The selected operation transmission protocol is unspecified.",
|
||||
);
|
||||
}
|
||||
Some(crate::operations::signature_relation(selected))
|
||||
}
|
||||
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
|
||||
warning(out, e.id, "operation_unresolved", "operation", reason);
|
||||
None
|
||||
}
|
||||
Err(problem) => {
|
||||
error(out, e.id, problem.code, "operation", &problem.message);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let value_relation = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Ordinary) => relation,
|
||||
_ => signature.as_ref(),
|
||||
};
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
@@ -1811,10 +1888,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if matches!(
|
||||
operation,
|
||||
Ok(crate::operations::OperationResolution::Unresolved { .. })
|
||||
) && let Some(owner) = value
|
||||
.path
|
||||
.first()
|
||||
.or(Some(&value.field))
|
||||
.and_then(|id| p.elements.get(id))
|
||||
.and_then(|field| field.parent)
|
||||
{
|
||||
let path_context = Relation {
|
||||
id: e.id,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: e.id,
|
||||
target: e.id,
|
||||
items: vec![owner],
|
||||
},
|
||||
};
|
||||
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
|
||||
{
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_path",
|
||||
"values.path",
|
||||
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(r) = value_relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Asynchronous if signature.is_some() => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
@@ -2254,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
false
|
||||
}
|
||||
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
fn validate_relation(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
{
|
||||
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
|
||||
error(out, r.id, problem.code, "body", problem.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
@@ -2632,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
if kind == RelationKind::StateTransition {
|
||||
for endpoint in [source, target] {
|
||||
@@ -2662,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
|
||||
},
|
||||
};
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
!issues.iter().any(|i| i.severity == Severity::Error)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Persisted view intent and pure visibility decisions over actual model identities.
|
||||
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
|
||||
//! set unions/reachability once; testing an edge never walks the model.
|
||||
use crate::{Id, Kind, Layer, OccurrenceBody, Project, RelationKind, SharedBody};
|
||||
use crate::{
|
||||
ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind,
|
||||
SharedBody,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -401,6 +404,25 @@ impl BehaviorIndex {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let topology = crate::topology_context::TopologyIndex::new(p);
|
||||
for relation in p.relations.values() {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose: purpose @ ContextualLinkPurpose::Exchange { exchange },
|
||||
} = &relation.body
|
||||
&& topology
|
||||
.validate_link(*owner, source, target, purpose)
|
||||
.is_ok()
|
||||
{
|
||||
let facts = index.facts.entry(*owner).or_default();
|
||||
facts.exchanges.insert(*exchange);
|
||||
if let Some(exchange) = p.relations.get(exchange) {
|
||||
facts.work.extend([exchange.source(), exchange.target()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
|
||||
|
||||
fn exchange(id: Id) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -22,6 +22,7 @@ fn message(
|
||||
reply: Option<Id>,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
|
||||
@@ -71,6 +71,7 @@ fn message(
|
||||
reply_to: Option<Id>,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(mean),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
@@ -110,7 +111,7 @@ fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_
|
||||
valid(&p);
|
||||
let text = language::format(&p);
|
||||
assert!(text.contains("communication_mean"));
|
||||
assert!(text.starts_with("arcadia 6;"));
|
||||
assert!(text.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
assert_eq!(language::parse(&text).unwrap(), p);
|
||||
let bytes = serde_json::to_vec(&p).unwrap();
|
||||
assert_eq!(persistence::decode(&bytes).unwrap(), p);
|
||||
@@ -122,8 +123,12 @@ fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_
|
||||
"schema {version}"
|
||||
);
|
||||
assert!(
|
||||
language::parse(&text.replacen("arcadia 6;", &format!("arcadia {version};"), 1))
|
||||
.is_err(),
|
||||
language::parse(&text.replacen(
|
||||
&format!("arcadia {SCHEMA_VERSION};"),
|
||||
&format!("arcadia {version};"),
|
||||
1
|
||||
))
|
||||
.is_err(),
|
||||
"source {version}"
|
||||
);
|
||||
}
|
||||
@@ -158,8 +163,11 @@ fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia()
|
||||
assert_eq!(result.migration.unwrap().from_schema, 5);
|
||||
let mut without_views = p.clone();
|
||||
without_views.diagrams.clear();
|
||||
let old =
|
||||
language::format(&without_views).replacen("arcadia 6;", "arcadia // old contract\n5;", 1);
|
||||
let old = language::format(&without_views).replacen(
|
||||
&format!("arcadia {SCHEMA_VERSION};"),
|
||||
"arcadia // old contract\n5;",
|
||||
1,
|
||||
);
|
||||
let bundle = source_project::SourceBundle {
|
||||
entry: "entry.arcadia".into(),
|
||||
files: BTreeMap::from([("entry.arcadia".into(), old.clone())]),
|
||||
@@ -168,7 +176,7 @@ fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia()
|
||||
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.bundle.files["entry.arcadia"],
|
||||
old.replacen("\n5;", "\n6;", 1)
|
||||
old.replacen("\n5;", &format!("\n{SCHEMA_VERSION};"), 1)
|
||||
);
|
||||
assert_eq!(compiled.decoded.project, without_views);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
|
||||
}
|
||||
fn exchange(id: Id) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -183,6 +184,7 @@ fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layer
|
||||
&mut p,
|
||||
branch,
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(e),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
@@ -195,6 +197,7 @@ fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layer
|
||||
&mut p,
|
||||
branch,
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(e),
|
||||
sender: Some(receiver),
|
||||
receiver: Some(sender),
|
||||
|
||||
@@ -363,6 +363,49 @@ project mission "Harbor safety — 港 🌊" {
|
||||
family: oa_core::view_options::ExchangeFamily::Participant,
|
||||
exchanges: vec![means],
|
||||
};
|
||||
let caller = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Common work caller", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Activity)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut occurrences = vec![];
|
||||
for name in ["First contribution", "Second contribution"] {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
occurrences.push(id);
|
||||
}
|
||||
*p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone();
|
||||
let id = p.relate(
|
||||
RelationKind::ContextualLink,
|
||||
"Shared work incidence",
|
||||
occurrences[0],
|
||||
occurrences[1],
|
||||
);
|
||||
p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: occurrences[0],
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: occurrences[1],
|
||||
},
|
||||
purpose: ContextualLinkPurpose::CommonWork { work },
|
||||
};
|
||||
p
|
||||
}
|
||||
|
||||
@@ -515,7 +558,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 7; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 8; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -1219,11 +1262,10 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
|| code.trim_start().starts_with("arcadia 6;")
|
||||
{
|
||||
if (1..=SCHEMA_VERSION).any(|version| {
|
||||
code.trim_start()
|
||||
.starts_with(&format!("arcadia {version};"))
|
||||
}) {
|
||||
read(&code);
|
||||
examples += 1;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 6;"));
|
||||
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
|
||||
@@ -102,6 +102,7 @@ fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -120,7 +120,7 @@ fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
|
||||
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
|
||||
assert_eq!(
|
||||
compiled.bundle.files["project.arcadia"],
|
||||
entry.replacen("arcadia 4;", "arcadia 6;", 1)
|
||||
entry.replacen("arcadia 4;", &format!("arcadia {SCHEMA_VERSION};"), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.index.lexical_owners
|
||||
|
||||
@@ -515,6 +515,7 @@ fn payload(p: &mut Project, layer: Layer, prefix: &str) -> Payload {
|
||||
scenario,
|
||||
&format!("{prefix} message"),
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -131,7 +131,7 @@ fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories(
|
||||
]);
|
||||
assert!(!model.contains(id(90)));
|
||||
let source = language::format(&model);
|
||||
assert!(source.starts_with("arcadia 6;"));
|
||||
assert!(source.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
assert!(
|
||||
source.contains(&format!("Named uuid \"{}\"", id(90))),
|
||||
"{source}"
|
||||
|
||||
@@ -91,6 +91,7 @@ fn message(
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -74,7 +74,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 6;"));
|
||||
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
|
||||
@@ -26,7 +26,9 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files.len(), 2);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 6;"));
|
||||
assert!(
|
||||
compiled.bundle.files["project.arcadia"].contains(&format!("arcadia {SCHEMA_VERSION};"))
|
||||
);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
|
||||
for text in [
|
||||
"// Contracts stay in their original module.",
|
||||
|
||||
@@ -51,6 +51,7 @@ fn message(
|
||||
call: Option<Id>,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange,
|
||||
sender: Some(sender),
|
||||
receiver,
|
||||
|
||||
@@ -271,6 +271,7 @@ fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
|
||||
owner,
|
||||
"Contextual message",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(global),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -372,7 +372,8 @@ fn source6_new_historical_facts_are_rejected_under_source5_even_without_live_cat
|
||||
error.contains("category semantic facts require schema/source 6"),
|
||||
"{error}"
|
||||
);
|
||||
let old = language::format(&p).replacen("arcadia 6;", "arcadia 5;", 1);
|
||||
let old =
|
||||
language::format(&p).replacen(&format!("arcadia {SCHEMA_VERSION};"), "arcadia 5;", 1);
|
||||
assert!(language::parse(&old).unwrap_err().iter().any(|d| {
|
||||
d.message
|
||||
.contains("category semantic facts require schema/source 6")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,9 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
(5, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert!(input.files[&input.entry].contains("arcadia 5;"));
|
||||
assert!(result.bundle.files[&result.bundle.entry].contains("arcadia 6;"));
|
||||
assert!(
|
||||
result.bundle.files[&result.bundle.entry].contains(&format!("arcadia {SCHEMA_VERSION};"))
|
||||
);
|
||||
assert!(
|
||||
again.decoded.migration.is_none(),
|
||||
"stabilized current source must not migrate twice"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
pub(crate) mod architecture_authoring;
|
||||
#[cfg(test)]
|
||||
mod architecture_authoring_tests;
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
@@ -314,6 +317,7 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
CheckpointTick,
|
||||
CheckpointWritten(Id, Result<(), String>),
|
||||
@@ -329,6 +333,7 @@ pub enum Message {
|
||||
ExactStrings(bool),
|
||||
Page(Page),
|
||||
Guide(crate::guide::Event),
|
||||
Traceability(crate::views::traceability::Event),
|
||||
Behavior(crate::behavior::Event),
|
||||
Layer(Layer),
|
||||
Select(Id),
|
||||
@@ -450,6 +455,7 @@ pub struct App {
|
||||
/// Home resumes this page; explicit Architecture navigation remains separate.
|
||||
last_workspace_page: Page,
|
||||
pub guide: crate::guide::State,
|
||||
pub traceability: crate::views::traceability::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
pub inspector_tab: crate::navigation::InspectorTab,
|
||||
@@ -460,6 +466,8 @@ pub struct App {
|
||||
pub active_diagram: Option<Id>,
|
||||
pub search: String,
|
||||
pub create_kind: Kind,
|
||||
pub architecture: Option<architecture_authoring::State>,
|
||||
pub architecture_palette_expanded: bool,
|
||||
pub status: String,
|
||||
pub busy: bool,
|
||||
pub pending: Option<Pending>,
|
||||
@@ -548,6 +556,7 @@ impl App {
|
||||
page: Page::Diagram,
|
||||
last_workspace_page: Page::Diagram,
|
||||
guide: crate::guide::State::default(),
|
||||
traceability: Default::default(),
|
||||
behavior: Default::default(),
|
||||
data_state: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
@@ -557,6 +566,8 @@ impl App {
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
architecture: None,
|
||||
architecture_palette_expanded: false,
|
||||
create_kind: if layer == Layer::Sa {
|
||||
Kind::Function
|
||||
} else {
|
||||
@@ -623,6 +634,7 @@ impl App {
|
||||
}
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.document.dirty()
|
||||
|| self.architecture.is_some()
|
||||
|| self.workflow.is_some()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
@@ -1051,6 +1063,39 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(architecture_authoring::State::unnamed_tool)
|
||||
{
|
||||
self.architecture = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
// Named placement owns input. Passive geometry/checkpoints continue;
|
||||
// navigation and close retain raw input until explicit completion/cancel.
|
||||
if self.architecture.is_some() {
|
||||
if matches!(message, Message::Escape) {
|
||||
self.architecture = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::none();
|
||||
}
|
||||
let placing = self.architecture.as_ref().is_some_and(|s| s.saved.placing);
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::Architecture(_)
|
||||
| Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
) && !(placing && matches!(&message, Message::Pan(_) | Message::Zoom(_)))
|
||||
{
|
||||
self.status = "Finish placing this element or Cancel (Esc) before leaving. Your name is retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
// Scrollable geometry can change while async work or an overlay blocks
|
||||
// commands. Keep the observed translation current; its passive handler
|
||||
// still validates document, scenario and revision before accepting it.
|
||||
@@ -1303,6 +1348,7 @@ impl App {
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
Message::Home(event) => return self.home_event(event),
|
||||
Message::CheckpointTick => return self.checkpoint_tick(),
|
||||
Message::CheckpointWritten(id, result) => {
|
||||
@@ -1718,6 +1764,13 @@ impl App {
|
||||
self.refresh_draft();
|
||||
}
|
||||
}
|
||||
Message::Traceability(event) => {
|
||||
self.traceability.update(event);
|
||||
return iced::widget::operation::snap_to(
|
||||
"traceability-list",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
Message::Search(value) => {
|
||||
self.search = value;
|
||||
self.explorer_page = 0;
|
||||
@@ -1910,6 +1963,10 @@ impl App {
|
||||
if t.request.sources.first().is_some_and(|source|
|
||||
crate::behavior::selected_behavior(self.project(), *source).is_some()));
|
||||
let previous_behavior_context = self.state_view_identity();
|
||||
let requested_destination = match &workflow {
|
||||
crate::workflow::Workflow::Transition(t) => t.request.destination_diagram,
|
||||
_ => None,
|
||||
};
|
||||
match workflow.update(self.project(), event) {
|
||||
Ok(Some(mut candidate)) => {
|
||||
let focus = transition_focus
|
||||
@@ -1932,7 +1989,24 @@ impl App {
|
||||
.and_then(|id| crate::behavior::selected_behavior(&candidate, id));
|
||||
let destination = focus.and_then(|id| {
|
||||
candidate.layer_of(id).and_then(|layer| {
|
||||
preferred_diagram(&candidate, self.active_diagram, layer, id)
|
||||
requested_destination
|
||||
.filter(|view| {
|
||||
candidate.diagrams.get(view).is_some_and(|d| {
|
||||
(d.layer == layer
|
||||
|| d.projection == ProjectionKind::Traceability)
|
||||
&& d.nodes
|
||||
.values()
|
||||
.any(|node| node.element == id)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
preferred_diagram(
|
||||
&candidate,
|
||||
self.active_diagram,
|
||||
layer,
|
||||
id,
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
if let (None, Some(id), Some(diagram)) =
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
//! Named, view-local creation. A draft never enters the accepted model.
|
||||
use super::*;
|
||||
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
|
||||
use serde::{Deserialize, Serialize};
|
||||
mod palette;
|
||||
pub use palette::view as palette;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Start(Kind, Option<Position>),
|
||||
Allocate(Kind, Id, Option<Position>),
|
||||
Rename(crate::route_edit::Context, Id, Position),
|
||||
TogglePalette,
|
||||
Existing(Option<Position>),
|
||||
Name(String),
|
||||
Submit,
|
||||
Click(crate::route_edit::Context, Position),
|
||||
Pick(Id),
|
||||
Locate(Id),
|
||||
Search(String),
|
||||
Filter(Filter),
|
||||
ShowPlaced(bool),
|
||||
Page(usize),
|
||||
Edit,
|
||||
Cancel,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Filter {
|
||||
Context,
|
||||
AllCompatible,
|
||||
Kind(Kind),
|
||||
}
|
||||
impl std::fmt::Display for Filter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Context => f.write_str("Suggested for this view"),
|
||||
Self::AllCompatible => f.write_str("All compatible types"),
|
||||
Self::Kind(k) => f.write_str(k.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Intent {
|
||||
Create {
|
||||
kind: Kind,
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
performer: Option<Id>,
|
||||
},
|
||||
Rename {
|
||||
element: Id,
|
||||
name: String,
|
||||
},
|
||||
Existing {
|
||||
selected: Option<Id>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Saved {
|
||||
pub version: u32,
|
||||
pub base: String,
|
||||
pub diagram: Id,
|
||||
pub intent: Intent,
|
||||
pub point: Option<Position>,
|
||||
pub placing: bool,
|
||||
pub query: String,
|
||||
pub filter: Filter,
|
||||
pub show_placed: bool,
|
||||
pub page: usize,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
pub saved: Saved,
|
||||
document: Id,
|
||||
revision: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
impl State {
|
||||
pub fn restore(app: &App, saved: Saved) -> Result<Self, String> {
|
||||
validate_saved(app.project(), &saved)?;
|
||||
Ok(Self {
|
||||
saved,
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
pub fn modal(&self) -> bool {
|
||||
!self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. })
|
||||
}
|
||||
pub fn inline(&self) -> bool {
|
||||
!self.saved.placing
|
||||
&& self.saved.point.is_some()
|
||||
&& !matches!(self.saved.intent, Intent::Existing { .. })
|
||||
}
|
||||
pub fn unnamed_tool(&self) -> bool {
|
||||
self.saved.placing
|
||||
&& self.saved.point.is_none()
|
||||
&& matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty())
|
||||
}
|
||||
}
|
||||
pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) {
|
||||
return Err("The architecture draft does not match its accepted model.".into());
|
||||
}
|
||||
let d = p
|
||||
.diagrams
|
||||
.get(&s.diagram)
|
||||
.ok_or("The creation draft's diagram is missing.")?;
|
||||
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|
||||
|| s.query.len() > 16384
|
||||
|| s.page > 1_000_000
|
||||
|| s.point
|
||||
.is_some_and(|p| !p.x.is_finite() || !p.y.is_finite())
|
||||
{
|
||||
return Err("Invalid architecture creation draft context.".into());
|
||||
}
|
||||
match &s.intent {
|
||||
Intent::Create {
|
||||
kind,
|
||||
name,
|
||||
performer,
|
||||
} if name.len() > 16384
|
||||
|| !kind.supports(d.layer)
|
||||
|| !d.projection.includes(*kind)
|
||||
|| *kind == Kind::Port
|
||||
|| performer.is_some_and(|id| {
|
||||
!kind.is_work()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer)
|
||||
}) =>
|
||||
{
|
||||
Err("Invalid architecture creation draft type or name.".into())
|
||||
}
|
||||
Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => {
|
||||
Err("The creation draft's existing element is incompatible.".into())
|
||||
}
|
||||
Intent::Rename { element, name }
|
||||
if name.len() > 16384
|
||||
|| !p.elements.contains_key(element)
|
||||
|| !d.nodes.values().any(|n| n.element == *element) =>
|
||||
{
|
||||
Err("The rename draft's element is not shown in this view.".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
|
||||
let mut values = match (d.layer, d.projection) {
|
||||
(_, ProjectionKind::StateMachine { .. }) => vec![],
|
||||
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
|
||||
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
|
||||
(Layer::Oa, ProjectionKind::Capabilities) => vec![
|
||||
Kind::OperationalMission,
|
||||
Kind::Capability,
|
||||
Kind::Entity,
|
||||
Kind::Actor,
|
||||
],
|
||||
(Layer::Sa, ProjectionKind::Capabilities) => vec![
|
||||
Kind::Mission,
|
||||
Kind::SystemCapability,
|
||||
Kind::System,
|
||||
Kind::SystemActor,
|
||||
],
|
||||
(Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity],
|
||||
(Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function],
|
||||
};
|
||||
values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System));
|
||||
values
|
||||
}
|
||||
pub fn kinds(app: &App) -> Vec<Kind> {
|
||||
app.active_diagram
|
||||
.and_then(|id| app.project().diagrams.get(&id))
|
||||
.map(|d| context_kinds(app.project(), d))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
}
|
||||
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
|
||||
// Existing System remains a useful choice even when the singleton exists.
|
||||
context_kinds(p, d).contains(&kind)
|
||||
|| (kind == Kind::System
|
||||
&& d.layer == Layer::Sa
|
||||
&& d.projection != ProjectionKind::FunctionalDataflow)
|
||||
}
|
||||
pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
|
||||
let Some(s) = &app.architecture else {
|
||||
return vec![];
|
||||
};
|
||||
let p = app.project();
|
||||
let Some(d) = p.diagrams.get(&s.saved.diagram) else {
|
||||
return vec![];
|
||||
};
|
||||
let query = s.saved.query.to_lowercase();
|
||||
let mut entries: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| compatible(p, d, e.id))
|
||||
.filter(|e| match s.saved.filter {
|
||||
Filter::Context => suggested(p, d, e.kind()),
|
||||
Filter::AllCompatible => true,
|
||||
Filter::Kind(k) => e.kind() == k,
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let shown = d.nodes.values().any(|n| n.element == e.id);
|
||||
let label = format!(
|
||||
"{} · @{} · {} · {}",
|
||||
e.name,
|
||||
e.alias,
|
||||
e.kind().label(),
|
||||
e.parent.map(|id| p.named(id)).unwrap_or("At root")
|
||||
);
|
||||
((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query))
|
||||
.then_some((e.id, label, shown))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
entries
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
|
||||
if self.architecture.is_some() {
|
||||
if self.architecture.as_ref().is_some_and(State::unnamed_tool) {
|
||||
self.architecture = None;
|
||||
} else {
|
||||
self.status =
|
||||
"Finish or cancel the current name before choosing another tool."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if self.page != Page::Diagram
|
||||
|| self.source_dirty()
|
||||
|| self.draft_dirty()
|
||||
|| self.behavior.has_pending()
|
||||
|| self.workflow.is_some()
|
||||
{
|
||||
self.status =
|
||||
"Apply or cancel pending edits before creating diagram content.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
return Task::none();
|
||||
};
|
||||
let intent = match event {
|
||||
Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create {
|
||||
kind,
|
||||
name: String::new(),
|
||||
performer: None,
|
||||
},
|
||||
Event::Allocate(kind, performer, _)
|
||||
if kinds(self).contains(&kind) && kind.is_work() =>
|
||||
{
|
||||
Intent::Create {
|
||||
kind,
|
||||
name: String::new(),
|
||||
performer: Some(performer),
|
||||
}
|
||||
}
|
||||
Event::Existing(_) => Intent::Existing { selected: None },
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
|
||||
let saved = Saved {
|
||||
version: 1,
|
||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||
diagram,
|
||||
intent,
|
||||
point,
|
||||
placing,
|
||||
query: String::new(),
|
||||
filter: Filter::Context,
|
||||
show_placed: false,
|
||||
page: 0,
|
||||
};
|
||||
if let Ok(state) = State::restore(self, saved) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.architecture = Some(state);
|
||||
self.connection = None;
|
||||
self.fit_to_view = false;
|
||||
self.status =
|
||||
"Click in the diagram to position the new element · Esc cancels".into();
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::TogglePalette => {
|
||||
self.architecture_palette_expanded = !self.architecture_palette_expanded;
|
||||
self.fit_to_view = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::Rename(context, element, point) => {
|
||||
if self.architecture.is_some()
|
||||
|| self.route_context() != Some(context)
|
||||
|| self.page != Page::Diagram
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| self.behavior.has_pending()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(e) = self.project().elements.get(&element) else {
|
||||
return Task::none();
|
||||
};
|
||||
let saved = Saved {
|
||||
version: 1,
|
||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||
diagram: context.diagram,
|
||||
intent: Intent::Rename {
|
||||
element,
|
||||
name: e.name.clone(),
|
||||
},
|
||||
point: Some(point),
|
||||
placing: false,
|
||||
query: String::new(),
|
||||
filter: Filter::Context,
|
||||
show_placed: false,
|
||||
page: 0,
|
||||
};
|
||||
if let Ok(state) = State::restore(self, saved) {
|
||||
self.architecture = Some(state);
|
||||
self.connection = None;
|
||||
self.fit_to_view = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::Cancel => {
|
||||
self.architecture = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::Name(value) => {
|
||||
if value.len() <= 16384
|
||||
&& let Some(s) = &mut self.architecture
|
||||
&& let (Intent::Create { name, .. } | Intent::Rename { name, .. }) =
|
||||
&mut s.saved.intent
|
||||
{
|
||||
*name = value;
|
||||
s.error = None;
|
||||
}
|
||||
}
|
||||
Event::Search(query) => {
|
||||
if query.len() <= 16384
|
||||
&& let Some(s) = &mut self.architecture
|
||||
{
|
||||
s.saved.query = query;
|
||||
s.saved.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Filter(filter) => {
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.filter = filter;
|
||||
s.saved.page = 0;
|
||||
}
|
||||
}
|
||||
Event::ShowPlaced(value) => {
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.show_placed = value;
|
||||
s.saved.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page(page) => {
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.page = page.min(1_000_000);
|
||||
}
|
||||
}
|
||||
Event::Edit => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.placing = false;
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::Pick(id) => {
|
||||
if !candidates(self)
|
||||
.iter()
|
||||
.any(|(candidate, _, shown)| *candidate == id && !shown)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.intent = Intent::Existing { selected: Some(id) };
|
||||
}
|
||||
return self.architecture_event(Event::Submit);
|
||||
}
|
||||
Event::Locate(id) => {
|
||||
if candidates(self)
|
||||
.iter()
|
||||
.any(|(candidate, _, shown)| *candidate == id && *shown)
|
||||
&& let Some(s) = self.architecture.take()
|
||||
{
|
||||
return self.update(Message::RevealInDiagram(s.saved.diagram, id));
|
||||
}
|
||||
}
|
||||
Event::Submit => {
|
||||
let Some(s) = &mut self.architecture else {
|
||||
return Task::none();
|
||||
};
|
||||
if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty())
|
||||
{
|
||||
s.error = Some("Enter an engineering name before placing this element.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(s.saved.intent, Intent::Existing { selected: None }) {
|
||||
return Task::none();
|
||||
}
|
||||
s.saved.placing = true;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if let Some(point) = s.saved.point {
|
||||
self.commit_architecture(point);
|
||||
}
|
||||
}
|
||||
Event::Click(context, point) => {
|
||||
if self.route_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.architecture.as_ref().is_some_and(|s| s.saved.placing) {
|
||||
if matches!(
|
||||
self.architecture.as_ref().unwrap().saved.intent,
|
||||
Intent::Existing { .. }
|
||||
) {
|
||||
self.commit_architecture(point);
|
||||
} else {
|
||||
let s = self.architecture.as_mut().unwrap();
|
||||
s.saved.point = Some(point);
|
||||
s.saved.placing = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn commit_architecture(&mut self, point: Position) {
|
||||
let Some(s) = self.architecture.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if s.document != self.canvas_document
|
||||
|| s.revision != self.document.revision()
|
||||
|| Some(s.saved.diagram) != self.active_diagram
|
||||
|| self.page != Page::Diagram
|
||||
|| self.source_dirty()
|
||||
|| self.draft_dirty()
|
||||
|| self.behavior.has_pending()
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
{
|
||||
self.status =
|
||||
"The diagram changed. The name is retained; cancel and restart this placement."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
let diagram = s.saved.diagram;
|
||||
if let Intent::Rename { element, name } = &s.saved.intent {
|
||||
let element = *element;
|
||||
let name = name.clone();
|
||||
if name.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.execute(Command::Rename(element, name)) {
|
||||
self.architecture = None;
|
||||
self.selected = element;
|
||||
self.refresh_draft();
|
||||
self.status = "Name updated · Undo restores the previous name".into();
|
||||
} else if let Some(s) = &mut self.architecture {
|
||||
s.error = Some(self.status.clone());
|
||||
s.saved.placing = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut candidate = self.project().clone();
|
||||
let mut commands = vec![];
|
||||
let id = match &s.saved.intent {
|
||||
Intent::Create {
|
||||
kind,
|
||||
name,
|
||||
performer,
|
||||
} => {
|
||||
if name.trim().is_empty() || !kinds(self).contains(kind) {
|
||||
return;
|
||||
}
|
||||
let Some(element) =
|
||||
candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let id = element.id;
|
||||
candidate.elements.insert(id, element.clone());
|
||||
commands.push(Command::CreateElement(element));
|
||||
if let Some(performer) = performer {
|
||||
commands.push(Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Performs".into(),
|
||||
*performer,
|
||||
id,
|
||||
));
|
||||
}
|
||||
id
|
||||
}
|
||||
Intent::Existing { selected: Some(id) }
|
||||
if compatible(&candidate, &candidate.diagrams[&diagram], *id)
|
||||
&& !candidate.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == *id) =>
|
||||
{
|
||||
*id
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let node = candidate.place(diagram, id, point);
|
||||
commands.push(Command::CreateNode {
|
||||
diagram,
|
||||
node: candidate.diagrams[&diagram].nodes[&node].clone(),
|
||||
});
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
self.architecture = None;
|
||||
self.selected = id;
|
||||
self.selected_node = Some(node);
|
||||
self.selected_bend = None;
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.refresh_draft();
|
||||
self.fit_to_view = false;
|
||||
self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into();
|
||||
} else if let Some(s) = &mut self.architecture {
|
||||
s.error = Some(self.status.clone());
|
||||
s.saved.placing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
|
||||
let s = app.architecture.as_ref().filter(|s| s.inline())?;
|
||||
let point = s.saved.point?;
|
||||
let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.));
|
||||
let width = (size.width - 16.).clamp(120., 320.);
|
||||
let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.));
|
||||
let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.));
|
||||
Some(
|
||||
column![
|
||||
iced::widget::Space::new().height(y),
|
||||
row![
|
||||
iced::widget::Space::new().width(x),
|
||||
iced::widget::opaque(
|
||||
iced::widget::container(name_editor(app))
|
||||
.padding(10)
|
||||
.width(width)
|
||||
.style(crate::style::card)
|
||||
)
|
||||
]
|
||||
]
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
fn name_editor(app: &App) -> Element<'_, Message> {
|
||||
let s = app.architecture.as_ref().unwrap();
|
||||
let (title, name, action) = match &s.saved.intent {
|
||||
Intent::Create {
|
||||
kind,
|
||||
name,
|
||||
performer,
|
||||
} => (
|
||||
performer
|
||||
.map(|id| {
|
||||
format!(
|
||||
"{} · performed by {}",
|
||||
kind.label(),
|
||||
crate::views::short(app.project().named(id), 32)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| format!("New {}", kind.label())),
|
||||
name,
|
||||
"Create",
|
||||
),
|
||||
Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"),
|
||||
Intent::Existing { .. } => unreachable!(),
|
||||
};
|
||||
let mut content = column![
|
||||
text(title).size(12),
|
||||
text_input("Engineering name", name)
|
||||
.id("architecture-name")
|
||||
.on_input(|v| Message::Architecture(Event::Name(v)))
|
||||
.on_submit(Message::Architecture(Event::Submit))
|
||||
.padding(7)
|
||||
.size(13),
|
||||
row![
|
||||
button(text(action).size(12))
|
||||
.padding([5, 9])
|
||||
.style(crate::style::nav)
|
||||
.on_press(Message::Architecture(Event::Submit)),
|
||||
button(text("Cancel").size(12))
|
||||
.padding([5, 9])
|
||||
.style(crate::style::nav)
|
||||
.on_press(Message::Architecture(Event::Cancel))
|
||||
]
|
||||
.spacing(6)
|
||||
]
|
||||
.spacing(7);
|
||||
if let Some(error) = &s.error {
|
||||
content = content.push(text(crate::views::short(error, 100)).size(11));
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
pub fn dialog(app: &App) -> Element<'_, Message> {
|
||||
let s = app.architecture.as_ref().unwrap();
|
||||
let action = |label: String, event| {
|
||||
button(text(label).size(12))
|
||||
.padding(8)
|
||||
.on_press(Message::Architecture(event))
|
||||
};
|
||||
let mut content = column![].spacing(12);
|
||||
match &s.saved.intent {
|
||||
Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app),
|
||||
Intent::Existing { .. } => {
|
||||
let p = app.project();
|
||||
let d = &p.diagrams[&s.saved.diagram];
|
||||
let mut filters = vec![Filter::Context, Filter::AllCompatible];
|
||||
filters.extend(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| {
|
||||
*k != Kind::Port
|
||||
&& d.projection.includes(*k)
|
||||
&& (k.supports(d.layer) || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
.map(Filter::Kind),
|
||||
);
|
||||
let entries = candidates(app);
|
||||
let page = s.saved.page.min(entries.len().saturating_sub(1) / 30);
|
||||
let mut rows = column![].spacing(5);
|
||||
for (id, label, shown) in entries.iter().skip(page * 30).take(30) {
|
||||
rows = rows.push(
|
||||
action(
|
||||
format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label),
|
||||
if *shown {
|
||||
Event::Locate(*id)
|
||||
} else {
|
||||
Event::Pick(*id)
|
||||
},
|
||||
)
|
||||
.width(iced::Fill),
|
||||
);
|
||||
}
|
||||
content = content.push(text("Add existing to this diagram").size(22))
|
||||
.push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13))
|
||||
.push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9))
|
||||
.push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f))))
|
||||
.push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v))))
|
||||
.push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12));
|
||||
if entries.is_empty() {
|
||||
content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13));
|
||||
}
|
||||
content = content
|
||||
.push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.)))
|
||||
.push(
|
||||
row![
|
||||
action("Previous".into(), Event::Page(page.saturating_sub(1))),
|
||||
action(
|
||||
"Next".into(),
|
||||
Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30))
|
||||
),
|
||||
action("Cancel".into(), Event::Cancel)
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(error) = &s.error {
|
||||
content = content.push(text(error).size(13));
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Compact neutral tool palette. Tool state never changes its allocated width.
|
||||
use super::*;
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
canvas::{self, Frame, Geometry, Path, Stroke},
|
||||
container, tooltip,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Icon {
|
||||
Element(Kind),
|
||||
Arrow,
|
||||
Existing,
|
||||
Select,
|
||||
Collapse(bool),
|
||||
}
|
||||
impl canvas::Program<Message> for Icon {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
renderer: &iced::Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
let color = match self {
|
||||
Self::Element(k) => crate::style::kind_color(*k),
|
||||
_ => crate::style::INK,
|
||||
};
|
||||
let stroke = Stroke::default().with_color(color).with_width(1.4);
|
||||
let line = |points: &[(f32, f32)]| {
|
||||
Path::new(|p| {
|
||||
p.move_to(Point::new(points[0].0, points[0].1));
|
||||
for &(x, y) in &points[1..] {
|
||||
p.line_to(Point::new(x, y));
|
||||
}
|
||||
})
|
||||
};
|
||||
match self {
|
||||
Self::Element(Kind::Actor | Kind::SystemActor | Kind::Role) => {
|
||||
f.stroke(&Path::circle(Point::new(8., 4.), 2.5), stroke);
|
||||
for points in [
|
||||
&[(8., 6.5), (8., 11.)][..],
|
||||
&[(3., 8.), (13., 8.)],
|
||||
&[(4., 15.), (8., 11.), (12., 15.)],
|
||||
] {
|
||||
f.stroke(&line(points), stroke);
|
||||
}
|
||||
}
|
||||
Self::Element(Kind::Capability | Kind::SystemCapability) => {
|
||||
f.stroke(&Path::circle(Point::new(8., 8.), 6.), stroke);
|
||||
}
|
||||
Self::Element(Kind::Mission | Kind::OperationalMission) => {
|
||||
f.stroke(
|
||||
&line(&[(3., 15.), (3., 2.), (13., 2.), (10., 6.), (3., 6.)]),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
Self::Element(k) => {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(
|
||||
Point::new(1., 3.),
|
||||
Size::new(14., 10.),
|
||||
if k.is_work() { 3.into() } else { 0.into() },
|
||||
),
|
||||
stroke,
|
||||
);
|
||||
if !k.is_work() {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(4., 6.), Size::new(8., 4.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Arrow => {
|
||||
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
|
||||
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
|
||||
}
|
||||
Self::Existing => {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
|
||||
stroke,
|
||||
);
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(5., 5.), Size::new(10., 10.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
Self::Select => {
|
||||
f.stroke(
|
||||
&line(&[
|
||||
(3., 1.),
|
||||
(3., 14.),
|
||||
(7., 10.),
|
||||
(11., 15.),
|
||||
(13., 14.),
|
||||
(9., 9.),
|
||||
(15., 8.),
|
||||
(3., 1.),
|
||||
]),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
Self::Collapse(expanded) => {
|
||||
let (a, b) = if *expanded { (5., 11.) } else { (11., 5.) };
|
||||
f.stroke(&line(&[(a, 3.), (b, 8.), (a, 13.)]), stroke);
|
||||
}
|
||||
}
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
}
|
||||
fn tool<'a>(
|
||||
app: &App,
|
||||
icon: Icon,
|
||||
label: &'a str,
|
||||
message: Message,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let width = if app.architecture_palette_expanded {
|
||||
156.
|
||||
} else {
|
||||
36.
|
||||
};
|
||||
let graphic = iced::widget::canvas(icon).width(16).height(16);
|
||||
let mut content = row![graphic].spacing(7).align_y(iced::Center);
|
||||
if app.architecture_palette_expanded {
|
||||
content = content.push(text(label).size(11).wrapping(text::Wrapping::None));
|
||||
}
|
||||
let mut enabled = app.architecture.as_ref().is_none_or(State::unnamed_tool)
|
||||
|| matches!(
|
||||
&message,
|
||||
Message::Architecture(Event::Cancel | Event::TogglePalette)
|
||||
);
|
||||
let mut hint = label.to_owned();
|
||||
if let Message::Connection(crate::connection_edit::Event::Choose(Some(connection))) = &message {
|
||||
let view = oa_core::projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
if !crate::connection_edit::visible(&view).any(|id| connection.can_start(app.project(), id))
|
||||
{
|
||||
enabled = false;
|
||||
hint.push_str(" — add or show a compatible source first");
|
||||
}
|
||||
}
|
||||
let control = button(content)
|
||||
.padding([5, 7])
|
||||
.height(28)
|
||||
.width(width - 4.)
|
||||
.clip(true)
|
||||
.style(if selected {
|
||||
crate::style::selected
|
||||
} else {
|
||||
crate::style::nav
|
||||
})
|
||||
.on_press_maybe(enabled.then_some(message));
|
||||
tooltip(
|
||||
control,
|
||||
container(text(hint).size(12))
|
||||
.padding(7)
|
||||
.style(crate::style::card),
|
||||
tooltip::Position::Left,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let mut content = column![tool(
|
||||
app,
|
||||
Icon::Collapse(app.architecture_palette_expanded),
|
||||
if app.architecture_palette_expanded {
|
||||
"Collapse tools"
|
||||
} else {
|
||||
"Expand tools"
|
||||
},
|
||||
Message::Architecture(Event::TogglePalette),
|
||||
false
|
||||
)]
|
||||
.spacing(3);
|
||||
let group = |name: &'static str| -> Element<'_, Message> {
|
||||
if app.architecture_palette_expanded {
|
||||
container(text(name).size(10).color(crate::style::MUTED))
|
||||
.padding([5, 4])
|
||||
.into()
|
||||
} else {
|
||||
iced::widget::rule::horizontal(1).into()
|
||||
}
|
||||
};
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Select,
|
||||
if app.architecture.is_some() {
|
||||
"Cancel / select"
|
||||
} else {
|
||||
"Select / move"
|
||||
},
|
||||
if app.architecture.is_some() {
|
||||
Message::Architecture(Event::Cancel)
|
||||
} else {
|
||||
Message::Connection(crate::connection_edit::Event::Choose(None))
|
||||
},
|
||||
app.architecture.is_none() && app.connection.is_none(),
|
||||
));
|
||||
for (heading, work) in [
|
||||
("Participants / capabilities", false),
|
||||
("Activities / functions", true),
|
||||
] {
|
||||
let choices: Vec<_> = kinds(app)
|
||||
.into_iter()
|
||||
.filter(|k| k.is_work() == work)
|
||||
.collect();
|
||||
if !choices.is_empty() {
|
||||
content = content.push(group(heading));
|
||||
}
|
||||
for kind in choices {
|
||||
let active = app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(|s| matches!(s.saved.intent,Intent::Create{kind:k,..} if kind==k));
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Element(kind),
|
||||
kind.label(),
|
||||
Message::Architecture(Event::Start(kind, None)),
|
||||
active,
|
||||
));
|
||||
}
|
||||
}
|
||||
content = content.push(group("Connections"));
|
||||
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||
let kind = match connection {
|
||||
crate::connection_tool::Tool::Interaction => Kind::Activity,
|
||||
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
|
||||
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
|
||||
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
|
||||
};
|
||||
if !d.projection.includes(kind) {
|
||||
continue;
|
||||
}
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Arrow,
|
||||
connection.label(),
|
||||
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
|
||||
app.connection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.tool == *connection),
|
||||
));
|
||||
}
|
||||
content = content.push(group("Common")).push(tool(
|
||||
app,
|
||||
Icon::Existing,
|
||||
"Add existing…",
|
||||
Message::Architecture(Event::Existing(None)),
|
||||
false,
|
||||
));
|
||||
container(scrollable(content))
|
||||
.width(if app.architecture_palette_expanded {
|
||||
156
|
||||
} else {
|
||||
36
|
||||
})
|
||||
.height(iced::Fill)
|
||||
.padding(2)
|
||||
.style(crate::style::panel)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use super::*;
|
||||
use crate::app::architecture_authoring::{Event, kinds};
|
||||
|
||||
fn setup(layer: Layer) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.layer = layer;
|
||||
app.active_diagram = None;
|
||||
let _ = app.update(Message::AddDiagram);
|
||||
app.zoom = 0.75;
|
||||
app.pan = iced::Vector::new(37., -19.);
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Architecture(event));
|
||||
}
|
||||
#[test]
|
||||
fn architecture_creation_names_before_commit_preserves_camera_and_undoes_once() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let before = app.document.bundle().clone();
|
||||
let model = app.project().clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
assert_eq!(app.project(), &model);
|
||||
let context = app.route_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Click(context, Position { x: 219., y: 117. }),
|
||||
);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
send(&mut app, Event::Name(" Response team 雨 ".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let element = &app.project().elements[&app.selected];
|
||||
assert_eq!(element.name, " Response team 雨 ");
|
||||
assert_eq!(element.parent, None);
|
||||
let node =
|
||||
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
|
||||
assert_eq!(node.position, Position { x: 219., y: 117. });
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
#[test]
|
||||
fn architecture_context_choices_are_small_and_system_is_singleton() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
assert_eq!(kinds(&app), vec![Kind::Entity, Kind::Actor, Kind::Activity]);
|
||||
app = setup(Layer::Sa);
|
||||
assert_eq!(
|
||||
kinds(&app),
|
||||
vec![Kind::System, Kind::SystemActor, Kind::Function]
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Start(Kind::System, Some(Position { x: 60., y: 60. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Pump controller".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
assert_eq!(kinds(&app), vec![Kind::SystemActor, Kind::Function]);
|
||||
}
|
||||
#[test]
|
||||
fn architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Start(Kind::Activity, None));
|
||||
send(&mut app, Event::Name("Diagnose pressure".into()));
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
assert!(
|
||||
matches!(&app.architecture.as_ref().unwrap().saved.intent, architecture_authoring::Intent::Create { name, kind: Kind::Activity, .. } if name == "Diagnose pressure")
|
||||
);
|
||||
send(&mut app, Event::Submit);
|
||||
let mut stale = app.route_context().unwrap();
|
||||
stale.revision += 1;
|
||||
send(&mut app, Event::Click(stale, Position { x: 1., y: 2. }));
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(1100., 700.)));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.architecture.is_some());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let entity = app
|
||||
.project()
|
||||
.make_element(Kind::Entity, Layer::Oa, "Response team", None)
|
||||
.unwrap();
|
||||
let entity_id = entity.id;
|
||||
let work = app
|
||||
.project()
|
||||
.make_element(Kind::Activity, Layer::Oa, "Report status", None)
|
||||
.unwrap();
|
||||
let work_id = work.id;
|
||||
let data = app
|
||||
.project()
|
||||
.make_element(Kind::DataType, Layer::Oa, "Pressure", None)
|
||||
.unwrap();
|
||||
let data_id = data.id;
|
||||
assert!(app.execute(Command::Batch(vec![
|
||||
Command::CreateElement(entity),
|
||||
Command::CreateElement(work),
|
||||
Command::CreateElement(data)
|
||||
])));
|
||||
send(&mut app, Event::Existing(Some(Position { x: 80., y: 80. })));
|
||||
let ids: Vec<_> = architecture_authoring::candidates(&app)
|
||||
.iter()
|
||||
.map(|row| row.0)
|
||||
.collect();
|
||||
assert!(ids.contains(&entity_id) && ids.contains(&work_id));
|
||||
assert!(!ids.contains(&data_id));
|
||||
send(&mut app, Event::Pick(entity_id));
|
||||
let semantic = app.project().elements.clone();
|
||||
let before = app.document.bundle().clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Existing(Some(Position { x: 90., y: 100. })),
|
||||
);
|
||||
assert!(
|
||||
!architecture_authoring::candidates(&app)
|
||||
.iter()
|
||||
.any(|row| row.0 == entity_id)
|
||||
);
|
||||
send(&mut app, Event::ShowPlaced(true));
|
||||
assert!(
|
||||
architecture_authoring::candidates(&app)
|
||||
.iter()
|
||||
.any(|row| row.0 == entity_id && row.2)
|
||||
);
|
||||
send(&mut app, Event::Pick(work_id));
|
||||
assert_eq!(app.project().elements, semantic);
|
||||
assert!(app.project().relations.is_empty());
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands() {
|
||||
for (name, placing) in [(" Team 雨 ", true), (" ", false)] {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
send(&mut app, Event::Name(name.into()));
|
||||
if placing {
|
||||
send(&mut app, Event::Submit);
|
||||
}
|
||||
app.architecture.as_mut().unwrap().saved.point = Some(Position { x: 127.5, y: 215. });
|
||||
app.architecture.as_mut().unwrap().saved.placing = placing;
|
||||
let snapshot = crate::recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
None,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap()
|
||||
.with_architecture(app.architecture.as_ref());
|
||||
let bytes = serde_json::to_vec(&snapshot).unwrap();
|
||||
let decoded: crate::recovery::Snapshot = serde_json::from_slice(&bytes).unwrap();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let mut restored = setup(Layer::Sa);
|
||||
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(decoded))));
|
||||
assert_eq!(restored.document.bundle(), &accepted);
|
||||
let draft = &restored.architecture.as_ref().unwrap().saved;
|
||||
assert!(
|
||||
matches!(&draft.intent, architecture_authoring::Intent::Create { name: actual, .. } if actual == name)
|
||||
);
|
||||
assert_eq!(draft.placing, placing);
|
||||
assert_eq!(draft.point, Some(Position { x: 127.5, y: 215. }));
|
||||
if placing {
|
||||
let context = restored.route_context().unwrap();
|
||||
send(
|
||||
&mut restored,
|
||||
Event::Click(context, Position { x: 20., y: 30. }),
|
||||
);
|
||||
assert!(restored.architecture.as_ref().unwrap().inline());
|
||||
send(&mut restored, Event::Submit);
|
||||
assert!(restored.architecture.is_none());
|
||||
assert_eq!(restored.project().elements[&restored.selected].name, name);
|
||||
} else {
|
||||
send(&mut restored, Event::Submit);
|
||||
assert!(restored.architecture.is_some());
|
||||
assert_eq!(restored.document.bundle(), &accepted);
|
||||
}
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
value["architecture"]["base"] = json!("stale");
|
||||
assert!(
|
||||
serde_json::from_value::<crate::recovery::Snapshot>(value)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
use iced::widget::canvas::Program;
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Actor, None));
|
||||
send(&mut app, Event::Name("Control centre".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let bounds = iced::Rectangle {
|
||||
x: 260.,
|
||||
y: 180.,
|
||||
width: 600.,
|
||||
height: 380.,
|
||||
};
|
||||
let local = iced::Point::new(190., 100.);
|
||||
let expected = Position {
|
||||
x: (local.x - app.pan.x) / app.zoom,
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let action = canvas
|
||||
.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(
|
||||
iced::mouse::Button::Left,
|
||||
)),
|
||||
bounds,
|
||||
iced::mouse::Cursor::Available(iced::Point::new(
|
||||
bounds.x + local.x,
|
||||
bounds.y + local.y,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
let message = action
|
||||
.into_inner()
|
||||
.0
|
||||
.expect("named placement emits a command");
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.point,
|
||||
Some(expected)
|
||||
);
|
||||
send(&mut app, Event::Submit);
|
||||
let node =
|
||||
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
|
||||
assert_eq!(node.position, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
send(&mut app, Event::Name("First".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let stale = app.route_context().unwrap();
|
||||
send(&mut app, Event::Cancel);
|
||||
send(&mut app, Event::Start(Kind::Actor, None));
|
||||
send(&mut app, Event::Name("Second".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Click(stale, Position { x: 1., y: 1. }));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.architecture.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
|
||||
use crate::workflow::{Event as W, Workflow};
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Report status", None)
|
||||
.unwrap();
|
||||
let architecture = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
|
||||
let mut first = Workflow::transition(&p, work).unwrap();
|
||||
let Workflow::Transition(t) = &first else {
|
||||
unreachable!()
|
||||
};
|
||||
let target = t
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == work)
|
||||
.unwrap()
|
||||
.target;
|
||||
let mut p = first.update(&p, W::Apply).unwrap().unwrap();
|
||||
let mut overview = p.diagrams[&architecture].clone();
|
||||
overview.id = Id::new_v4();
|
||||
overview.alias = "chosen_overview".into();
|
||||
overview.name = "Chosen system overview".into();
|
||||
overview.projection = ProjectionKind::Overview;
|
||||
overview.nodes.clear();
|
||||
let chosen = overview.id;
|
||||
p.diagrams.insert(chosen, overview);
|
||||
let placement = p.place(chosen, target, Position { x: 823., y: 417. });
|
||||
let mut app = App::with_project(p);
|
||||
app.selected = work;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.refresh_draft();
|
||||
let _ = app.update(Message::StartTransition);
|
||||
let _ = app.update(Message::Workflow(W::Destination(Choice {
|
||||
id: chosen,
|
||||
label: "Chosen system overview".into(),
|
||||
})));
|
||||
let before = app.project().diagrams.clone();
|
||||
let _ = app.update(Message::Workflow(W::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert_eq!(app.active_diagram, Some(chosen));
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.selected_node, Some(placement));
|
||||
assert_eq!(app.project().diagrams, before);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ impl App {
|
||||
self.selected,
|
||||
)
|
||||
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
|
||||
.map(|snapshot| snapshot.with_architecture(self.architecture.as_ref()))
|
||||
{
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
@@ -186,11 +187,21 @@ impl App {
|
||||
} else {
|
||||
Page::Diagram
|
||||
};
|
||||
self.status = if self.migration.is_some() {
|
||||
"Recovered source 5 as source 6 in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it."
|
||||
if let Some(saved) = restored.architecture {
|
||||
self.active_diagram = Some(saved.diagram);
|
||||
self.layer = self.project().diagrams[&saved.diagram].layer;
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.page = Page::Diagram;
|
||||
self.architecture = architecture_authoring::State::restore(self, saved).ok();
|
||||
}
|
||||
self.status = if let Some(report) = &self.migration {
|
||||
format!(
|
||||
"Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.",
|
||||
report.from_schema, report.to_schema
|
||||
)
|
||||
} else {
|
||||
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged."
|
||||
}.into();
|
||||
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
|
||||
};
|
||||
self.request_lint()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +59,62 @@ impl App {
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::ExpandQualified(_, endpoint) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand_context(self.project(), context.owner, endpoint);
|
||||
self.behavior.topology = state;
|
||||
if changed {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::OpenQualified(_, endpoint) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint);
|
||||
if let Ok(resolved) = resolved {
|
||||
self.behavior.topology.remember(
|
||||
context.owner,
|
||||
endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
);
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::SelectQualified(_, endpoint) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& let Ok(resolved) =
|
||||
oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
let reference = endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence);
|
||||
self.select(reference);
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Focus(_) => {
|
||||
self.behavior.topology.focused = !self.behavior.topology.focused;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
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;
|
||||
self.behavior.topology.clear_context();
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
@@ -234,6 +284,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let initial = app.selected;
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Name(" unfinished contextual work ".into()),
|
||||
);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(app.behavior.topology.focused);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
context, f.nodes[1],
|
||||
)));
|
||||
assert_eq!(app.selected, initial);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(!app.behavior.topology.focused);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
}
|
||||
#[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);
|
||||
|
||||
@@ -711,14 +711,21 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
|
||||
};
|
||||
let e = &p.elements[&selected.id];
|
||||
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain)
|
||||
&& (uses_explicit_topology(p, e.id) || e.steps().is_none_or(Vec::is_empty));
|
||||
if e.kind() == Kind::StateMachine {
|
||||
out = column![text("States & modes").size(20)].spacing(8);
|
||||
} else if compact_process {
|
||||
// The process canvas owns its compact header and remaining-height layout.
|
||||
out = column![].spacing(6);
|
||||
} else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
out = column![text("Processes & functional chains").size(20)].spacing(8);
|
||||
}
|
||||
out = out.push(
|
||||
pick_list(choices, Some(selected), Message::BehaviorOwner)
|
||||
.width(Fill)
|
||||
.text_size(13)
|
||||
.padding(10),
|
||||
.padding(if compact_process { 6 } else { 10 }),
|
||||
);
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return topology::view(app, e.id, out);
|
||||
@@ -767,6 +774,17 @@ fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
|
||||
})
|
||||
}
|
||||
pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
|
||||
if let Some(Relation {
|
||||
body: RelationBody::ContextualLink { owner, .. },
|
||||
..
|
||||
}) = p.relations.get(&selected)
|
||||
{
|
||||
return p
|
||||
.elements
|
||||
.get(owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.map(|e| e.id);
|
||||
}
|
||||
let mut current = Some(
|
||||
p.relations
|
||||
.get(&selected)
|
||||
@@ -1154,7 +1172,7 @@ impl Scene {
|
||||
MessageKind::Timer { .. } => "timer · ",
|
||||
}
|
||||
};
|
||||
let message_label = if !scenario {
|
||||
let mut message_label = if !scenario {
|
||||
r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone())
|
||||
} else if let MessageKind::Timer { expression } = message_kind {
|
||||
format!("{} · {expression}", e.name)
|
||||
@@ -1163,6 +1181,12 @@ impl Scene {
|
||||
} else {
|
||||
e.name.clone()
|
||||
};
|
||||
if scenario
|
||||
&& let Some(operation) = crate::data_values::operation_label(p, e)
|
||||
{
|
||||
message_label.push_str(" · ");
|
||||
message_label.push_str(&operation);
|
||||
}
|
||||
self.lines.push(Line {
|
||||
id: *id,
|
||||
points,
|
||||
@@ -2311,6 +2335,7 @@ mod direct_canvas_tests {
|
||||
f.owner,
|
||||
&format!("Reply {index}"),
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(f.exchange),
|
||||
sender: Some(f.receiver),
|
||||
receiver: Some(f.sender),
|
||||
|
||||
@@ -5,6 +5,10 @@ use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_operation_tests.rs"]
|
||||
mod operation_tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Tool {
|
||||
Lifeline,
|
||||
@@ -125,6 +129,9 @@ pub enum Event {
|
||||
Receiver(Choice),
|
||||
Call(Choice),
|
||||
Style(MessageStyle),
|
||||
OperationIntent(bool),
|
||||
Operation(Choice),
|
||||
ClearOperation,
|
||||
Parent(Choice),
|
||||
Expression(String),
|
||||
SecondGuard(String),
|
||||
@@ -175,6 +182,10 @@ struct Draft {
|
||||
receiver: Option<Id>,
|
||||
call: Option<Id>,
|
||||
style: MessageStyle,
|
||||
#[serde(default)]
|
||||
operation: Option<OperationUse>,
|
||||
#[serde(default)]
|
||||
disabled_operation: Option<OperationUse>,
|
||||
expression: String,
|
||||
second_guard: String,
|
||||
operator: FragmentOperator,
|
||||
@@ -330,6 +341,8 @@ impl State {
|
||||
receiver: None,
|
||||
call: None,
|
||||
style: MessageStyle::CallReply,
|
||||
operation: None,
|
||||
disabled_operation: None,
|
||||
expression: String::new(),
|
||||
second_guard: "else".into(),
|
||||
operator: FragmentOperator::Alternative,
|
||||
@@ -351,6 +364,27 @@ impl State {
|
||||
Event::Name(s) => d.name = s,
|
||||
Event::Expression(s) => d.expression = s,
|
||||
Event::SecondGuard(s) => d.second_guard = s,
|
||||
Event::OperationIntent(enabled) => {
|
||||
if enabled {
|
||||
d.operation = Some(
|
||||
d.operation
|
||||
.clone()
|
||||
.or_else(|| d.disabled_operation.take())
|
||||
.unwrap_or(OperationUse { allocation: None }),
|
||||
);
|
||||
} else {
|
||||
d.disabled_operation =
|
||||
d.operation.take().or_else(|| d.disabled_operation.take());
|
||||
}
|
||||
}
|
||||
Event::Operation(c) => {
|
||||
d.operation = Some(OperationUse {
|
||||
allocation: Some(c.id),
|
||||
})
|
||||
}
|
||||
Event::ClearOperation => {
|
||||
d.operation = Some(OperationUse { allocation: None })
|
||||
}
|
||||
Event::Target(c) => {
|
||||
d.target = Some(c.id);
|
||||
d.bindings.clear();
|
||||
@@ -358,7 +392,9 @@ impl State {
|
||||
Event::Exchange(c) => {
|
||||
if d.exchange != Some(c.id) {
|
||||
d.exchange = Some(c.id);
|
||||
d.reset_values();
|
||||
if !d.has_operation_intent(p) {
|
||||
d.reset_values();
|
||||
}
|
||||
if !d.fixed_endpoints {
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
@@ -394,7 +430,9 @@ impl State {
|
||||
std::mem::swap(&mut d.sender, &mut d.receiver);
|
||||
d.call = None;
|
||||
}
|
||||
d.retain_value_paths(p);
|
||||
if !d.has_operation_intent(p) {
|
||||
d.retain_value_paths(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Call(c) => {
|
||||
@@ -410,7 +448,7 @@ impl State {
|
||||
..
|
||||
}) = p.elements.get(&c.id).and_then(|e| e.shared())
|
||||
{
|
||||
if d.exchange != *exchange {
|
||||
if d.exchange != *exchange && !d.has_operation_intent(p) {
|
||||
d.reset_values();
|
||||
}
|
||||
d.exchange = *exchange;
|
||||
@@ -426,18 +464,24 @@ impl State {
|
||||
d.values.insert(id, s);
|
||||
}
|
||||
Event::ValueFocus(key)
|
||||
if d.exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), &key))
|
||||
if d.signature(p)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|r| {
|
||||
crate::data_values::resolve(p, &r, &d.value_kind(), &key)
|
||||
})
|
||||
.is_some() =>
|
||||
{
|
||||
d.value_focus = Some(key);
|
||||
}
|
||||
Event::ValueBrowse(key) => {
|
||||
if key.as_ref().is_none_or(|key| {
|
||||
d.exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), key))
|
||||
d.signature(p)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|r| {
|
||||
crate::data_values::resolve(p, &r, &d.value_kind(), key)
|
||||
})
|
||||
.is_some()
|
||||
}) {
|
||||
d.value_browse = key;
|
||||
@@ -767,6 +811,33 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
.collect()
|
||||
}
|
||||
impl Draft {
|
||||
fn has_operation_intent(&self, p: &Project) -> bool {
|
||||
self.operation.is_some()
|
||||
|| self
|
||||
.call
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
fn proposed_message(&self, p: &Project) -> oa_core::Element {
|
||||
let mut message = self.append(p, "Operation preview", self.message_body());
|
||||
// This is a transient resolver input; no generated identity reaches a command.
|
||||
message.id = Id::nil();
|
||||
message
|
||||
}
|
||||
fn signature(&self, p: &Project) -> Result<Option<Relation>, String> {
|
||||
crate::data_values::message_signature(p, &self.proposed_message(p))
|
||||
}
|
||||
fn reset_values(&mut self) {
|
||||
self.values.clear();
|
||||
self.value_focus = None;
|
||||
@@ -804,8 +875,8 @@ impl Draft {
|
||||
exchange: &Relation,
|
||||
) -> Result<Vec<crate::data_values::FieldPathChoice>, String> {
|
||||
match &self.value_browse {
|
||||
Some(key) => crate::data_values::children(p, exchange, &self.kind(), key),
|
||||
None => Ok(crate::data_values::roots(p, exchange, &self.kind())),
|
||||
Some(key) => crate::data_values::children(p, exchange, &self.value_kind(), key),
|
||||
None => Ok(crate::data_values::roots(p, exchange, &self.value_kind())),
|
||||
}
|
||||
}
|
||||
fn authored_values(&self) -> Vec<(&crate::data_values::FieldPathKey, &str)> {
|
||||
@@ -815,6 +886,9 @@ impl Draft {
|
||||
.map(|(key, value)| (key, value.as_str()))
|
||||
.collect()
|
||||
}
|
||||
fn value_kind(&self) -> MessageKind {
|
||||
crate::data_values::operation_value_phase(&self.kind(), self.operation.as_ref())
|
||||
}
|
||||
fn kind(&self) -> MessageKind {
|
||||
match self.style {
|
||||
MessageStyle::Asynchronous => MessageKind::Asynchronous,
|
||||
@@ -841,6 +915,14 @@ impl Draft {
|
||||
self.receiver
|
||||
},
|
||||
message_kind: self.kind(),
|
||||
operation: if matches!(
|
||||
self.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
) {
|
||||
self.operation.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
reply_to: self.call,
|
||||
values: self
|
||||
.values
|
||||
@@ -974,6 +1056,7 @@ impl Draft {
|
||||
sender: self.receiver,
|
||||
receiver: self.sender,
|
||||
message_kind: MessageKind::Reply,
|
||||
operation: None,
|
||||
reply_to: Some(call_id),
|
||||
values: vec![],
|
||||
},
|
||||
@@ -1136,7 +1219,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a
|
||||
use crate::data_values;
|
||||
let mut out = column![text("Message values").size(14)].spacing(7);
|
||||
if let Some(browse) = &d.value_browse {
|
||||
let title = data_values::resolve(p, exchange, &d.kind(), browse)
|
||||
let title = data_values::resolve(p, exchange, &d.value_kind(), browse)
|
||||
.map(|choice| format!("Fields inside {}", choice.label))
|
||||
.unwrap_or_else(|| "This record path is no longer available in this message.".into());
|
||||
out = out.push(text(title).size(11)).push(
|
||||
@@ -1182,7 +1265,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a
|
||||
match d
|
||||
.value_focus
|
||||
.as_ref()
|
||||
.map(|key| data_values::resolve(p, exchange, &d.kind(), key))
|
||||
.map(|key| data_values::resolve(p, exchange, &d.value_kind(), key))
|
||||
{
|
||||
Some(Some(focus)) => {
|
||||
let key = focus.key;
|
||||
@@ -1233,7 +1316,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a
|
||||
ValueSection::Authored,
|
||||
));
|
||||
for (key, expression) in &authored[value_page(authored.len(), d.value_authored_page)] {
|
||||
let title = data_values::resolve(p, exchange, &d.kind(), key)
|
||||
let title = data_values::resolve(p, exchange, &d.value_kind(), key)
|
||||
.map(|choice| choice.label)
|
||||
.unwrap_or_else(|| format!("Unavailable field {} · review this value", key.field));
|
||||
out = out.push(
|
||||
@@ -1579,8 +1662,71 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) {
|
||||
form = form.push(value_panel(p, d, exchange));
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
@@ -2072,6 +2218,7 @@ pub(crate) mod direct_tests {
|
||||
f.owner,
|
||||
&format!("Call {index}"),
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(f.exchange),
|
||||
sender: Some(f.sender),
|
||||
receiver: Some(f.receiver),
|
||||
@@ -3173,6 +3320,7 @@ mod data_contract_tests {
|
||||
f.scenario,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: Some(lifelines[0]),
|
||||
receiver: Some(lifelines[1]),
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn fixture() -> Project {
|
||||
persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
fn id(p: &Project, n: u32) -> Id {
|
||||
let alias = format!("operation_probe_{n}");
|
||||
p.elements
|
||||
.values()
|
||||
.find(|e| e.alias == alias)
|
||||
.map(|e| e.id)
|
||||
.or_else(|| {
|
||||
p.relations
|
||||
.values()
|
||||
.find(|r| r.alias == alias)
|
||||
.map(|r| r.id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn choice(p: &Project, n: u32) -> Choice {
|
||||
Choice {
|
||||
id: id(p, n),
|
||||
label: p.named(id(p, n)).to_owned(),
|
||||
}
|
||||
}
|
||||
fn prepare(p: &Project) -> State {
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
Event::Start(id(p, 40), Tool::Message),
|
||||
Event::Style(MessageStyle::CallReply),
|
||||
Event::Exchange(choice(p, 34)),
|
||||
Event::Sender(choice(p, 41)),
|
||||
Event::Receiver(choice(p, 42)),
|
||||
Event::OperationIntent(true),
|
||||
] {
|
||||
state.update(p, event).unwrap();
|
||||
}
|
||||
state
|
||||
}
|
||||
#[test]
|
||||
fn operation_gui_selects_two_parameterless_allocations_without_inventing_refinement_and_reply_derives_call()
|
||||
{
|
||||
let p = fixture();
|
||||
let mut state = prepare(&p);
|
||||
let choices = crate::data_values::operation_choices(
|
||||
&p,
|
||||
&state.draft.as_ref().unwrap().proposed_message(&p),
|
||||
);
|
||||
assert_eq!(
|
||||
choices.iter().map(|c| c.id).collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([id(&p, 30), id(&p, 31)])
|
||||
);
|
||||
for allocation in [30, 31] {
|
||||
state
|
||||
.update(&p, Event::Operation(choice(&p, allocation)))
|
||||
.unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
||||
let added: Vec<_> = expected
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| !p.contains(e.id))
|
||||
.collect();
|
||||
assert_eq!(added.len(), 2);
|
||||
let call = added
|
||||
.iter()
|
||||
.find(|e| {
|
||||
matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let reply = added
|
||||
.iter()
|
||||
.find(|e| {
|
||||
matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(call.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:Some(OperationUse {allocation:Some(selected)}),..},..}) if *selected==id(&p,allocation))
|
||||
);
|
||||
assert!(
|
||||
matches!(reply.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:None,reply_to:Some(selected),..},..}) if *selected==call.id)
|
||||
);
|
||||
let label = crate::data_values::operation_label(&expected, reply).unwrap();
|
||||
assert!(label.contains("Reply operation from Call"));
|
||||
assert!(label.contains(if allocation == 30 {
|
||||
"Reset"
|
||||
} else {
|
||||
"ReadStatus"
|
||||
}));
|
||||
assert_eq!(expected.relations, p.relations);
|
||||
let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
doc.execute(command).unwrap();
|
||||
let after = doc.bundle().clone();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(
|
||||
Document::from_bundle(after.clone(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn operation_gui_unresolved_and_disabled_selection_recovery_preserves_rejected_raw_values() {
|
||||
let p = fixture();
|
||||
let mut state = prepare(&p);
|
||||
state.update(&p, Event::Operation(choice(&p, 30))).unwrap();
|
||||
state.update(&p, Event::OperationIntent(false)).unwrap();
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let mut state = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), Some(raw));
|
||||
state.update(&p, Event::OperationIntent(true)).unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.operation
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allocation,
|
||||
Some(id(&p, 30))
|
||||
);
|
||||
let key = crate::data_values::FieldPathKey {
|
||||
path: vec![],
|
||||
field: id(&p, 20),
|
||||
};
|
||||
state
|
||||
.update(&p, Event::Value(key.clone(), " rejected raw && ".into()))
|
||||
.unwrap();
|
||||
state.update(&p, Event::ClearOperation).unwrap();
|
||||
assert!(state.draft.as_ref().unwrap().signature(&p).is_err());
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let mut restored = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
|
||||
assert!(restored.prepare_pending(&p).is_err());
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
|
||||
restored
|
||||
.update(&p, Event::Operation(choice(&p, 31)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().values[&key],
|
||||
" rejected raw && "
|
||||
);
|
||||
assert!(restored.prepare_pending(&p).is_err());
|
||||
restored
|
||||
.update(&p, Event::Value(key, String::new()))
|
||||
.unwrap();
|
||||
assert!(restored.prepare_pending(&p).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_gui_async_picker_uses_request_root_and_nested_directions_without_erasing_rejected_values()
|
||||
{
|
||||
let mut p = fixture();
|
||||
let allocation = id(&p, 30);
|
||||
if let RelationBody::InterfaceItemAllocation { transmission, .. } =
|
||||
&mut p.relations.get_mut(&allocation).unwrap().body
|
||||
{
|
||||
*transmission = TransmissionProtocol::Asynchronous;
|
||||
}
|
||||
let record = p
|
||||
.add_element(
|
||||
Kind::DataType,
|
||||
Layer::Sa,
|
||||
"Operation parameter record",
|
||||
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::Sa, "Nested value", Some(record))
|
||||
.unwrap();
|
||||
let item = id(&p, 20);
|
||||
let mut fields = vec![];
|
||||
for (name, direction) in [
|
||||
("Request input", ParameterDirection::In),
|
||||
("Response output", ParameterDirection::Out),
|
||||
("Return result", ParameterDirection::Return),
|
||||
] {
|
||||
let field = p
|
||||
.add_element(Kind::DataField, Layer::Sa, name, Some(item))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
direction,
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
fields.push(field);
|
||||
}
|
||||
validate_candidate(&p).unwrap();
|
||||
let mut state = prepare(&p);
|
||||
state
|
||||
.update(&p, Event::Style(MessageStyle::Asynchronous))
|
||||
.unwrap();
|
||||
state.update(&p, Event::Operation(choice(&p, 30))).unwrap();
|
||||
let signature = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.signature(&p)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let choices = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.browsed_values(&p, &signature)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
choices.iter().map(|c| c.key.field).collect::<Vec<_>>(),
|
||||
vec![fields[0]]
|
||||
);
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::ValueBrowse(Some(crate::data_values::FieldPathKey {
|
||||
path: vec![],
|
||||
field: fields[0],
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
let choices = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.browsed_values(&p, &signature)
|
||||
.unwrap();
|
||||
assert_eq!(choices.len(), 1);
|
||||
assert_eq!(
|
||||
choices[0].key,
|
||||
crate::data_values::FieldPathKey {
|
||||
path: vec![fields[0]],
|
||||
field: leaf
|
||||
}
|
||||
);
|
||||
let rejected = crate::data_values::FieldPathKey {
|
||||
path: vec![fields[1]],
|
||||
field: leaf,
|
||||
};
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Value(rejected.clone(), " retained output expression ".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.prepare_pending(&p).is_err());
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let mut restored = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().values[&rejected],
|
||||
" retained output expression "
|
||||
);
|
||||
restored.update(&p, Event::OperationIntent(false)).unwrap();
|
||||
restored.update(&p, Event::ValueBrowse(None)).unwrap();
|
||||
let d = restored.draft.as_ref().unwrap();
|
||||
let ordinary = d.signature(&p).unwrap().unwrap();
|
||||
assert!(
|
||||
d.browsed_values(&p, &ordinary)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.key.field == fields[1]),
|
||||
"Ordinary asynchronous channel behavior is preserved"
|
||||
);
|
||||
assert_eq!(d.values[&rejected], " retained output expression ");
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
pub(crate) mod contextual;
|
||||
mod direct;
|
||||
mod view;
|
||||
use direct::Input;
|
||||
@@ -20,6 +21,7 @@ pub enum Tool {
|
||||
Control,
|
||||
Connect,
|
||||
Branch,
|
||||
Contextual,
|
||||
}
|
||||
impl Tool {
|
||||
fn title(self) -> &'static str {
|
||||
@@ -30,6 +32,7 @@ impl Tool {
|
||||
Self::Control => "Add control",
|
||||
Self::Connect => "Connect contributions",
|
||||
Self::Branch => "Insert two paths",
|
||||
Self::Contextual => "Connect qualified contributions",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +53,13 @@ pub enum Event {
|
||||
Control(ControlChoice),
|
||||
Search(usize, String),
|
||||
Page(usize, usize),
|
||||
Contextual(contextual::Event),
|
||||
Shelf(Shelf),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Shelf {
|
||||
Tools,
|
||||
Selection,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ControlChoice(pub ControlKind);
|
||||
@@ -70,11 +80,15 @@ pub struct State {
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id)>,
|
||||
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
pub(crate) focused: bool,
|
||||
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -99,6 +113,8 @@ struct Draft {
|
||||
guard: String,
|
||||
alternate_guard: String,
|
||||
control: ControlKind,
|
||||
#[serde(default)]
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
@@ -140,6 +156,9 @@ impl State {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Shelf(shelf) => {
|
||||
self.shelf = (self.shelf != Some(shelf)).then_some(shelf);
|
||||
}
|
||||
Event::Start(owner, tool, selected) => {
|
||||
let e = p
|
||||
.elements
|
||||
@@ -178,7 +197,17 @@ impl State {
|
||||
guard: String::new(),
|
||||
alternate_guard: "else".into(),
|
||||
control: ControlKind::Choice,
|
||||
contextual: (tool == Tool::Contextual)
|
||||
.then(|| contextual::Draft::new(p, owner, selected)),
|
||||
});
|
||||
if let Some(endpoint) = self
|
||||
.qualified_selection(p, owner)
|
||||
.map(|address| address.endpoint.clone())
|
||||
&& let Some(contextual) =
|
||||
self.draft.as_mut().and_then(|d| d.contextual.as_mut())
|
||||
{
|
||||
contextual.seed_source(endpoint);
|
||||
}
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
@@ -200,6 +229,11 @@ impl State {
|
||||
d.browsers[index].page = 0;
|
||||
}
|
||||
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
|
||||
Event::Contextual(event) => {
|
||||
if let Some(contextual) = &mut d.contextual {
|
||||
contextual.update(event);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -212,11 +246,31 @@ impl State {
|
||||
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.))
|
||||
let available = self.fit_area(fallback_width);
|
||||
(available.width / graph.width.max(1.))
|
||||
.min(available.height / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
}
|
||||
|
||||
fn fit_area(&self, fallback_width: f32) -> Size {
|
||||
// The sensor measures the inner scroller, independent of graph content.
|
||||
// Leave a small gutter for scrollbars; before first layout use a compact
|
||||
// provisional viewport and recompute when the sensor reports actual size.
|
||||
let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.));
|
||||
Size::new(
|
||||
(viewport.width - 24.).max(1.),
|
||||
(viewport.height - 10.).max(1.),
|
||||
)
|
||||
}
|
||||
|
||||
fn canvas_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
|
||||
fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool {
|
||||
let available = self.fit_area(fallback_width);
|
||||
self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height)
|
||||
}
|
||||
}
|
||||
|
||||
mod recovery_insertion {
|
||||
@@ -461,6 +515,12 @@ impl Draft {
|
||||
if !matches!(owner.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return Err("Choose a process or chain.".into());
|
||||
}
|
||||
if (self.tool == Tool::Contextual) != self.contextual.is_some() {
|
||||
return Err("The qualified link draft and selected tool do not agree.".into());
|
||||
}
|
||||
if let Some(draft) = &self.contextual {
|
||||
return draft.command(p, self.owner, &self.name);
|
||||
}
|
||||
for id in [self.before, self.after].into_iter().flatten() {
|
||||
if !is_contribution(p, self.owner, id) {
|
||||
return Err("Choose contributions from this process or chain.".into());
|
||||
@@ -638,6 +698,7 @@ impl Draft {
|
||||
lifeline: None,
|
||||
},
|
||||
Tool::Exchange => OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: target,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -744,10 +805,27 @@ struct GraphNode {
|
||||
detail: String,
|
||||
control: Option<ControlKind>,
|
||||
source: Option<Id>,
|
||||
preview: Vec<(Id, String)>,
|
||||
preview_links: Vec<(Id, Id)>,
|
||||
preview: Vec<(OccurrenceEndpoint, String)>,
|
||||
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
|
||||
preview_total: usize,
|
||||
}
|
||||
impl GraphNode {
|
||||
fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle {
|
||||
self.preview
|
||||
.iter()
|
||||
.position(|(candidate, _)| candidate == endpoint)
|
||||
.map(|i| {
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
self.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
self.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
)
|
||||
})
|
||||
.unwrap_or(self.rect)
|
||||
}
|
||||
}
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
source: Id,
|
||||
@@ -755,7 +833,30 @@ struct GraphEdge {
|
||||
guard: String,
|
||||
points: Vec<Point>,
|
||||
label: Rectangle,
|
||||
incidence: bool,
|
||||
layer: EdgeLayer,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EdgeLayer {
|
||||
Direct,
|
||||
Contextual,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PaintPass {
|
||||
DirectEdges,
|
||||
NodeBackgrounds,
|
||||
ContextualEdges,
|
||||
NodeContents,
|
||||
Labels,
|
||||
}
|
||||
const PAINT_PASSES: [PaintPass; 5] = [
|
||||
PaintPass::DirectEdges,
|
||||
PaintPass::NodeBackgrounds,
|
||||
PaintPass::ContextualEdges,
|
||||
PaintPass::NodeContents,
|
||||
PaintPass::Labels,
|
||||
];
|
||||
mod contextual_routing;
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
@@ -771,6 +872,16 @@ impl<'a> Graph<'a> {
|
||||
Self::expanded(p, owner, selected, &BTreeSet::new())
|
||||
}
|
||||
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
|
||||
Self::expanded_qualified(p, owner, selected, expanded, &BTreeSet::new())
|
||||
}
|
||||
fn expanded_qualified(
|
||||
p: &'a Project,
|
||||
owner: Id,
|
||||
selected: Id,
|
||||
expanded: &BTreeSet<Id>,
|
||||
expanded_paths: &BTreeSet<OccurrenceEndpoint>,
|
||||
) -> Self {
|
||||
let context_index = oa_core::topology_context::TopologyIndex::new(p);
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
@@ -864,26 +975,51 @@ impl<'a> Graph<'a> {
|
||||
if let Some(source) = sequence {
|
||||
let contributions = contributions(p, *source);
|
||||
node.preview_total = contributions.len();
|
||||
node.preview = contributions
|
||||
.into_iter()
|
||||
.take(if expanded.contains(&e.id) {
|
||||
usize::MAX
|
||||
} else {
|
||||
4
|
||||
})
|
||||
.map(|c| (c.id, p.named(c.id).to_owned()))
|
||||
.collect();
|
||||
node.preview = contextual::preview(
|
||||
&context_index,
|
||||
p,
|
||||
owner,
|
||||
e.id,
|
||||
expanded_paths,
|
||||
if expanded.contains(&e.id) { 512 } else { 4 },
|
||||
)
|
||||
.into_iter()
|
||||
.map(|endpoint| {
|
||||
let name = format!(
|
||||
"{}{}",
|
||||
"› ".repeat(endpoint.references.len().saturating_sub(1).min(3)),
|
||||
p.named(endpoint.occurrence)
|
||||
);
|
||||
(endpoint, name)
|
||||
})
|
||||
.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.iter().map(|(id, _)| id.clone()).collect();
|
||||
node.preview_links = sequence_links(p, *source)
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
preview_ids.contains(&r.source())
|
||||
&& preview_ids.contains(&r.target())
|
||||
preview_ids.contains(&OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.source(),
|
||||
}) && preview_ids.contains(&OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.target(),
|
||||
})
|
||||
})
|
||||
.map(|r| {
|
||||
(
|
||||
OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.source(),
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.target(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
@@ -944,11 +1080,83 @@ impl<'a> Graph<'a> {
|
||||
guard,
|
||||
points,
|
||||
label,
|
||||
incidence: false,
|
||||
layer: EdgeLayer::Direct,
|
||||
});
|
||||
}
|
||||
let mut contextual_pairs = BTreeSet::new();
|
||||
for r in p.relations.values() {
|
||||
let RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if *caller != owner {
|
||||
continue;
|
||||
}
|
||||
let source_id = source
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(source.occurrence);
|
||||
let target_id = target
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(target.occurrence);
|
||||
let Some(a) = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == source_id)
|
||||
.map(|n| contextual_routing::anchor(n, source, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(b) = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == target_id)
|
||||
.map(|n| contextual_routing::anchor(n, target, true))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let below = size.height + 30. + edges.len() as f32 * 48.;
|
||||
let first = contextual_pairs.insert((source.clone(), target.clone()));
|
||||
let (points, label) = contextual_routing::route(a, b, below, first);
|
||||
let (caption, incidence) = match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
(format!("Precedence · {guard}"), false)
|
||||
}
|
||||
ContextualLinkPurpose::Exchange { exchange } => {
|
||||
(format!("Assembly · {}", p.named(*exchange)), true)
|
||||
}
|
||||
ContextualLinkPurpose::CommonWork { work } => {
|
||||
(format!("Common work · {}", p.named(*work)), true)
|
||||
}
|
||||
};
|
||||
edges.push(GraphEdge {
|
||||
id: r.id,
|
||||
source: source_id,
|
||||
target: target_id,
|
||||
guard: caption,
|
||||
points,
|
||||
label,
|
||||
incidence,
|
||||
layer: EdgeLayer::Contextual,
|
||||
});
|
||||
}
|
||||
for e in &edges {
|
||||
for pt in &e.points {
|
||||
size.height = size.height.max(pt.y + 30.);
|
||||
if e.layer == EdgeLayer::Contextual {
|
||||
size.width = size.width.max(pt.x + 24.);
|
||||
}
|
||||
}
|
||||
if e.layer == EdgeLayer::Contextual {
|
||||
size.width = size.width.max(e.label.x + e.label.width + 24.);
|
||||
size.height = size.height.max(e.label.y + e.label.height + 24.);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
@@ -1053,155 +1261,196 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
f.scale(self.scale);
|
||||
for e in &self.edges {
|
||||
let selected =
|
||||
e.id == self.selected || e.source == self.selected || e.target == self.selected;
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&e.points,
|
||||
if selected { style::TEAL } else { style::MUTED },
|
||||
if selected { 2.5 } else { 1.5 },
|
||||
);
|
||||
}
|
||||
for n in &self.nodes {
|
||||
let rect = Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into());
|
||||
f.fill(&rect, Color::from_rgb8(243, 248, 248));
|
||||
f.stroke(
|
||||
&rect,
|
||||
Stroke::default()
|
||||
.with_color(if n.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
})
|
||||
.with_width(if n.id == self.selected { 2.5 } else { 1. }),
|
||||
);
|
||||
let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. };
|
||||
if let Some(kind) = n.control {
|
||||
let center = Point::new(n.rect.x + 33., n.rect.y + 34.);
|
||||
let glyph = match kind {
|
||||
ControlKind::Choice | ControlKind::Merge => Path::new(|p| {
|
||||
p.move_to(Point::new(center.x, center.y - 22.));
|
||||
p.line_to(Point::new(center.x + 23., center.y));
|
||||
p.line_to(Point::new(center.x, center.y + 22.));
|
||||
p.line_to(Point::new(center.x - 23., center.y));
|
||||
p.close();
|
||||
}),
|
||||
ControlKind::Fork | ControlKind::Join => Path::rectangle(
|
||||
Point::new(center.x - 23., center.y - 5.),
|
||||
Size::new(46., 10.),
|
||||
),
|
||||
ControlKind::Loop => Path::circle(center, 23.),
|
||||
};
|
||||
f.stroke(
|
||||
&glyph,
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.),
|
||||
);
|
||||
if matches!(kind, ControlKind::Fork | ControlKind::Join) {
|
||||
f.fill(&glyph, style::TEAL);
|
||||
} else {
|
||||
draw_text(
|
||||
&mut f,
|
||||
if kind == ControlKind::Loop {
|
||||
"IT"
|
||||
for pass in PAINT_PASSES {
|
||||
match pass {
|
||||
PaintPass::DirectEdges | PaintPass::ContextualEdges => {
|
||||
for e in self.edges.iter().filter(|edge| {
|
||||
edge.layer
|
||||
== if pass == PaintPass::DirectEdges {
|
||||
EdgeLayer::Direct
|
||||
} else {
|
||||
EdgeLayer::Contextual
|
||||
}
|
||||
}) {
|
||||
let selected = e.id == self.selected
|
||||
|| e.source == self.selected
|
||||
|| e.target == self.selected;
|
||||
if e.incidence {
|
||||
f.stroke(
|
||||
&Path::new(|path| {
|
||||
path.move_to(e.points[0]);
|
||||
for point in &e.points[1..] {
|
||||
path.line_to(*point);
|
||||
}
|
||||
}),
|
||||
Stroke::default()
|
||||
.with_color(if selected { style::TEAL } else { style::MUTED })
|
||||
.with_width(if selected { 2.5 } else { 1.5 }),
|
||||
);
|
||||
} else {
|
||||
"OR"
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&e.points,
|
||||
if selected { style::TEAL } else { style::MUTED },
|
||||
if selected { 2.5 } else { 1.5 },
|
||||
);
|
||||
}
|
||||
.into(),
|
||||
Point::new(center.x - 10., center.y - 8.),
|
||||
12.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2),
|
||||
Point::new(text_x, n.rect.y + 12.),
|
||||
13.,
|
||||
style::INK,
|
||||
);
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(&n.detail, 37, 2),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 60.),
|
||||
11.,
|
||||
style::MUTED,
|
||||
);
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
);
|
||||
let mini: BTreeMap<_, _> = n
|
||||
.preview
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, _))| {
|
||||
(
|
||||
*id,
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
n.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
n.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
PaintPass::NodeBackgrounds => {
|
||||
for n in &self.nodes {
|
||||
let rect =
|
||||
Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into());
|
||||
f.fill(&rect, Color::from_rgb8(243, 248, 248));
|
||||
f.stroke(
|
||||
&rect,
|
||||
Stroke::default()
|
||||
.with_color(if n.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
})
|
||||
.with_width(if n.id == self.selected { 2.5 } else { 1. }),
|
||||
);
|
||||
}
|
||||
}
|
||||
PaintPass::NodeContents => {
|
||||
for n in &self.nodes {
|
||||
let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. };
|
||||
if let Some(kind) = n.control {
|
||||
let center = Point::new(n.rect.x + 33., n.rect.y + 34.);
|
||||
let glyph = match kind {
|
||||
ControlKind::Choice | ControlKind::Merge => Path::new(|p| {
|
||||
p.move_to(Point::new(center.x, center.y - 22.));
|
||||
p.line_to(Point::new(center.x + 23., center.y));
|
||||
p.line_to(Point::new(center.x, center.y + 22.));
|
||||
p.line_to(Point::new(center.x - 23., center.y));
|
||||
p.close();
|
||||
}),
|
||||
ControlKind::Fork | ControlKind::Join => Path::rectangle(
|
||||
Point::new(center.x - 23., center.y - 5.),
|
||||
Size::new(46., 10.),
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (a, b) in &n.preview_links {
|
||||
let a = mini[a];
|
||||
let b = mini[b];
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&[
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
style::MUTED,
|
||||
1.,
|
||||
);
|
||||
ControlKind::Loop => Path::circle(center, 23.),
|
||||
};
|
||||
f.stroke(
|
||||
&glyph,
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.),
|
||||
);
|
||||
if matches!(kind, ControlKind::Fork | ControlKind::Join) {
|
||||
f.fill(&glyph, style::TEAL);
|
||||
} else {
|
||||
draw_text(
|
||||
&mut f,
|
||||
if kind == ControlKind::Loop {
|
||||
"IT"
|
||||
} else {
|
||||
"OR"
|
||||
}
|
||||
.into(),
|
||||
Point::new(center.x - 10., center.y - 8.),
|
||||
12.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2),
|
||||
Point::new(text_x, n.rect.y + 12.),
|
||||
13.,
|
||||
style::INK,
|
||||
);
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(&n.detail, 37, 2),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 60.),
|
||||
11.,
|
||||
style::MUTED,
|
||||
);
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
format!(
|
||||
"{} shown · {} direct contributions",
|
||||
n.preview.len(),
|
||||
n.preview_total
|
||||
),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
);
|
||||
let mini: BTreeMap<_, _> = n
|
||||
.preview
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, _))| {
|
||||
(
|
||||
id.clone(),
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
n.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
n.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (a, b) in &n.preview_links {
|
||||
let a = mini[a];
|
||||
let b = mini[b];
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&[
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
style::MUTED,
|
||||
1.,
|
||||
);
|
||||
}
|
||||
for (id, name) in &n.preview {
|
||||
let r = mini[id];
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
f.stroke(
|
||||
&Path::rectangle(r.position(), r.size()),
|
||||
Stroke::default().with_color(style::LINE),
|
||||
);
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(name, 17, 2),
|
||||
Point::new(r.x + 4., r.y + 3.),
|
||||
9.,
|
||||
style::INK,
|
||||
);
|
||||
}
|
||||
if n.preview.is_empty() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
"Source has no contributions yet".into(),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 136.),
|
||||
11.,
|
||||
style::MUTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, name) in &n.preview {
|
||||
let r = mini[id];
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
f.stroke(
|
||||
&Path::rectangle(r.position(), r.size()),
|
||||
Stroke::default().with_color(style::LINE),
|
||||
);
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(name, 17, 2),
|
||||
Point::new(r.x + 4., r.y + 3.),
|
||||
9.,
|
||||
style::INK,
|
||||
);
|
||||
PaintPass::Labels => {
|
||||
for e in &self.edges {
|
||||
if !e.guard.is_empty() {
|
||||
f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE);
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(&format!("[{}]", e.guard), 19, 2),
|
||||
e.label.position(),
|
||||
11.,
|
||||
style::INK,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.preview.is_empty() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
"Source has no contributions yet".into(),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 136.),
|
||||
11.,
|
||||
style::MUTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in &self.edges {
|
||||
if !e.guard.is_empty() {
|
||||
f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE);
|
||||
draw_text(
|
||||
&mut f,
|
||||
wrapped(&format!("[{}]", e.guard), 19, 2),
|
||||
e.label.position(),
|
||||
11.,
|
||||
style::INK,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.overlay(state, &mut f, cursor, bounds);
|
||||
@@ -1296,6 +1545,7 @@ mod tests {
|
||||
guard: "request accepted".into(),
|
||||
alternate_guard: "else".into(),
|
||||
control: ControlKind::Choice,
|
||||
contextual: None,
|
||||
}
|
||||
}
|
||||
fn legacy_fixture() -> (Project, Id) {
|
||||
@@ -1339,6 +1589,7 @@ mod tests {
|
||||
owner_id,
|
||||
&format!("Ordered contribution {index}"),
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchanges[index % exchanges.len()]),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -1565,7 +1816,13 @@ mod tests {
|
||||
.iter()
|
||||
.find(|n| n.source == Some(source_id))
|
||||
.unwrap();
|
||||
assert_eq!(reference.preview[0].0, inside_id);
|
||||
assert_eq!(
|
||||
reference.preview[0].0,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![reference.id],
|
||||
occurrence: inside_id
|
||||
}
|
||||
);
|
||||
assert_eq!(result.children(source_id).count(), 1);
|
||||
assert!(
|
||||
!targets(&result, source_id, Tool::Reuse)
|
||||
@@ -1687,4 +1944,102 @@ mod tests {
|
||||
state.zoom(None);
|
||||
assert!(state.scale(graph.size, 500.) < 1.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_uses_measured_height_and_reports_minimum_without_changing_manual_zoom() {
|
||||
let mut state = State::default();
|
||||
let graph = Size::new(420., 960.);
|
||||
state.zoom(None);
|
||||
let mut previous = 0.;
|
||||
for height in [180., 320., 600.] {
|
||||
state.viewport(Size::new(1000., height));
|
||||
let scale = state.scale(graph, 200.);
|
||||
assert!(scale > previous);
|
||||
assert!(graph.height * scale <= height - 10. + 0.01);
|
||||
assert!(!state.fit_limited(graph, 200.));
|
||||
previous = scale;
|
||||
}
|
||||
state.viewport(Size::new(500., 180.));
|
||||
let huge = Size::new(500., 10_000.);
|
||||
assert_eq!(state.scale(huge, 200.), 0.05);
|
||||
assert!(state.fit_limited(huge, 200.));
|
||||
state.zoom(Some(1.));
|
||||
for height in [180., 600.] {
|
||||
state.viewport(Size::new(500., height));
|
||||
assert_eq!(state.scale(huge, 200.), 1.);
|
||||
assert!(!state.fit_limited(huge, 200.));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_disclosures_and_focus_preserve_rejected_raw_draft_and_accepted_source() {
|
||||
let (p, owner, _, _, _, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
for input in [
|
||||
Event::Start(owner, Tool::Contextual, None),
|
||||
Event::Name(" pending 雨 ".into()),
|
||||
Event::Contextual(contextual::Event::Guard(" unavailable && 雨 ".into())),
|
||||
] {
|
||||
let _ = app.update(event(input));
|
||||
}
|
||||
let before = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.behavior.prepare_pending(app.project()).is_err());
|
||||
for (shelf, expected) in [
|
||||
(Shelf::Tools, Some(Shelf::Tools)),
|
||||
(Shelf::Selection, Some(Shelf::Selection)),
|
||||
(Shelf::Selection, None),
|
||||
] {
|
||||
let focused = app.behavior.topology.focused;
|
||||
let _ = app.update(event(Event::Shelf(shelf)));
|
||||
assert_eq!(app.behavior.topology.shelf, expected);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert_eq!(app.behavior.topology.focused, !focused);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), before);
|
||||
assert!(app.behavior.prepare_pending(app.project()).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() {
|
||||
let (p, owner, _, _, _, _) = fixture();
|
||||
let mut state = State::default();
|
||||
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.));
|
||||
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.));
|
||||
let context = Context {
|
||||
document: p.id,
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
for scale in [0.45, 1.] {
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
graph.scale = scale;
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
let node = &graph.nodes[0];
|
||||
let id = node.id;
|
||||
let point = node.rect.center();
|
||||
let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.));
|
||||
let screen = Point::new(
|
||||
bounds.x + point.x * scale - 11.,
|
||||
bounds.y + point.y * scale - 19.,
|
||||
);
|
||||
let message = graph
|
||||
.input(
|
||||
&mut Input::default(),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0);
|
||||
assert!(
|
||||
matches!(message,Some(Message::TopologyCanvas(CanvasEvent::Select(c,actual))) if c==context && actual==id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
//! Lazy qualified authoring over the core address resolver. A folded reference
|
||||
//! never replaces its retained endpoint with the reference or a same-named leaf.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use oa_core::topology_context::TopologyIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Purpose {
|
||||
#[default]
|
||||
Precedence,
|
||||
Exchange,
|
||||
CommonWork,
|
||||
}
|
||||
impl std::fmt::Display for Purpose {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Precedence => "Precedence",
|
||||
Self::Exchange => "Exchange assembly",
|
||||
Self::CommonWork => "Common work incidence",
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Side(bool),
|
||||
Pick(OccurrenceEndpoint),
|
||||
Expand(OccurrenceEndpoint),
|
||||
Up,
|
||||
Root,
|
||||
Purpose(Purpose),
|
||||
Evidence(Choice),
|
||||
Guard(String),
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
source: Option<OccurrenceEndpoint>,
|
||||
target: Option<OccurrenceEndpoint>,
|
||||
target_side: bool,
|
||||
purpose: Purpose,
|
||||
evidence: Option<Id>,
|
||||
guard: String,
|
||||
search: String,
|
||||
page: usize,
|
||||
#[serde(default)]
|
||||
scope: Vec<Id>,
|
||||
}
|
||||
impl Draft {
|
||||
pub(super) fn seed_source(&mut self, endpoint: OccurrenceEndpoint) {
|
||||
if self.edit.is_none() {
|
||||
self.source = Some(endpoint);
|
||||
}
|
||||
}
|
||||
pub(super) fn new(p: &Project, owner: Id, selected: Option<Id>) -> Self {
|
||||
if let Some(r) = selected.and_then(|id| p.relations.get(&id))
|
||||
&& let RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
&& *caller == owner
|
||||
{
|
||||
let (purpose, evidence, guard) = match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
(Purpose::Precedence, None, guard.clone())
|
||||
}
|
||||
ContextualLinkPurpose::Exchange { exchange } => {
|
||||
(Purpose::Exchange, Some(*exchange), String::new())
|
||||
}
|
||||
ContextualLinkPurpose::CommonWork { work } => {
|
||||
(Purpose::CommonWork, Some(*work), String::new())
|
||||
}
|
||||
};
|
||||
return Self {
|
||||
edit: Some(r.id),
|
||||
source: Some(source.clone()),
|
||||
target: Some(target.clone()),
|
||||
purpose,
|
||||
evidence,
|
||||
guard,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
Self {
|
||||
source: selected
|
||||
.filter(|id| is_contribution(p, owner, *id))
|
||||
.map(|occurrence| OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
pub(super) fn update(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Side(target) => self.target_side = target,
|
||||
Event::Pick(endpoint) => {
|
||||
if self.target_side {
|
||||
self.target = Some(endpoint);
|
||||
} else {
|
||||
self.source = Some(endpoint);
|
||||
}
|
||||
}
|
||||
Event::Expand(endpoint) => {
|
||||
self.scope = endpoint.references;
|
||||
self.scope.push(endpoint.occurrence);
|
||||
self.search.clear();
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Up => {
|
||||
self.scope.pop();
|
||||
self.search.clear();
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Root => {
|
||||
self.scope.clear();
|
||||
self.search.clear();
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Purpose(purpose) => self.purpose = purpose,
|
||||
Event::Evidence(choice) => self.evidence = Some(choice.id),
|
||||
Event::Guard(raw) => self.guard = raw,
|
||||
Event::Search(raw) => {
|
||||
self.search = raw;
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Page(page) => self.page = page,
|
||||
}
|
||||
}
|
||||
fn purpose(&self) -> Result<ContextualLinkPurpose, String> {
|
||||
Ok(match self.purpose {
|
||||
Purpose::Precedence => ContextualLinkPurpose::Precedence {
|
||||
guard: self.guard.clone(),
|
||||
},
|
||||
Purpose::Exchange => ContextualLinkPurpose::Exchange {
|
||||
exchange: self
|
||||
.evidence
|
||||
.ok_or("Choose the exact exchange used for assembly.")?,
|
||||
},
|
||||
Purpose::CommonWork => ContextualLinkPurpose::CommonWork {
|
||||
work: self.evidence.ok_or("Choose the shared work identity.")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
pub(super) fn command(&self, p: &Project, owner: Id, name: &str) -> Result<Command, String> {
|
||||
let source = self
|
||||
.source
|
||||
.as_ref()
|
||||
.ok_or("Choose the qualified source contribution.")?;
|
||||
let target = self
|
||||
.target
|
||||
.as_ref()
|
||||
.ok_or("Choose the qualified target contribution.")?;
|
||||
let purpose = self.purpose()?;
|
||||
TopologyIndex::new(p)
|
||||
.validate_link(owner, source, target, &purpose)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut relation = if let Some(id) = self.edit {
|
||||
p.relations.get(&id).filter(|r| matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller==owner)).ok_or("This caller-owned link no longer exists.")?.clone()
|
||||
} else {
|
||||
let id = Id::new_v4();
|
||||
Relation {
|
||||
id,
|
||||
alias: p.fresh_alias("contextual_link", "relation", id),
|
||||
name: self.purpose.to_string(),
|
||||
description: String::new(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
purpose: purpose.clone(),
|
||||
},
|
||||
}
|
||||
};
|
||||
if !name.trim().is_empty() {
|
||||
relation.name = name.to_owned();
|
||||
}
|
||||
relation.body = RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
purpose,
|
||||
};
|
||||
let command = if self.edit.is_some() {
|
||||
Command::EditRelation(relation.id, relation)
|
||||
} else {
|
||||
Command::CreateRelation(relation)
|
||||
};
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn address_label(p: &Project, owner: Id, endpoint: &OccurrenceEndpoint) -> String {
|
||||
std::iter::once(&owner)
|
||||
.chain(endpoint.references.iter())
|
||||
.chain(std::iter::once(&endpoint.occurrence))
|
||||
.map(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| format!("Unavailable {id}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ")
|
||||
}
|
||||
struct AddressRow {
|
||||
endpoint: OccurrenceEndpoint,
|
||||
reference: bool,
|
||||
}
|
||||
fn rows(p: &Project, owner: Id, scope: &[Id], search: &str) -> Result<Vec<AddressRow>, String> {
|
||||
let index = TopologyIndex::new(p);
|
||||
let sequence = index
|
||||
.resolve_scope(owner, scope)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let query = search.trim().to_lowercase();
|
||||
Ok(index
|
||||
.contributions(sequence)
|
||||
.into_iter()
|
||||
.filter_map(|occurrence| {
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: scope.to_vec(),
|
||||
occurrence,
|
||||
};
|
||||
if !address_label(p, owner, &endpoint)
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let reference = matches!(
|
||||
p.elements.get(&occurrence).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
||||
..
|
||||
})
|
||||
);
|
||||
Some(AddressRow {
|
||||
endpoint,
|
||||
reference,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn event(value: Event) -> Message {
|
||||
super::event(super::Event::Contextual(value))
|
||||
}
|
||||
|
||||
pub(super) fn preview(
|
||||
index: &TopologyIndex<'_>,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
reference: Id,
|
||||
expanded: &BTreeSet<OccurrenceEndpoint>,
|
||||
limit: usize,
|
||||
) -> Vec<OccurrenceEndpoint> {
|
||||
let children = |endpoint: &OccurrenceEndpoint| {
|
||||
if index.resolve(owner, endpoint).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
let sequence = p
|
||||
.elements
|
||||
.get(&endpoint.occurrence)
|
||||
.and_then(|e| e.shared())
|
||||
.and_then(|body| match body {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => *sequence,
|
||||
_ => None,
|
||||
});
|
||||
let Some(sequence) = sequence else {
|
||||
return vec![];
|
||||
};
|
||||
let available: BTreeSet<_> = index.contributions(sequence).into_iter().collect();
|
||||
let mut references = endpoint.references.clone();
|
||||
references.push(endpoint.occurrence);
|
||||
p.elements
|
||||
.get(&sequence)
|
||||
.and_then(|e| e.steps())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|id| available.contains(id))
|
||||
.map(|occurrence| OccurrenceEndpoint {
|
||||
references: references.clone(),
|
||||
occurrence: *occurrence,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let root = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: reference,
|
||||
};
|
||||
let mut pending = children(&root);
|
||||
pending.reverse();
|
||||
let mut visible = vec![];
|
||||
while let Some(endpoint) = pending.pop() {
|
||||
if visible.len() == limit {
|
||||
break;
|
||||
}
|
||||
if index.resolve(owner, &endpoint).is_err() {
|
||||
continue;
|
||||
}
|
||||
if expanded.contains(&endpoint) {
|
||||
pending.extend(children(&endpoint).into_iter().rev());
|
||||
}
|
||||
visible.push(endpoint);
|
||||
}
|
||||
visible
|
||||
}
|
||||
pub(super) fn view<'a>(p: &'a Project, owner: Id, d: &'a Draft) -> Element<'a, Message> {
|
||||
let mut out = column![
|
||||
text("Choose each endpoint in its caller context. Enter a reference to browse all its contributions; repeated uses stay distinct. Browsing preserves both selected endpoints.").size(12),
|
||||
row![button("Choose source").style(if !d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(false))), button("Choose target").style(if d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(true)))].spacing(6),
|
||||
text(format!("Source: {}", d.source.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12),
|
||||
text(format!("Target: {}", d.target.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12),
|
||||
pick_list(vec![Purpose::Precedence,Purpose::Exchange,Purpose::CommonWork],Some(d.purpose),|p| event(Event::Purpose(p))).width(Fill).text_size(12).padding(8)
|
||||
].spacing(7);
|
||||
match d.purpose {
|
||||
Purpose::Precedence => {
|
||||
out = out.push(
|
||||
text_input("Precedence guard (optional)", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
}
|
||||
purpose => {
|
||||
let choices = if purpose == Purpose::Exchange {
|
||||
targets(p, owner, Tool::Exchange)
|
||||
} else {
|
||||
targets(p, owner, Tool::Work)
|
||||
};
|
||||
let selected = d.evidence.map(|id| Choice {
|
||||
id,
|
||||
label: p.named(id).to_owned(),
|
||||
});
|
||||
out = out
|
||||
.push(
|
||||
pick_list(choices, selected, |c| event(Event::Evidence(c)))
|
||||
.placeholder("Choose exact engineering identity…")
|
||||
.width(Fill)
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
)
|
||||
.push(
|
||||
text("Assembly incidence does not imply execution order or fuse executions.")
|
||||
.size(11),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(
|
||||
text_input("Search all contributions in this scope…", &d.search)
|
||||
.on_input(|v| event(Event::Search(v)))
|
||||
.padding(7)
|
||||
.size(12),
|
||||
);
|
||||
let scope_label = std::iter::once(&owner)
|
||||
.chain(d.scope.iter())
|
||||
.map(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| format!("Unavailable {id}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ");
|
||||
out = out
|
||||
.push(text(format!("Browsing: {scope_label}")).size(12))
|
||||
.push(
|
||||
row![
|
||||
button("Root").on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Root))),
|
||||
button("Up one reference")
|
||||
.on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Up)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
let rows = match rows(p, owner, &d.scope, &d.search) {
|
||||
Ok(rows) => rows,
|
||||
Err(error) => return out.push(text(error).size(12)).into(),
|
||||
};
|
||||
let range = super::view::choice_page(rows.len(), d.page);
|
||||
let page = range.start / 24;
|
||||
out = out.push(
|
||||
row![
|
||||
button("Previous").on_press_maybe((page > 0).then(|| event(Event::Page(page - 1)))),
|
||||
text(format!(
|
||||
"{} matching contributions · page {}",
|
||||
rows.len(),
|
||||
page + 1
|
||||
))
|
||||
.size(11),
|
||||
button("Next")
|
||||
.on_press_maybe((range.end < rows.len()).then(|| event(Event::Page(page + 1))))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
for r in &rows[range] {
|
||||
let mut line = row![
|
||||
button(text(address_label(p, owner, &r.endpoint)).size(12))
|
||||
.width(Fill)
|
||||
.on_press(event(Event::Pick(r.endpoint.clone())))
|
||||
]
|
||||
.spacing(5);
|
||||
if r.reference {
|
||||
line = line
|
||||
.push(button("Enter reference").on_press(event(Event::Expand(r.endpoint.clone()))));
|
||||
}
|
||||
out = out.push(line);
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id, Id) {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let sequence = p
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Reusable check",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sequence_id = sequence.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
|
||||
let leaf = occurrence(
|
||||
&p,
|
||||
sequence_id,
|
||||
"Verify pressure",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let leaf_id = leaf.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(leaf)]).unwrap();
|
||||
let a = occurrence(
|
||||
&p,
|
||||
f.owner,
|
||||
"First check",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let b = occurrence(
|
||||
&p,
|
||||
f.owner,
|
||||
"Second check",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let (a_id, b_id) = (a.id, b.id);
|
||||
p = execute_batch(&p, &[Command::CreateElement(a), Command::CreateElement(b)]).unwrap();
|
||||
(p, f.owner, a_id, b_id, leaf_id, f.work[0])
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_repeated_sources_remain_distinct_and_apply_has_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (p, owner, a, b, leaf, work) = fixture(layer);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![b],
|
||||
occurrence: leaf,
|
||||
};
|
||||
for event in [
|
||||
Event::Pick(source.clone()),
|
||||
Event::Side(true),
|
||||
Event::Pick(target.clone()),
|
||||
Event::Purpose(Purpose::CommonWork),
|
||||
Event::Evidence(Choice {
|
||||
id: work,
|
||||
label: String::new(),
|
||||
}),
|
||||
] {
|
||||
state
|
||||
.update(&p, super::super::Event::Contextual(event))
|
||||
.unwrap();
|
||||
}
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let restored = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
|
||||
let command = restored.prepare_pending(&p).unwrap();
|
||||
let Command::CreateRelation(record) = &command else {
|
||||
panic!("one caller-owned record")
|
||||
};
|
||||
assert_eq!(
|
||||
record.body,
|
||||
RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
purpose: ContextualLinkPurpose::CommonWork { work }
|
||||
}
|
||||
);
|
||||
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
||||
let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
doc.execute(command.clone()).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(
|
||||
doc.project().elements,
|
||||
p.elements,
|
||||
"no copied work or source occurrences"
|
||||
);
|
||||
let after = doc.bundle().clone();
|
||||
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
assert_ne!(
|
||||
address_label(&p, owner, &source),
|
||||
address_label(&p, owner, &target)
|
||||
);
|
||||
assert!(
|
||||
Graph::new(&expected, owner, record.id)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == record.id && edge.incidence)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_rejected_endpoint_keeps_exact_raw_intent_after_recovery() {
|
||||
let (p, owner, a, _, leaf, _) = fixture(Layer::Oa);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
for event in [
|
||||
Event::Pick(OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: leaf,
|
||||
}),
|
||||
Event::Side(true),
|
||||
Event::Pick(OccurrenceEndpoint {
|
||||
references: vec![Id::new_v4()],
|
||||
occurrence: leaf,
|
||||
}),
|
||||
Event::Guard(" pressure > && ".into()),
|
||||
] {
|
||||
state
|
||||
.update(&p, super::super::Event::Contextual(event))
|
||||
.unwrap();
|
||||
}
|
||||
let before = state.recovery_snapshot().unwrap();
|
||||
assert!(state.prepare_pending(&p).is_err());
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), before);
|
||||
let restored = State::from_recovery(before.clone().unwrap()).unwrap();
|
||||
assert!(restored.prepare_pending(&p).is_err());
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), before);
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_deleted_transient_selection_does_not_seed_or_hide_retained_authored_endpoint()
|
||||
{
|
||||
let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa);
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let mut state = State::default();
|
||||
state.select_context(oa_core::topology_context::ContextAddress {
|
||||
owner,
|
||||
endpoint: endpoint.clone(),
|
||||
});
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
let before = state.recovery_snapshot().unwrap();
|
||||
let deleted = execute_batch(&p, &[Command::Delete(leaf)]).unwrap();
|
||||
assert!(state.qualified_selection(&deleted, owner).is_none());
|
||||
assert_eq!(
|
||||
state.recovery_snapshot().unwrap(),
|
||||
before,
|
||||
"Authored endpoint is retained for explicit repair"
|
||||
);
|
||||
assert!(state.prepare_pending(&deleted).is_err());
|
||||
state.remember(owner, reference);
|
||||
assert_eq!(state.back(&deleted), Some((owner, reference)));
|
||||
assert!(state.selected_context.is_none());
|
||||
state.draft = None;
|
||||
state
|
||||
.update(
|
||||
&deleted,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contextual
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.source
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_scope_paging_and_search_reach_tail_beyond_512_without_losing_endpoints() {
|
||||
let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa);
|
||||
let sequence = p.elements[&leaf].parent.unwrap();
|
||||
let mut commands = Vec::new();
|
||||
let mut tails = Vec::new();
|
||||
for (scope_index, scope) in [owner, sequence].into_iter().enumerate() {
|
||||
for n in 0..520 {
|
||||
let mut record = occurrence(
|
||||
&p,
|
||||
scope,
|
||||
&format!("Contribution {n:04}"),
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
record.alias = format!("scope_{scope_index}_contribution_{n:04}");
|
||||
if n == 519 {
|
||||
tails.push(record.id);
|
||||
}
|
||||
commands.push(Command::CreateElement(record));
|
||||
}
|
||||
}
|
||||
let p = execute_batch(&p, &commands).unwrap();
|
||||
let all = rows(&p, owner, &[], "").unwrap();
|
||||
assert!(all.len() > 512);
|
||||
let last_page = super::super::view::choice_page(all.len(), usize::MAX);
|
||||
assert!(
|
||||
all[last_page]
|
||||
.iter()
|
||||
.any(|row| row.endpoint.occurrence == tails[0])
|
||||
);
|
||||
let found = rows(&p, owner, &[], "Contribution 0519").unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].endpoint.occurrence, tails[0]);
|
||||
let mut draft = Draft::default();
|
||||
draft.update(Event::Pick(OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: leaf,
|
||||
}));
|
||||
draft.update(Event::Side(true));
|
||||
draft.update(Event::Pick(found[0].endpoint.clone()));
|
||||
let chosen = (draft.source.clone(), draft.target.clone());
|
||||
draft.update(Event::Expand(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: reference,
|
||||
}));
|
||||
draft.update(Event::Search("Contribution 0519".into()));
|
||||
let nested = rows(&p, owner, &draft.scope, &draft.search).unwrap();
|
||||
assert_eq!(nested.len(), 1);
|
||||
assert_eq!(
|
||||
nested[0].endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: tails[1]
|
||||
}
|
||||
);
|
||||
let saved = serde_json::to_value(&draft).unwrap();
|
||||
let mut restored: Draft = serde_json::from_value(saved.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&restored).unwrap(), saved);
|
||||
restored.update(Event::Up);
|
||||
assert!(restored.scope.is_empty());
|
||||
assert_eq!((restored.source.clone(), restored.target.clone()), chosen);
|
||||
assert!(restored.command(&p, owner, "Tail constraint").is_ok());
|
||||
let mut historical = saved;
|
||||
historical.as_object_mut().unwrap().remove("scope");
|
||||
let restored: Draft = serde_json::from_value(historical).unwrap();
|
||||
assert!(restored.scope.is_empty());
|
||||
assert_eq!((restored.source, restored.target), chosen);
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_nested_preview_folds_only_the_exact_reference_instance() {
|
||||
let (mut p, owner, a, b, leaf, _) = fixture(Layer::Oa);
|
||||
let source = p.elements[&leaf].parent.unwrap();
|
||||
let deep = p
|
||||
.make_element(Kind::Process, Layer::Oa, "Nested definition", None)
|
||||
.unwrap();
|
||||
let deep_id = deep.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(deep)]).unwrap();
|
||||
let deep_leaf = occurrence(
|
||||
&p,
|
||||
deep_id,
|
||||
"Nested control",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let deep_leaf_id = deep_leaf.id;
|
||||
let nested = occurrence(
|
||||
&p,
|
||||
source,
|
||||
"Nested use",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(deep_id),
|
||||
},
|
||||
);
|
||||
let nested_id = nested.id;
|
||||
p = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::CreateElement(deep_leaf),
|
||||
Command::CreateElement(nested),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let path = OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: nested_id,
|
||||
};
|
||||
let mut state = State::default();
|
||||
assert!(state.expand_context(&p, owner, path.clone()));
|
||||
state.expanded.insert(b);
|
||||
let graph = Graph::expanded_qualified(
|
||||
&p,
|
||||
owner,
|
||||
owner,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == a)
|
||||
.unwrap()
|
||||
.preview
|
||||
.iter()
|
||||
.any(|(v, _)| v.references == vec![a, nested_id] && v.occurrence == deep_leaf_id)
|
||||
);
|
||||
assert!(
|
||||
!graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == b)
|
||||
.unwrap()
|
||||
.preview
|
||||
.iter()
|
||||
.any(|(v, _)| v.occurrence == deep_leaf_id)
|
||||
);
|
||||
assert!(state.expand_context(&p, owner, path));
|
||||
let graph = Graph::expanded_qualified(
|
||||
&p,
|
||||
owner,
|
||||
owner,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
assert!(
|
||||
!graph
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.preview.iter().any(|(v, _)| v.occurrence == deep_leaf_id))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Contextual routes use outer-card gutters, never inner preview x coordinates.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct Anchor {
|
||||
point: Point,
|
||||
lane: f32,
|
||||
right: bool,
|
||||
}
|
||||
pub(super) fn anchor(node: &GraphNode, endpoint: &OccurrenceEndpoint, incoming: bool) -> Anchor {
|
||||
let preview = node
|
||||
.preview
|
||||
.iter()
|
||||
.position(|(address, _)| address == endpoint);
|
||||
// The outward-facing side of each preview column has no sibling mini-card
|
||||
// between it and the host boundary. Hidden addresses use the host itself.
|
||||
let right = preview.map_or(!incoming, |index| index % 2 == 1);
|
||||
let rect = node.endpoint_rect(endpoint);
|
||||
Anchor {
|
||||
point: Point::new(
|
||||
if right { rect.x + rect.width } else { rect.x },
|
||||
rect.center_y(),
|
||||
),
|
||||
lane: if right {
|
||||
node.rect.x + node.rect.width + 18.
|
||||
} else {
|
||||
node.rect.x - 16.
|
||||
},
|
||||
right,
|
||||
}
|
||||
}
|
||||
pub(super) fn route(a: Anchor, mut b: Anchor, below: f32, first: bool) -> (Vec<Point>, Rectangle) {
|
||||
let same_lane = (a.lane - b.lane).abs() < 0.1;
|
||||
let short = first && same_lane && a.right && b.right;
|
||||
// A small separate return lane keeps a left-side detour from retracing itself.
|
||||
// Both lanes remain outside the first card's x=24 boundary.
|
||||
if same_lane && !short {
|
||||
b.lane += 4.;
|
||||
}
|
||||
let mut points = vec![a.point, Point::new(a.lane, a.point.y)];
|
||||
if !short {
|
||||
points.extend([Point::new(a.lane, below), Point::new(b.lane, below)]);
|
||||
}
|
||||
points.extend([Point::new(b.lane, b.point.y), b.point]);
|
||||
points.dedup();
|
||||
let label = Rectangle::new(
|
||||
if short {
|
||||
Point::new(a.lane - 6., (a.point.y + b.point.y) / 2. - 18.)
|
||||
} else {
|
||||
Point::new(((a.lane + b.lane) / 2. - 66.).max(4.), below - 38.)
|
||||
},
|
||||
Size::new(132., 36.),
|
||||
);
|
||||
(points, label)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn baseline() -> Project {
|
||||
persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/source7-native/base/expected-baseline.json"
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
fn id(p: &Project, alias: &str) -> Id {
|
||||
p.elements
|
||||
.values()
|
||||
.find(|element| element.alias == alias)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
fn add(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
) -> (Project, Id) {
|
||||
let id = Id::new_v4();
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: p.fresh_alias("native_gate", "relation", id),
|
||||
name: "First assessment gate".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: "Check accepted".into(),
|
||||
},
|
||||
},
|
||||
};
|
||||
(
|
||||
execute_batch(p, &[Command::CreateRelation(relation)]).unwrap(),
|
||||
id,
|
||||
)
|
||||
}
|
||||
fn crosses(a: Point, b: Point, rect: Rectangle) -> bool {
|
||||
if a.x == b.x {
|
||||
a.x > rect.x
|
||||
&& a.x < rect.x + rect.width
|
||||
&& a.y.min(b.y) < rect.y + rect.height
|
||||
&& a.y.max(b.y) > rect.y
|
||||
} else {
|
||||
assert_eq!(a.y, b.y, "Every route segment is orthogonal");
|
||||
a.y > rect.y
|
||||
&& a.y < rect.y + rect.height
|
||||
&& a.x.min(b.x) < rect.x + rect.width
|
||||
&& a.x.max(b.x) > rect.x
|
||||
}
|
||||
}
|
||||
fn assert_clear(
|
||||
graph: &Graph<'_>,
|
||||
edge: &GraphEdge,
|
||||
source: &OccurrenceEndpoint,
|
||||
target: &OccurrenceEndpoint,
|
||||
) {
|
||||
for node in &graph.nodes {
|
||||
if node.id != edge.source && node.id != edge.target {
|
||||
assert!(
|
||||
!edge
|
||||
.points
|
||||
.windows(2)
|
||||
.any(|line| crosses(line[0], line[1], node.rect)),
|
||||
"Route crosses unrelated {}",
|
||||
node.title
|
||||
);
|
||||
}
|
||||
for (endpoint, _) in &node.preview {
|
||||
if endpoint != source && endpoint != target {
|
||||
let mini = node.endpoint_rect(endpoint);
|
||||
assert!(
|
||||
!edge
|
||||
.points
|
||||
.windows(2)
|
||||
.any(|line| crosses(line[0], line[1], mini)),
|
||||
"Route crosses another qualified contribution {endpoint:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_native_x_to_first_b_keeps_visible_arrow_above_background_and_clear_of_other_references()
|
||||
{
|
||||
for prefix in ["oa", "sa"] {
|
||||
let p = baseline();
|
||||
let owner = id(&p, &format!("{prefix}_caller"));
|
||||
let reference = id(&p, &format!("{prefix}_r1"));
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id(&p, &format!("{prefix}_x")),
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: id(&p, &format!("{prefix}_b")),
|
||||
};
|
||||
let (q, link) = add(&p, owner, source.clone(), target.clone());
|
||||
let graph = Graph::new(&q, owner, link);
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let host = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == reference)
|
||||
.unwrap();
|
||||
let mini = host.endpoint_rect(&target);
|
||||
assert_eq!(
|
||||
mini,
|
||||
Rectangle::new(Point::new(161., 282.), Size::new(113., 32.))
|
||||
);
|
||||
assert_eq!(
|
||||
edge.points,
|
||||
vec![
|
||||
Point::new(294., 76.),
|
||||
Point::new(312., 76.),
|
||||
Point::new(312., 298.),
|
||||
Point::new(274., 298.)
|
||||
]
|
||||
);
|
||||
assert_clear(&graph, edge, &source, &target);
|
||||
assert_eq!(
|
||||
graph.size.height,
|
||||
Graph::new(&p, owner, owner).size.height,
|
||||
"This adjacent target needs no below-canvas detour"
|
||||
);
|
||||
assert_eq!(edge.layer, EdgeLayer::Contextual);
|
||||
let position = |pass| {
|
||||
PAINT_PASSES
|
||||
.iter()
|
||||
.position(|actual| *actual == pass)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(position(PaintPass::NodeBackgrounds) < position(PaintPass::ContextualEdges));
|
||||
assert!(position(PaintPass::ContextualEdges) < position(PaintPass::NodeContents));
|
||||
assert!(position(PaintPass::DirectEdges) < position(PaintPass::NodeBackgrounds));
|
||||
// The arrow points left into B's right border. Its head stays in the
|
||||
// host's clear right padding and is not covered by any mini-card.
|
||||
let end = *edge.points.last().unwrap();
|
||||
assert_eq!(end, Point::new(mini.x + mini.width, mini.center_y()));
|
||||
let stub = Point::new(end.x + 10., end.y);
|
||||
assert!(host.rect.contains(stub));
|
||||
let context = Context {
|
||||
document: q.id,
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
for scale in [0.45, 1.] {
|
||||
let click = |point: Point| {
|
||||
let mut interactive = Graph::new(&q, owner, link);
|
||||
interactive.interaction = Some((context, CanvasTool::Select, true));
|
||||
interactive.scale = scale;
|
||||
interactive
|
||||
.input(
|
||||
&mut Input::default(),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(
|
||||
Point::ORIGIN,
|
||||
Size::new(
|
||||
interactive.size.width * scale,
|
||||
interactive.size.height * scale,
|
||||
),
|
||||
),
|
||||
mouse::Cursor::Available(Point::new(point.x * scale, point.y * scale)),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
};
|
||||
assert!(
|
||||
matches!(click(stub), Some(Message::TopologyCanvas(CanvasEvent::Select(_, id))) if id == link)
|
||||
);
|
||||
assert!(
|
||||
matches!(click(mini.center()), Some(Message::TopologyCanvas(CanvasEvent::SelectQualified(_, endpoint))) if endpoint == target)
|
||||
);
|
||||
}
|
||||
assert_eq!(q.elements, p.elements);
|
||||
let (parallel_project, second) = add(&q, owner, source.clone(), target.clone());
|
||||
let parallel = Graph::new(¶llel_project, owner, second);
|
||||
let first_edge = parallel.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let second_edge = parallel
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == second)
|
||||
.unwrap();
|
||||
assert_ne!(first_edge.points, second_edge.points);
|
||||
assert_ne!(first_edge.label, second_edge.label);
|
||||
assert_clear(¶llel, first_edge, &source, &target);
|
||||
assert_clear(¶llel, second_edge, &source, &target);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_shared_anchor_preserves_selected_direct_reconnect_at_fit_and_full_scale() {
|
||||
for prefix in ["oa", "sa"] {
|
||||
let p = baseline();
|
||||
let owner = id(&p, &format!("{prefix}_caller"));
|
||||
let x = id(&p, &format!("{prefix}_x"));
|
||||
let r1 = id(&p, &format!("{prefix}_r1"));
|
||||
let r2 = id(&p, &format!("{prefix}_r2"));
|
||||
let (p, contextual) = add(
|
||||
&p,
|
||||
owner,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: x,
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![r1],
|
||||
occurrence: id(&p, &format!("{prefix}_b")),
|
||||
},
|
||||
);
|
||||
let command = link(&p, x, r1, "Direct caller order");
|
||||
let Command::CreateRelation(relation) = &command else {
|
||||
unreachable!()
|
||||
};
|
||||
let direct = relation.id;
|
||||
let p = execute_batch(&p, &[command]).unwrap();
|
||||
let context = Context {
|
||||
document: p.id,
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
for scale in [0.45, 1.] {
|
||||
let mut graph = Graph::new(&p, owner, direct);
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
graph.scale = scale;
|
||||
let source = graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == direct)
|
||||
.unwrap()
|
||||
.points[0];
|
||||
assert_eq!(
|
||||
source,
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == contextual)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
);
|
||||
let destination = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == r2)
|
||||
.unwrap()
|
||||
.rect
|
||||
.center();
|
||||
let screen = |point: Point| Point::new(point.x * scale, point.y * scale);
|
||||
let bounds = Rectangle::new(
|
||||
Point::ORIGIN,
|
||||
Size::new(graph.size.width * scale, graph.size.height * scale),
|
||||
);
|
||||
let mut input = Input::default();
|
||||
let press = graph
|
||||
.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(source)),
|
||||
)
|
||||
.expect("The selected endpoint captures its press")
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(
|
||||
press.is_none(),
|
||||
"A shared contextual anchor must not steal the selected direct handle"
|
||||
);
|
||||
let _ = graph.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: screen(destination),
|
||||
}),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(destination)),
|
||||
);
|
||||
let release = graph
|
||||
.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(destination)),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(
|
||||
matches!(release, Some(Message::TopologyCanvas(CanvasEvent::Reconnect {
|
||||
context: actual_context, link, end: direct::End::Source, target,
|
||||
})) if actual_context == context && link == direct && target == r2)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_nested_visible_hidden_and_parallel_paths_keep_exact_addresses_and_legacy_routes()
|
||||
{
|
||||
let p = baseline();
|
||||
let owner = id(&p, "oa_caller");
|
||||
let r3 = id(&p, "oa_r3");
|
||||
let inner = id(&p, "oa_inner");
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id(&p, "oa_x"),
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![r3, inner],
|
||||
occurrence: id(&p, "oa_b"),
|
||||
};
|
||||
let (q, link) = add(&p, owner, source.clone(), target.clone());
|
||||
let expanded = BTreeSet::from([r3]);
|
||||
let paths = BTreeSet::from([OccurrenceEndpoint {
|
||||
references: vec![r3],
|
||||
occurrence: inner,
|
||||
}]);
|
||||
let graph = Graph::expanded_qualified(&q, owner, link, &expanded, &paths);
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
assert_clear(&graph, edge, &source, &target);
|
||||
let host = graph.nodes.iter().find(|node| node.id == r3).unwrap();
|
||||
let mini = host.endpoint_rect(&target);
|
||||
assert_ne!(mini, host.rect);
|
||||
assert_eq!(
|
||||
edge.points.last(),
|
||||
Some(&Point::new(mini.x, mini.center_y()))
|
||||
);
|
||||
let folded = Graph::new(&q, owner, link);
|
||||
let folded_edge = folded.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let folded_host = folded.nodes.iter().find(|node| node.id == r3).unwrap();
|
||||
assert_eq!(
|
||||
folded_edge.points.last(),
|
||||
Some(&Point::new(folded_host.rect.x, folded_host.rect.center_y()))
|
||||
);
|
||||
assert_clear(&folded, folded_edge, &source, &target);
|
||||
let (q2, other) = add(&q, owner, source.clone(), target.clone());
|
||||
let parallel = Graph::expanded_qualified(&q2, owner, link, &expanded, &paths);
|
||||
let a = parallel.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let b = parallel.edges.iter().find(|edge| edge.id == other).unwrap();
|
||||
assert_ne!(a.points, b.points);
|
||||
assert_ne!(a.label, b.label);
|
||||
assert_clear(¶llel, a, &source, &target);
|
||||
assert_clear(¶llel, b, &source, &target);
|
||||
let procedure = id(&p, "oa_procedure");
|
||||
let old = Graph::new(&p, procedure, procedure);
|
||||
let new = Graph::new(&q2, procedure, procedure);
|
||||
assert_eq!(old.size, new.size);
|
||||
assert_eq!(
|
||||
old.edges
|
||||
.iter()
|
||||
.map(|e| (&e.points, e.label, e.layer))
|
||||
.collect::<Vec<_>>(),
|
||||
new.edges
|
||||
.iter()
|
||||
.map(|e| (&e.points, e.label, e.layer))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(new.edges.iter().all(|edge| edge.layer == EdgeLayer::Direct));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
SelectQualified(Context, OccurrenceEndpoint),
|
||||
ExpandQualified(Context, OccurrenceEndpoint),
|
||||
OpenQualified(Context, OccurrenceEndpoint),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
@@ -57,6 +60,7 @@ pub enum CanvasEvent {
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Focus(Context),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
@@ -65,11 +69,15 @@ impl CanvasEvent {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::SelectQualified(c, ..)
|
||||
| Self::ExpandQualified(c, ..)
|
||||
| Self::OpenQualified(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
@@ -79,6 +87,53 @@ impl CanvasEvent {
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
|
||||
self.selected_context = Some(address);
|
||||
}
|
||||
pub(crate) fn clear_context(&mut self) {
|
||||
self.selected_context = None;
|
||||
}
|
||||
pub(super) fn qualified_selection(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
) -> Option<&oa_core::topology_context::ContextAddress> {
|
||||
self.selected_context.as_ref().filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
})
|
||||
}
|
||||
pub(crate) fn expand_context(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
endpoint: OccurrenceEndpoint,
|
||||
) -> bool {
|
||||
if oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &endpoint)
|
||||
.is_err()
|
||||
|| !matches!(
|
||||
p.elements
|
||||
.get(&endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = endpoint.references.first() {
|
||||
self.expanded.insert(*root);
|
||||
}
|
||||
if !self.contextual_expanded.remove(&endpoint) {
|
||||
self.contextual_expanded.insert(endpoint);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
@@ -90,6 +145,7 @@ impl State {
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
@@ -133,11 +189,18 @@ impl State {
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
while let Some((owner, occurrence, address)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
self.selected_context = address.filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
});
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
@@ -145,6 +208,11 @@ impl State {
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
return Some(r.id);
|
||||
}
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
@@ -355,6 +423,20 @@ impl Graph<'_> {
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn qualified_at(&self, point: Point) -> Option<OccurrenceEndpoint> {
|
||||
self.nodes.iter().find_map(|node| {
|
||||
node.preview.iter().enumerate().find_map(|(i, (id, _))| {
|
||||
let rect = Rectangle::new(
|
||||
Point::new(
|
||||
node.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
node.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
);
|
||||
rect.contains(point).then(|| id.clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
@@ -366,6 +448,19 @@ impl Graph<'_> {
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn contextual_edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|edge| edge.layer == EdgeLayer::Contextual)
|
||||
.find(|edge| {
|
||||
edge.label.contains(point)
|
||||
|| edge.points.windows(2).any(|segment| {
|
||||
crate::diagram::distance_to_segment(point, segment[0], segment[1])
|
||||
< 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
@@ -374,6 +469,9 @@ impl Graph<'_> {
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
if self.project.relations.get(&self.selected)?.kind() != RelationKind::SequenceLink {
|
||||
return None;
|
||||
}
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
@@ -497,13 +595,17 @@ impl Graph<'_> {
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
if let Some(endpoint) = self.qualified_at(point) {
|
||||
return publish(CanvasEvent::SelectQualified(context, endpoint));
|
||||
} else if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if let Some(link) = self.contextual_edge_at(point) {
|
||||
return publish(CanvasEvent::Select(context, link));
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
@@ -728,13 +830,17 @@ impl State {
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.map(|n| {
|
||||
self.qualified_selection(p, owner)
|
||||
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
|
||||
})
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
|
||||
@@ -896,6 +896,7 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
|
||||
f.owner,
|
||||
"Ordered contribution",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -136,3 +136,246 @@ fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible
|
||||
assert_eq!(closed.scrolls[0].1.height, size.height);
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_workspace(
|
||||
pending: bool,
|
||||
details: bool,
|
||||
header_height: f32,
|
||||
) -> iced::Element<'static, Message, iced::Theme, ()> {
|
||||
let canvas = container(
|
||||
scrollable(iced::widget::Space::new().width(1800.).height(2400.))
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let graph = explicit_pane(
|
||||
iced::widget::Space::new().height(header_height).into(),
|
||||
iced::widget::Space::new().height(1800.).into(),
|
||||
canvas.into(),
|
||||
details,
|
||||
pending,
|
||||
);
|
||||
let form = if pending {
|
||||
editor_panel(
|
||||
container(iced::widget::Space::new().height(32))
|
||||
.id("test-process-actions")
|
||||
.into(),
|
||||
iced::widget::Space::new().height(2000).into(),
|
||||
)
|
||||
} else {
|
||||
iced::widget::Space::new().height(0).into()
|
||||
};
|
||||
panes(graph, form, pending)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_layout_measures_remaining_canvas_with_details_forms_and_window_resize() {
|
||||
// Minimum/default panes both with side panels and focused; tall content
|
||||
// deliberately cannot influence the enclosing scrollable allocations.
|
||||
for size in [
|
||||
Size::new(500., 570.),
|
||||
Size::new(1050., 570.),
|
||||
Size::new(840., 770.),
|
||||
Size::new(1394., 802.),
|
||||
] {
|
||||
for pending in [false, true] {
|
||||
for details in [false, true] {
|
||||
for header_height in [68., 96.] {
|
||||
let mut view = explicit_workspace(pending, details, header_height);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(node.size(), size);
|
||||
let canvas = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.unwrap()
|
||||
.1;
|
||||
let pane_bottom = if pending {
|
||||
size.height / 2.
|
||||
} else {
|
||||
size.height
|
||||
};
|
||||
assert!(canvas.x >= 0. && canvas.y >= 0.);
|
||||
assert!(
|
||||
canvas.x + canvas.width <= size.width
|
||||
&& canvas.y + canvas.height <= pane_bottom,
|
||||
"Canvas must be visible, not merely allocated outside a parent clip: {size:?} {canvas:?}"
|
||||
);
|
||||
assert!(canvas.height >= if pending { 75. } else { 300. });
|
||||
let mut state = State::default();
|
||||
state.viewport(canvas.size());
|
||||
state.zoom(None);
|
||||
let graph = Size::new(900., 1400.);
|
||||
let scale = state.scale(graph, 1.);
|
||||
assert!(
|
||||
graph.width * scale <= canvas.width
|
||||
&& graph.height * scale <= canvas.height
|
||||
);
|
||||
assert!(!state.fit_limited(graph, 1.));
|
||||
if pending {
|
||||
let form = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-form")))
|
||||
.unwrap()
|
||||
.1;
|
||||
let actions = actual.actions.unwrap();
|
||||
assert!(form.height >= 200. && form.y + form.height <= size.height);
|
||||
assert!(actions.y >= pane_bottom && actions.y + actions.height <= form.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_layout_disclosures_and_pending_editor_keep_real_manual_canvas_scroll() {
|
||||
let mut view = explicit_workspace(false, false, 68.);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-canvas"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: Some(90.),
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
let (_, before) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let initial: Vec<_> = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.filter(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.collect();
|
||||
assert_eq!(initial.len(), 1, "The exact canvas ID must be unique");
|
||||
assert_eq!(
|
||||
initial[0].2,
|
||||
Vector::new(90., 180.),
|
||||
"Establish the actual canvas scroll before any tree diff"
|
||||
);
|
||||
for (pending, details, height, size) in [
|
||||
(false, true, 96., Size::new(500., 570.)),
|
||||
(true, true, 68., Size::new(1050., 570.)),
|
||||
(true, false, 68., Size::new(1394., 802.)),
|
||||
(false, false, 96., Size::new(500., 570.)),
|
||||
] {
|
||||
view = explicit_workspace(pending, details, height);
|
||||
tree.diff(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
let canvas = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
canvas.2,
|
||||
Vector::new(90., 180.),
|
||||
"Shelf/header/form/focus resize must retain the same scroll state"
|
||||
);
|
||||
if pending {
|
||||
let actions = actual.actions;
|
||||
let mut form_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 form_scroll);
|
||||
let (_, scrolled) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(scrolled.actions, actions);
|
||||
assert!(
|
||||
scrolled
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.)
|
||||
);
|
||||
assert!(
|
||||
scrolled
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-canvas"))
|
||||
&& s.2 == Vector::new(90., 180.))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_whole_app_focus_and_disclosures_retain_canvas_tree_with_pending_raw_form() {
|
||||
use iced::advanced::widget::tree;
|
||||
use std::rc::Rc;
|
||||
fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||
if tree.tag == tag {
|
||||
return Some(vec![]);
|
||||
}
|
||||
tree.children.iter().enumerate().find_map(|(i, child)| {
|
||||
let mut path = path_to(child, tag)?;
|
||||
path.insert(0, i);
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree {
|
||||
for &i in path {
|
||||
tree = &mut tree.children[i]
|
||||
}
|
||||
tree
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.nodes[1]));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let graph: Element<'_, Message> =
|
||||
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
|
||||
let tag = widget::Tree::new(graph.as_widget()).tag;
|
||||
drop(graph);
|
||||
let mut tree = widget::Tree::new(crate::views::view(&app).as_widget());
|
||||
let mut path = path_to(&tree, tag).unwrap();
|
||||
path.pop();
|
||||
let marker = Rc::new(());
|
||||
at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker));
|
||||
let _ = app.update(event(Event::Start(f.owner, Tool::Contextual, None)));
|
||||
let _ = app.update(event(Event::Name(" preserved raw 雨 ".into())));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
for (shelf, expected) in [
|
||||
(Shelf::Tools, Some(Shelf::Tools)),
|
||||
(Shelf::Selection, Some(Shelf::Selection)),
|
||||
(Shelf::Selection, None),
|
||||
] {
|
||||
let focused = app.behavior.topology.focused;
|
||||
let _ = app.update(event(Event::Shelf(shelf)));
|
||||
assert_eq!(app.behavior.topology.shelf, expected);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert_eq!(app.behavior.topology.focused, !focused);
|
||||
tree.diff(crate::views::view(&app).as_widget());
|
||||
let mut current = path_to(&tree, tag).unwrap();
|
||||
current.pop();
|
||||
assert_eq!(
|
||||
current, path,
|
||||
"Main must not shift when explorer/inspector are hidden"
|
||||
);
|
||||
assert_eq!(Rc::strong_count(&marker), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
at(&mut tree, &path).state.downcast_ref::<Rc<()>>(),
|
||||
&marker
|
||||
));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,7 @@ pub(super) fn view<'a>(
|
||||
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);
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending && explicit {
|
||||
controls = controls
|
||||
.push(
|
||||
@@ -162,39 +160,179 @@ pub(super) fn view<'a>(
|
||||
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);
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
body:
|
||||
RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
},
|
||||
..
|
||||
}) = p.relations.get(&app.selected)
|
||||
&& *caller == owner
|
||||
{
|
||||
navigation=navigation.push(text(format!("From: {}",super::contextual::address_label(p,owner,source))).size(12))
|
||||
.push(text(format!("To: {}",super::contextual::address_label(p,owner,target))).size(12))
|
||||
.push(text(match purpose {ContextualLinkPurpose::Precedence {..}=>"Precedence · hidden contributions attach visually to their folded reference",_=>"Assembly incidence · no execution order is implied"}).size(11));
|
||||
}
|
||||
if let Some(address) = state.qualified_selection(p, owner) {
|
||||
navigation=navigation.push(text(super::contextual::address_label(p,owner,&address.endpoint)).size(12))
|
||||
.push(text("Qualified contribution · definition properties affect every use of the source.").size(11))
|
||||
.push(button("Open contribution definition").on_press(Message::TopologyCanvas(CanvasEvent::OpenQualified(context,address.endpoint.clone()))));
|
||||
if matches!(
|
||||
p.elements
|
||||
.get(&address.endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
navigation = navigation.push(
|
||||
button(if state.contextual_expanded.contains(&address.endpoint) {
|
||||
"Fold nested reference"
|
||||
} else {
|
||||
"Expand nested reference"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::ExpandQualified(
|
||||
context,
|
||||
address.endpoint.clone(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !pending {
|
||||
controls = controls.push(button("Qualified link / assembly…").on_press(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
)));
|
||||
}
|
||||
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))),
|
||||
);
|
||||
}
|
||||
}
|
||||
navigation = navigation.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
navigation = navigation.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let diagram = if explicit {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_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();
|
||||
// Explicit graphs no longer have a scrolling page ancestor. A retained
|
||||
// offset from an ordered view must not distort their pointer coordinates.
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
let mut header = column![
|
||||
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.)
|
||||
)))
|
||||
out.width(Fill),
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Tools))),
|
||||
button(text("Selection details").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Selection))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(6);
|
||||
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
|
||||
}
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
controls = controls.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
@@ -209,26 +347,44 @@ pub(super) fn view<'a>(
|
||||
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),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let canvas = container(
|
||||
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
};
|
||||
explicit_pane(
|
||||
header.into(),
|
||||
details,
|
||||
canvas.into(),
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
} else {
|
||||
out = out
|
||||
.push(
|
||||
button(if state.focused {
|
||||
"Restore explorer and properties"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))),
|
||||
)
|
||||
.push(controls)
|
||||
.push(navigation);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
@@ -264,204 +420,180 @@ pub(super) fn view<'a>(
|
||||
})
|
||||
.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))),
|
||||
);
|
||||
}
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
};
|
||||
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…"
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
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 {
|
||||
"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)))
|
||||
""
|
||||
};
|
||||
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),
|
||||
);
|
||||
}
|
||||
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) => {
|
||||
@@ -470,21 +602,49 @@ pub(super) fn view<'a>(
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"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,
|
||||
)
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
/// A permanent three-slot tree: variable controls never remount the canvas.
|
||||
/// The shelf scrolls independently; the canvas receives actual remaining height.
|
||||
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: iced::Element<'a, Message, iced::Theme, R>,
|
||||
details: iced::Element<'a, Message, iced::Theme, R>,
|
||||
canvas: iced::Element<'a, Message, iced::Theme, R>,
|
||||
show_details: bool,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
|
||||
// slot so its disclosure cannot move/remount the following canvas tree.
|
||||
Column::from_vec(vec![
|
||||
container(header).width(Fill).into(),
|
||||
scrollable(details)
|
||||
.id("process-details")
|
||||
.width(Fill)
|
||||
.height(if show_details {
|
||||
if pending { 64. } else { 112. }
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.into(),
|
||||
container(canvas).width(Fill).height(Fill).into(),
|
||||
])
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
|
||||
@@ -1,6 +1,109 @@
|
||||
//! One address for a carried value, shared by scenario creation and property editing.
|
||||
use oa_core::*;
|
||||
|
||||
fn resolve_operation(
|
||||
project: &Project,
|
||||
message: &Element,
|
||||
) -> Result<operations::OperationResolution, operations::OperationError> {
|
||||
if project.elements.get(&message.id) == Some(message) {
|
||||
return operations::resolve(project, message.id);
|
||||
}
|
||||
let active = matches!(
|
||||
message.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) || matches!(message.shared(),Some(SharedBody::Occurrence { body:OccurrenceBody::Exchange { message_kind:MessageKind::Reply,reply_to:Some(call),..},.. }) if matches!(project.elements.get(call).and_then(|e|e.shared()),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:Some(_),..},..})));
|
||||
if !active {
|
||||
return Ok(operations::OperationResolution::Ordinary);
|
||||
}
|
||||
let mut candidate = project.clone();
|
||||
candidate.elements.insert(message.id, message.clone());
|
||||
operations::resolve(&candidate, message.id)
|
||||
}
|
||||
|
||||
/// The same exact signature used by core admission, evaluated with the authored
|
||||
/// message in place. Unresolved operation intent never falls back to channel union.
|
||||
pub(crate) fn message_signature(
|
||||
project: &Project,
|
||||
message: &Element,
|
||||
) -> Result<Option<Relation>, String> {
|
||||
match resolve_operation(project, message).map_err(|e| e.to_string())? {
|
||||
operations::OperationResolution::Selected(operation) => {
|
||||
Ok(Some(operations::signature_relation(&operation)))
|
||||
}
|
||||
operations::OperationResolution::Unresolved { reason, .. } => Err(reason),
|
||||
operations::OperationResolution::Ordinary => Ok(match message.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) => exchange.and_then(|id| project.relations.get(&id)).cloned(),
|
||||
_ => None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn operation_choices(project: &Project, message: &Element) -> Vec<crate::app::Choice> {
|
||||
let mut candidate = project.clone();
|
||||
candidate.elements.insert(message.id, message.clone());
|
||||
let mut choices: Vec<_> = operations::eligible_allocations(&candidate, message.id)
|
||||
.into_iter()
|
||||
.map(|id| crate::app::Choice {
|
||||
id,
|
||||
label: operation_identity(project, id),
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
|
||||
choices
|
||||
}
|
||||
|
||||
pub(crate) fn operation_identity(project: &Project, allocation: Id) -> String {
|
||||
if let Some(r) = project.relations.get(&allocation) {
|
||||
format!(
|
||||
"{} / {} · {} (@{})",
|
||||
project.named(r.source()),
|
||||
project.named(r.target()),
|
||||
r.name,
|
||||
r.alias
|
||||
)
|
||||
} else {
|
||||
format!("Unavailable operation allocation · {allocation}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn operation_label(project: &Project, message: &Element) -> Option<String> {
|
||||
let reply = matches!(
|
||||
message.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
);
|
||||
let prefix = if reply {
|
||||
"Reply operation from Call"
|
||||
} else {
|
||||
"Operation"
|
||||
};
|
||||
match resolve_operation(project, message) {
|
||||
Ok(operations::OperationResolution::Ordinary) => None,
|
||||
Ok(operations::OperationResolution::Unresolved { reason, .. }) => {
|
||||
Some(format!("{prefix}: unresolved · {reason}"))
|
||||
}
|
||||
Ok(operations::OperationResolution::Selected(operation)) => Some(format!(
|
||||
"{prefix}: {}",
|
||||
operation_identity(project, operation.allocation)
|
||||
)),
|
||||
Err(error) => Some(format!("{prefix}: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FieldPathKey {
|
||||
pub path: Vec<Id>,
|
||||
@@ -38,6 +141,18 @@ fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &Messa
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// Selected asynchronous operations carry request parameters. Ordinary channel
|
||||
/// messages retain their existing phase rules; unresolved intent has no signature.
|
||||
pub(crate) fn operation_value_phase(
|
||||
kind: &MessageKind,
|
||||
operation: Option<&OperationUse>,
|
||||
) -> MessageKind {
|
||||
if *kind == MessageKind::Asynchronous && operation.is_some() {
|
||||
MessageKind::Call
|
||||
} else {
|
||||
kind.clone()
|
||||
}
|
||||
}
|
||||
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
|
||||
let qualified = entry
|
||||
.path
|
||||
|
||||
@@ -46,6 +46,7 @@ pub struct DiagramCanvas<'a> {
|
||||
pub context: Context,
|
||||
pub selected_bend: Option<usize>,
|
||||
pub editable: bool,
|
||||
pub placing: bool,
|
||||
pub connection: Option<&'a crate::connection_edit::Session>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -227,6 +228,9 @@ impl DiagramCanvas<'_> {
|
||||
let scene = self.scene(state);
|
||||
let mut request = crate::model_menu::Request::object(self.diagram, bounds.center());
|
||||
request.canvas = Some(self.context);
|
||||
request.creation_point = Some(position(
|
||||
self.world(Point::new(bounds.width / 2., bounds.height / 2.)),
|
||||
));
|
||||
let anchor =
|
||||
if let Some(port) = scene.view.ports.iter().find(|p| p.element == self.selected) {
|
||||
request.object = port.element;
|
||||
@@ -258,6 +262,7 @@ impl DiagramCanvas<'_> {
|
||||
None
|
||||
};
|
||||
if let Some(anchor) = anchor {
|
||||
request.creation_point = Some(anchor);
|
||||
request.anchor = Point::new(
|
||||
bounds.x + anchor.x * self.zoom + self.pan.x,
|
||||
bounds.y + anchor.y * self.zoom + self.pan.y,
|
||||
@@ -278,6 +283,7 @@ impl DiagramCanvas<'_> {
|
||||
Point::new(bounds.x + local.x, bounds.y + local.y),
|
||||
);
|
||||
request.canvas = Some(self.context);
|
||||
request.creation_point = Some(position(world));
|
||||
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
&& let Some(index) = edge
|
||||
@@ -612,6 +618,28 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}) if state.keyboard_focus => {
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
match key.as_ref() {
|
||||
Key::Named(Named::F2)
|
||||
if self.editable && self.connection.is_none() && modifiers.is_empty() =>
|
||||
{
|
||||
let view = self.scene(state).view;
|
||||
let node = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == self.selected || n.id == self.selected)?;
|
||||
Some(
|
||||
Action::publish(Message::Architecture(
|
||||
crate::app::architecture_authoring::Event::Rename(
|
||||
self.context,
|
||||
node.element,
|
||||
Position {
|
||||
x: node.position.x + 14.,
|
||||
y: node.position.y + 35.,
|
||||
},
|
||||
),
|
||||
))
|
||||
.and_capture(),
|
||||
)
|
||||
}
|
||||
Key::Named(Named::ContextMenu) | Key::Named(Named::F10)
|
||||
if *key == Key::Named(Named::ContextMenu) || modifiers.shift() =>
|
||||
{
|
||||
@@ -685,6 +713,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let scene = self.scene(state);
|
||||
let view = &scene.view;
|
||||
let world = self.world(p);
|
||||
if self.placing && self.editable {
|
||||
return Some(
|
||||
Action::publish(Message::Architecture(
|
||||
crate::app::architecture_authoring::Event::Click(
|
||||
self.context,
|
||||
position(world),
|
||||
),
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if self.connection.is_some() && self.editable {
|
||||
let event = if let Some(id) = self.endpoint_at(view, p) {
|
||||
state.connection_press = Some((id, p));
|
||||
@@ -1301,7 +1340,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if self.connection.is_some() && cursor.is_over(bounds) {
|
||||
if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) {
|
||||
mouse::Interaction::Crosshair
|
||||
} else if state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
@@ -1858,6 +1897,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
@@ -1931,6 +1971,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
@@ -2054,6 +2095,7 @@ mod tests {
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: Some(&session),
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.));
|
||||
@@ -2226,6 +2268,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
@@ -2300,6 +2343,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
@@ -2395,6 +2439,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
@@ -2521,6 +2566,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
for end in ends {
|
||||
@@ -2573,6 +2619,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
@@ -2645,6 +2692,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
assert!(
|
||||
@@ -2676,6 +2724,7 @@ mod tests {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let span = &routed.edges[&edge].points;
|
||||
|
||||
@@ -62,6 +62,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ mod tests {
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use iced::{
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod context_fields;
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
mod view_fields;
|
||||
@@ -44,6 +45,23 @@ pub struct ReferenceBrowser {
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn set_shape_value(record: &mut Value, path: &str, value: Value) -> Result<(), String> {
|
||||
if let Some(target) = record.pointer_mut(path) {
|
||||
*target = value;
|
||||
return Ok(());
|
||||
}
|
||||
// Ordinary messages omit operation=None in source/JSON. Authoring that one
|
||||
// optional field must not require changing the historic record encoding.
|
||||
if let Some(parent) = path.strip_suffix("/operation")
|
||||
&& parent.ends_with("/Occurrence/body/Exchange")
|
||||
&& let Some(fields) = record.pointer_mut(parent).and_then(Value::as_object_mut)
|
||||
{
|
||||
fields.insert("operation".into(), value);
|
||||
return Ok(());
|
||||
}
|
||||
Err("Property no longer exists.".into())
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
pub original: Id,
|
||||
@@ -323,9 +341,7 @@ impl Draft {
|
||||
} else {
|
||||
value
|
||||
};
|
||||
*candidate
|
||||
.pointer_mut(path)
|
||||
.ok_or("Property no longer exists.")? = value;
|
||||
set_shape_value(&mut candidate, path, value)?;
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
@@ -333,10 +349,7 @@ impl Draft {
|
||||
/// 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;
|
||||
set_shape_value(&mut self.value, path, value)?;
|
||||
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -355,7 +368,8 @@ impl Draft {
|
||||
|| path.ends_with("/Port/contract")
|
||||
|| path.ends_with("/Occurrence/body")
|
||||
|| path.ends_with("/Event/body")
|
||||
|| path.ends_with("/message_kind");
|
||||
|| path.ends_with("/message_kind")
|
||||
|| path.ends_with("/ContextualLink/purpose");
|
||||
if !constructor {
|
||||
return self.shape_scoped(path, proposed);
|
||||
}
|
||||
@@ -1697,6 +1711,9 @@ pub fn scoped<'a>(
|
||||
) -> View<'a, Message> {
|
||||
match draft.value.pointer(path) {
|
||||
Some(value) => property(draft, project, value, path, label, false),
|
||||
None if path.ends_with("/Occurrence/body/Exchange/operation") => {
|
||||
property(draft, project, &Value::Null, path, label, false)
|
||||
}
|
||||
None => text("This property is not applicable to the current target.")
|
||||
.size(12)
|
||||
.into(),
|
||||
@@ -1711,6 +1728,9 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = context_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
if let Some(view) = view_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
@@ -2419,6 +2439,7 @@ mod tests {
|
||||
receiver: Some(receiver),
|
||||
message_kind,
|
||||
reply_to,
|
||||
operation: None,
|
||||
values: vec![],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Qualified editors share core's resolver, including incomplete leaf selection.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use oa_core::topology_context::TopologyIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Purpose {
|
||||
Precedence,
|
||||
Exchange,
|
||||
CommonWork,
|
||||
}
|
||||
impl std::fmt::Display for Purpose {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Precedence => "Precedence",
|
||||
Self::Exchange => "Exchange assembly",
|
||||
Self::CommonWork => "Common work incidence",
|
||||
})
|
||||
}
|
||||
}
|
||||
fn choice(p: &Project, id: Id) -> crate::app::Choice {
|
||||
crate::app::Choice {
|
||||
id,
|
||||
label: p
|
||||
.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.or_else(|| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.map(|r| format!("{} (@{})", r.name, r.alias))
|
||||
})
|
||||
.unwrap_or_else(|| format!("Unavailable {id}")),
|
||||
}
|
||||
}
|
||||
pub(super) fn property<'a>(
|
||||
draft: &'a Draft,
|
||||
p: &'a Project,
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
) -> Option<View<'a, Message>> {
|
||||
if !path.contains("/ContextualLink/") {
|
||||
return None;
|
||||
}
|
||||
let relation: Relation = serde_json::from_value(draft.context_value())
|
||||
.ok()
|
||||
.or_else(|| p.relations.get(&draft.original).cloned())?;
|
||||
let RelationBody::ContextualLink { owner, .. } = relation.body else {
|
||||
return None;
|
||||
};
|
||||
if path.ends_with("/source/occurrence") || path.ends_with("/target/occurrence") {
|
||||
let parent = path.strip_suffix("/occurrence")?;
|
||||
let endpoint: OccurrenceEndpoint =
|
||||
serde_json::from_value(draft.context_value().pointer(parent)?.clone()).ok()?;
|
||||
let index = TopologyIndex::new(p);
|
||||
let scope = match index.resolve_scope(owner, &endpoint.references) {
|
||||
Ok(scope) => scope,
|
||||
Err(error) => return Some(text(error.to_string()).size(12).into()),
|
||||
};
|
||||
let choices = index
|
||||
.contributions(scope)
|
||||
.into_iter()
|
||||
.map(|id| choice(p, id))
|
||||
.collect::<Vec<_>>();
|
||||
let selected = (!endpoint.occurrence.is_nil()).then(|| choice(p, endpoint.occurrence));
|
||||
return Some(
|
||||
column![
|
||||
text("Only the leaf changes; the caller and complete reference path remain fixed.")
|
||||
.size(11),
|
||||
pick_list(choices, selected, {
|
||||
let path = path.to_owned();
|
||||
move |choice| Message::FieldShape(path.clone(), json!(choice.id))
|
||||
})
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if path.ends_with("/source") || path.ends_with("/target") {
|
||||
let endpoint: OccurrenceEndpoint = serde_json::from_value(value.clone()).ok()?;
|
||||
let index = TopologyIndex::new(p);
|
||||
let mut out = column![
|
||||
text(crate::behavior::topology::contextual::address_label(
|
||||
p, owner, &endpoint
|
||||
))
|
||||
.size(12)
|
||||
]
|
||||
.spacing(6);
|
||||
match index.resolve_scope(owner, &endpoint.references) {
|
||||
Ok(scope) => {
|
||||
let choices = index
|
||||
.contributions(scope)
|
||||
.into_iter()
|
||||
.map(|id| choice(p, id))
|
||||
.collect::<Vec<_>>();
|
||||
let selected =
|
||||
(!endpoint.occurrence.is_nil()).then(|| choice(p, endpoint.occurrence));
|
||||
out = out.push(
|
||||
pick_list(choices, selected, {
|
||||
let path = path.to_owned();
|
||||
let references = endpoint.references.clone();
|
||||
move |choice| {
|
||||
Message::FieldShape(
|
||||
path.clone(),
|
||||
json!(OccurrenceEndpoint {
|
||||
references: references.clone(),
|
||||
occurrence: choice.id
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
.placeholder("Choose a contribution in this context…")
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if index.resolve(owner, &endpoint).is_ok()
|
||||
&& matches!(
|
||||
p.elements
|
||||
.get(&endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
let mut references = endpoint.references.clone();
|
||||
references.push(endpoint.occurrence);
|
||||
out = out.push(button("Enter selected reference").on_press(
|
||||
Message::FieldShape(
|
||||
path.to_owned(),
|
||||
json!(OccurrenceEndpoint {
|
||||
references,
|
||||
occurrence: Id::nil()
|
||||
}),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => out = out.push(text(error.to_string()).size(12)),
|
||||
}
|
||||
if let Some(reference) = endpoint.references.last() {
|
||||
let mut references = endpoint.references.clone();
|
||||
references.pop();
|
||||
out = out.push(button("Up to caller context").on_press(Message::FieldShape(
|
||||
path.to_owned(),
|
||||
json!(OccurrenceEndpoint {
|
||||
references,
|
||||
occurrence: *reference
|
||||
}),
|
||||
)));
|
||||
}
|
||||
return Some(out.push(text("Entering a reference retains an unfinished endpoint until you choose its contribution. Apply checks the complete caller path.").size(11)).into());
|
||||
}
|
||||
if path.ends_with("/owner") {
|
||||
let mut choices = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect::<Vec<_>>();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
|
||||
return Some(
|
||||
pick_list(choices, Some(choice(p, owner)), {
|
||||
let path = path.to_owned();
|
||||
move |choice| Message::FieldShape(path.clone(), json!(choice.id))
|
||||
})
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if path.ends_with("/purpose") {
|
||||
let purpose: ContextualLinkPurpose = serde_json::from_value(value.clone()).ok()?;
|
||||
let selected = match purpose {
|
||||
ContextualLinkPurpose::Precedence { .. } => Purpose::Precedence,
|
||||
ContextualLinkPurpose::Exchange { .. } => Purpose::Exchange,
|
||||
ContextualLinkPurpose::CommonWork { .. } => Purpose::CommonWork,
|
||||
};
|
||||
let mut out = column![
|
||||
pick_list(
|
||||
vec![Purpose::Precedence, Purpose::Exchange, Purpose::CommonWork],
|
||||
Some(selected),
|
||||
{
|
||||
let path = path.to_owned();
|
||||
move |purpose| {
|
||||
Message::FieldVariant(
|
||||
path.clone(),
|
||||
match purpose {
|
||||
Purpose::Precedence => json!(ContextualLinkPurpose::Precedence {
|
||||
guard: String::new()
|
||||
}),
|
||||
Purpose::Exchange => json!(ContextualLinkPurpose::Exchange {
|
||||
exchange: Id::nil()
|
||||
}),
|
||||
Purpose::CommonWork => {
|
||||
json!(ContextualLinkPurpose::CommonWork { work: Id::nil() })
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(7);
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { .. } => {
|
||||
let path = format!("{path}/Precedence/guard");
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(&path)
|
||||
.map(|input| input.raw.as_str())
|
||||
.unwrap_or("");
|
||||
out = out.push(
|
||||
text_input("Precedence guard", raw)
|
||||
.on_input(move |raw| Message::Field(path.clone(), raw))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
purpose => {
|
||||
let (selected, choices, leaf) = match purpose {
|
||||
ContextualLinkPurpose::Exchange { exchange } => (
|
||||
exchange,
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange
|
||||
) && p.layer_of(r.id) == p.layer_of(owner)
|
||||
})
|
||||
.map(|r| choice(p, r.id))
|
||||
.collect::<Vec<_>>(),
|
||||
"Exchange/exchange",
|
||||
),
|
||||
ContextualLinkPurpose::CommonWork { work } => (
|
||||
work,
|
||||
p.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_work() && Some(e.layer()) == p.layer_of(owner))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect::<Vec<_>>(),
|
||||
"CommonWork/work",
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let path = format!("{path}/{leaf}");
|
||||
out=out.push(pick_list(choices,(!selected.is_nil()).then(||choice(p,selected)),move|choice|Message::FieldShape(path.clone(),json!(choice.id))).placeholder("Choose exact engineering identity…").padding(8).text_size(12).width(Fill)).push(text("Assembly incidence does not imply execution order or merge executions.").size(11));
|
||||
}
|
||||
}
|
||||
return Some(out.into());
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -51,6 +51,146 @@ pub(super) fn property<'a>(
|
||||
return Some(message_value(draft, project, value, path));
|
||||
}
|
||||
let leaf = path.rsplit('/').next()?;
|
||||
if leaf == "operation" && path.ends_with("/Occurrence/body/Exchange/operation") {
|
||||
let message = element(draft, project)?;
|
||||
let operation = match message.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
} => operation.as_ref(),
|
||||
_ => return None,
|
||||
};
|
||||
let choices = crate::data_values::operation_choices(project, &message);
|
||||
let selected = operation
|
||||
.and_then(|o| o.allocation)
|
||||
.map(|id| crate::app::Choice {
|
||||
id,
|
||||
label: crate::data_values::operation_identity(project, id),
|
||||
});
|
||||
let path = path.to_owned();
|
||||
let mut out = column![
|
||||
text("Exact interface operation allocation").size(12),
|
||||
pick_list(choices, selected, {
|
||||
let path = path.clone();
|
||||
move |choice| Message::FieldShape(path.clone(), json!({"allocation":choice.id}))
|
||||
})
|
||||
.placeholder("Choose an eligible allocation…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
button("Keep operation unresolved").on_press(Message::FieldShape(
|
||||
path.clone(),
|
||||
json!({"allocation":null})
|
||||
)),
|
||||
button("Use ordinary channel semantics")
|
||||
.on_press(Message::FieldShape(path, Value::Null))
|
||||
]
|
||||
.spacing(7);
|
||||
if let Some(label) = crate::data_values::operation_label(project, &message) {
|
||||
out = out.push(text(label).size(11));
|
||||
}
|
||||
return Some(out.into());
|
||||
}
|
||||
if leaf == "Exchange" && path.contains("/Occurrence/") && value.is_object() {
|
||||
let mut out = column![].spacing(7);
|
||||
if let Some(message) = element(draft, project) {
|
||||
let operation_path = format!("{path}/operation");
|
||||
let body = message.shared();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind,
|
||||
operation,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = body
|
||||
{
|
||||
let scenario = validation::ancestor_of_kind(project, message.id, Kind::Scenario);
|
||||
let boundary = scenario
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
})
|
||||
)
|
||||
});
|
||||
if message.layer() == Layer::Sa
|
||||
&& boundary
|
||||
&& matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|
||||
{
|
||||
let enabled = operation.is_some();
|
||||
out = out.push(
|
||||
checkbox(enabled)
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle({
|
||||
let path = operation_path.clone();
|
||||
move |enabled| {
|
||||
Message::FieldShape(
|
||||
path.clone(),
|
||||
if enabled {
|
||||
json!({"allocation":null})
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
)
|
||||
}
|
||||
}),
|
||||
);
|
||||
if let Some(operation) = operation {
|
||||
let mut choices = crate::data_values::operation_choices(project, &message);
|
||||
let selected = operation.allocation.map(|id| crate::app::Choice {
|
||||
id,
|
||||
label: crate::data_values::operation_identity(project, id),
|
||||
});
|
||||
if let Some(selected) = &selected
|
||||
&& !choices.iter().any(|c| c.id == selected.id)
|
||||
{
|
||||
choices.push(selected.clone());
|
||||
}
|
||||
out = out
|
||||
.push(
|
||||
pick_list(choices, selected, {
|
||||
let path = operation_path.clone();
|
||||
move |choice| {
|
||||
Message::FieldShape(
|
||||
path.clone(),
|
||||
json!({"allocation":choice.id}),
|
||||
)
|
||||
}
|
||||
})
|
||||
.placeholder("Choose exact interface operation allocation…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
)
|
||||
.push(button("Keep operation unresolved").on_press(
|
||||
Message::FieldShape(operation_path, json!({"allocation":null})),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(description) = crate::data_values::operation_label(project, &message) {
|
||||
out = out.push(text(description).size(11));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, field) in value.as_object()? {
|
||||
if key != "operation" {
|
||||
out = out.push(super::property(
|
||||
draft,
|
||||
project,
|
||||
field,
|
||||
&format!("{path}/{key}"),
|
||||
&friendly(key),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
return Some(out.into());
|
||||
}
|
||||
if leaf == "direction" && path.contains("/DataField/") {
|
||||
let operation = element(draft, project)
|
||||
.and_then(|e| e.parent)
|
||||
@@ -315,21 +455,30 @@ fn message_value<'a>(
|
||||
let row = edited.pointer(path).unwrap_or(value).clone();
|
||||
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
|
||||
let owner = element(draft, project);
|
||||
let context = owner
|
||||
let signature = owner.as_ref().and_then(|message| {
|
||||
crate::data_values::message_signature(project, message)
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
let phase = owner
|
||||
.as_ref()
|
||||
.and_then(Element::shared)
|
||||
.and_then(|body| match body {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id),
|
||||
message_kind,
|
||||
operation,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => project.relations.get(id).map(|r| (r, message_kind)),
|
||||
} => Some(crate::data_values::operation_value_phase(
|
||||
message_kind,
|
||||
operation.as_ref(),
|
||||
)),
|
||||
_ => None,
|
||||
});
|
||||
let context = signature.as_ref().zip(phase.as_ref());
|
||||
let current_key = current
|
||||
.as_ref()
|
||||
.map(|value| crate::data_values::FieldPathKey {
|
||||
@@ -788,6 +937,7 @@ mod tests {
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -187,15 +187,16 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
|
||||
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
|
||||
"Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.",
|
||||
"Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.",
|
||||
"Choose Entity in the diagram's Create toolbar. Enter Rescue coordinator, choose Choose position, then click the diagram. Use Actor for an external participant such as a Vessel in distress. You can also right-click a blank area, choose Create Entity here, enter its name and choose Create here.",
|
||||
"Choose Activity in the same toolbar, name it Assess incident and place it. Select Rescue coordinator and use + Relationship with Allocation to assign Assess incident to it. The allocated-work view then shows the activity inside its performer.",
|
||||
"Use Add existing to place an element that is already in the model. The initial choices match this view; no search is required. Include already shown offers Locate for elements already placed here.",
|
||||
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
|
||||
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Continue into system design",
|
||||
"Use Create / update counterpart on an operational element to review an OA → SA mapping. In System Analysis, identify the System under study, its external Actors and the Functions for which each is responsible. The transition tutorial explains scope, reuse and review decisions.",
|
||||
"Use Create System counterpart on an operational element to create a linked SA element. Choose System for the system under study or System actor for an external participant, then choose the result diagram. The original operational element stays in OA. The transition tutorial explains scope and reuse.",
|
||||
),
|
||||
walkthrough(
|
||||
"Build a reviewable OA → SA baseline",
|
||||
@@ -239,7 +240,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Switching perspective does not transition the model",
|
||||
"The 01 Operational and 02 System controls change the visible perspective and available creation kinds. They do not move or delete records. Create / update counterpart makes a reviewed, traceable correspondence while preserving both perspectives.",
|
||||
"The 01 Operational and 02 System controls change the visible perspective and creation choices. To represent an element in the other layer, use Create System counterpart or Create Operational counterpart. Both elements remain linked by a trace.",
|
||||
),
|
||||
explain(
|
||||
"Keep the boundary explicit",
|
||||
@@ -261,8 +262,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Create and allocate work",
|
||||
&[
|
||||
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
|
||||
"Create Activity in OA or Function in SA. Select existing work and use + In selected to decompose it into smaller work units.",
|
||||
"To create work for a participant, select the participant, choose Activity or Function, then + Allocated. To allocate existing work, use + Relationship with Allocation from the participant to the leaf work.",
|
||||
"In the diagram's Create toolbar, choose Activity in OA or Function in SA, enter a name and click to place it. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
|
||||
"To assign work to a participant, use + Relationship with Allocation from the participant to the leaf work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
|
||||
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
|
||||
],
|
||||
),
|
||||
@@ -438,17 +439,17 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Chains => {
|
||||
const {
|
||||
Article {
|
||||
summary: "An operational Process or system Functional chain selects an ordered path through existing exchanges. It explains how work contributes to an outcome.",
|
||||
keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps",
|
||||
summary: "An operational Process or system Functional chain connects contributions of existing work and exchanges, including alternatives and reusable procedures. It explains how work contributes to an outcome.",
|
||||
keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps caller contextual qualified assembly common work focus reuse",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
|
||||
"Select that behavior in the page's selector. Choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"Choose Sequence link and click or drag from the preceding contribution to the following contribution. In Select / reorder, drag a selected path endpoint to reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.",
|
||||
"Select that behavior in the page's selector. Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
],
|
||||
),
|
||||
@@ -458,7 +459,20 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Review the path as an engineer",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Tools → Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
),
|
||||
walkthrough(
|
||||
"Connect a particular use of a procedure",
|
||||
&[
|
||||
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Selection details to read its complete caller and reference identity.",
|
||||
"In Selection details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
|
||||
"Open Tools → Qualified link / assembly… and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
|
||||
"Choose Precedence to describe order and its guard, Exchange assembly to identify a work exchange between the contributions, or Common work incidence to state that both use the same work. Select the engineering identity when required, then Apply link.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Keep caller intent separate from the definition",
|
||||
"A link into the first assessment's second contribution belongs to that caller. It does not change the shared assessment or its second use, and it does not delay the first contribution by targeting the whole procedure. Common work records shared identity without merging executions. Model checks identify the particular caller path and report incomplete analysis when expansion cannot be completed.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Exchanges, Topic::Capabilities, Topic::Scenarios],
|
||||
@@ -470,7 +484,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use a scenario to tell a concrete behavioral story, including repeated messages, actions, state observations and guarded alternatives.",
|
||||
keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state",
|
||||
keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Create a scenario",
|
||||
@@ -492,6 +506,15 @@ fn article(topic: Topic) -> Article {
|
||||
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Invoke an interface operation",
|
||||
&[
|
||||
"In an SA BoundaryInterface scenario, create or edit a Call or Asynchronous message. Choose its boundary exchange and actual sending and receiving lifelines first. The receiving Standard component port must provide the interface; the sending port must require a compatible interface.",
|
||||
"Enable Invoke a specific interface operation and choose the exact eligible allocation. Two parameterless operations on one channel remain distinct. Choose Keep operation unresolved when the operation decision is unfinished; existing value expressions remain available.",
|
||||
"For a Reply, inspect Reply operation from Call. It derives the selection from that particular Call and cannot be selected independently. Enter values for the selected operation's call or reply parameters; incompatible values must be corrected explicitly.",
|
||||
"When transitioning that invocation to OA, explicitly choose to omit the technical operation and enter the engineering rationale in the preview or semantic review row. The SA source keeps its operation identity.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Conditions are engineering statements",
|
||||
"Guards, actions and effects document behavior; a drawn scenario does not execute code or prove timing. Add evidence references when a simulation, test or analysis establishes a stronger claim.",
|
||||
@@ -623,20 +646,20 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Review a counterpart transition",
|
||||
&[
|
||||
"Select an operational element and choose Create / update counterpart. Selecting a system element starts the reverse System → Operational workflow.",
|
||||
"Select an operational element and choose Create System counterpart. For a system element, Create Operational counterpart starts the reverse direction. The source stays in its original layer.",
|
||||
"Choose Include: Selected only, Required references or Whole subtree. Required dependencies must have compatible counterparts before the plan can apply.",
|
||||
"For each source, choose Create new counterpart or reuse an existing target. Review the target kind and any parent, performer or System-boundary decisions offered by the preview.",
|
||||
"For each source, choose Create new counterpart or reuse an existing target. For an operational participant, explicitly choose System for the system under study or System actor for an external participant. Use Show result in to choose a compatible diagram; Automatic selects an architecture view for ordinary elements.",
|
||||
"Read the proposed changes and conflicts. For a descriptive conflict, choose the source value, keep the target value, or enter a merged value. Changing the target/kind requires reviewing that target's decisions again.",
|
||||
"Apply only after every blocking decision is resolved. The accepted plan is one undoable model change; Cancel preserves the current model.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Review drift deliberately",
|
||||
"On an existing trace, choose Review semantics… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. A manual trace records correspondence without propagating properties.",
|
||||
"On an existing trace, choose Review counterpart changes… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. A manual trace records correspondence without propagating properties.",
|
||||
),
|
||||
explain(
|
||||
"Know what reconciliation changes",
|
||||
"Semantic review supports structural and scalar changes, typed constructors, individual collection rows and explicit ordered collections. Several trace rows can contribute to one combined destination proposal. Candidate validation checks the complete proposed model before one undoable transaction. The Review semantic changes topic explains included rows, mapped references and order decisions. Attached-record and artifact reconciliation remain outside this dialog's authoring controls; use their supported editing workflows.",
|
||||
"Semantic review supports structural and scalar changes, typed constructors, individual collection rows, explicit ordered collections, existing attachments and artifact links. Several trace rows can contribute to one combined destination proposal. Candidate validation checks the complete proposed model before one undoable transaction. The Review semantic changes topic explains included rows, mapped references and each explicit attachment decision.",
|
||||
),
|
||||
explain(
|
||||
"Avoid duplicate counterparts",
|
||||
@@ -663,20 +686,20 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Compare the right records and direction",
|
||||
&[
|
||||
"From an existing counterpart trace, choose Review semantics…. Confirm the OA → SA or SA → OA direction and select the intended trace / review history. Each correspondence and direction has its own retained row draft; opening another row does not discard the first row's choices.",
|
||||
"From an existing counterpart trace, choose Review counterpart changes…. Confirm the OA → SA or SA → OA direction and select the intended trace / review history. Each correspondence and direction has its own retained row draft; opening another row does not discard the first row's choices.",
|
||||
"In Properties, select a fact. Compare Previously reviewed with Current on both sides. Unreviewed means there is no prior observation; Both changed means both sides changed since that observation, not that one side is automatically wrong.",
|
||||
"Choose Keep for the destination, Take for the source, or Set final value…. For a kind change, Set final kind and payload… reviews the complete replacement and its affected references. Leave unselected makes no decision for that fact.",
|
||||
"Read Reference pairs and Additional changes before Apply reviewed changes. A missing or ambiguous correspondence must be resolved explicitly. Apply commits the validated model and the reviewed history together; Undo restores both.",
|
||||
"Read Reference mapping and Additional changes before Apply reviewed changes. A missing or ambiguous correspondence must be resolved explicitly. Apply commits the validated model and the reviewed history together; Undo restores both.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Map references by meaning",
|
||||
"Reference pairs connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
|
||||
"Reference mapping connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
|
||||
),
|
||||
walkthrough(
|
||||
"Review one message value or lifeline binding",
|
||||
&[
|
||||
"Open Members / rows and choose the exact identities on both sides. For a message value, browse the enclosing record fields as well as the final field: origin.latitude and destination.latitude are distinct addresses.",
|
||||
"Open Contents and order and choose the exact identities on both sides. For a message value, browse the enclosing record fields as well as the final field: origin.latitude and destination.latitude are distinct addresses.",
|
||||
"For a binding, choose the referenced lifeline key and its compatible local lifeline. Check which scenario owns each side. Keep or Take concerns that row, not all bindings in the scenario.",
|
||||
"Use Set final value… to enter the destination expression or local lifeline. Include in destination collection controls that row's presence. Unchecking removes the entry without deleting its referenced engineering object.",
|
||||
"If a row key changed, review the old entry's removal and the new entry's addition separately. A missing source identity is not the same decision as an identified source entry that has been deleted. Read the proposed value before applying.",
|
||||
@@ -685,12 +708,16 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Review membership and sequence order separately",
|
||||
&[
|
||||
"Review which occurrences belong to Steps, or which operands belong to Fragment branches, using Members / rows. This changes membership; it does not silently reparent or create the referenced records.",
|
||||
"Review which occurrences belong to Steps, or which operands belong to Fragment branches, using Contents and order. This changes membership; it does not silently reparent or create the referenced records.",
|
||||
"Choose Review final order… or the corresponding order fact in Properties. Keep retains destination positions. Take uses mapped source order when it can preserve destination-only members without an ambiguous placement.",
|
||||
"For an explicit result, choose Set final value…, then use Up and Down. If you change membership afterward, choose Reconcile with selected membership: surviving positions are retained, removed members leave the list, and new members append for you to position.",
|
||||
"Every surviving destination member must appear exactly once. Resolve an ambiguous insertion with an explicit final order; do not remove an engineering contribution merely to make the preview pass.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Review attached records and external evidence",
|
||||
"In Owned elements and relationships, choose exact OA and SA child or relationship identities, then Use exact attachment pair. Review a child's parent or a relationship endpoint independently; creating a new child or relationship requires its explicit transition mapping. In Evidence links, pair the exact links and choose Review this exact artifact pair. Presence concerns attachment to the reviewed owner. Taking an identified source absence can delete the destination link; a destination-only link is retained. Reserve a new destination counterpart only when one is needed, then explicitly review Presence and all seven metadata facts before the combined Apply. The reservation creates nothing by itself.",
|
||||
),
|
||||
explain(
|
||||
"Preserve intentional differences and useful history",
|
||||
"Keep can acknowledge a deliberate difference without overwriting the destination. Reset this exact previous observation clears only the chosen review history; it does not restore an old model value. Overlapping row addresses may require an explicit history reset. Review those messages rather than clearing unrelated observations. Pending review choices and raw values can be recovered after an unexpected exit, but the plan is recomputed against the recovered accepted model.",
|
||||
@@ -698,8 +725,8 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Review several sources together",
|
||||
&[
|
||||
"Use Choose another trace to add or reopen, then Open row. Include this row determines whether its decisions contribute to the shared preview. The trace / review history selector switches the row you are editing; excluded rows retain their drafts.",
|
||||
"Review each included source's contribution to the destination. Compatible decisions combine; contradictory Keep, Take or final-value decisions must be resolved explicitly. Read the combined conflicts, Reference pairs and Additional changes before Apply reviewed changes. One Apply commits the combined model and review histories; one Undo restores them.",
|
||||
"Use Add linked object, then Review object. Include this object determines whether its decisions contribute to the shared preview. The object selector switches the object you are editing; excluded rows retain their drafts.",
|
||||
"Review each included source's contribution to the destination. Compatible decisions combine; contradictory Keep, Take or final-value decisions must be resolved explicitly. Read the combined conflicts, Reference mapping and Additional changes before Apply reviewed changes. One Apply commits the combined model and review histories; one Undo restores them.",
|
||||
"Only one trace history per source can be included. Some reverse correspondences cannot share the core reference mapping; the preview explains the conflict. Exclude a row or choose a distinct correspondence without discarding its draft. Switching direction retains the other direction's choices for later review.",
|
||||
"Recovery retains included and excluded row drafts and raw final values, then recomputes the plan against the recovered accepted model. Refresh preview is not automatic synchronization and does not authorize changes to attached records or artifacts.",
|
||||
],
|
||||
@@ -730,6 +757,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose the relation to the artifact: References, Satisfies, Verifies or Implements. Add a description explaining the evidence and its limits. Apply changes, then inspect the owner and reference in Traceability.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Find existing evidence",
|
||||
"In Traceability, choose Development artifacts to show artifact links directly. Search by the link's name or alias, its owner, URI, revision or fragment. Layer traces shows OA/SA correspondences; All shows both lists. Filtering changes only what is shown and preserves the selected record and unfinished property input.",
|
||||
),
|
||||
explain(
|
||||
"A reference is not an attachment",
|
||||
"The model preserves the author-provided URI, revision and fragment. Loading or saving a model does not fetch, execute or verify an external artifact. A path on one workstation may not exist on another; choose team-resolvable locations and identifiers.",
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Request {
|
||||
pub placement: Option<Id>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
@@ -25,6 +26,7 @@ impl Request {
|
||||
placement: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
}
|
||||
@@ -205,13 +207,24 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
),
|
||||
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),
|
||||
]);
|
||||
for kind in crate::app::architecture_authoring::kinds(app) {
|
||||
entries.push(item(
|
||||
&format!("Create {} here…", kind.label()),
|
||||
Message::Architecture(crate::app::architecture_authoring::Event::Start(
|
||||
kind,
|
||||
request.creation_point,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Add existing here…",
|
||||
Message::Architecture(crate::app::architecture_authoring::Event::Existing(
|
||||
request.creation_point,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
if request.route_point.is_some()
|
||||
|| request.bend.is_some()
|
||||
@@ -343,7 +356,23 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
if e.kind().is_performer() || e.kind().is_work() {
|
||||
if e.kind().is_performer() && request.canvas.is_some() {
|
||||
if crate::app::architecture_authoring::kinds(app).contains(&work) {
|
||||
entries.push(item(
|
||||
if e.layer() == Layer::Oa {
|
||||
"Add activity…"
|
||||
} else {
|
||||
"Add function…"
|
||||
},
|
||||
Message::Architecture(crate::app::architecture_authoring::Event::Allocate(
|
||||
work,
|
||||
id,
|
||||
request.creation_point,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
} else if e.kind().is_performer() || e.kind().is_work() {
|
||||
entries.push(item(
|
||||
if e.kind().is_performer() {
|
||||
"Create allocated work"
|
||||
@@ -416,12 +445,16 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
if p.layer_of(id).is_some() {
|
||||
entries.push(item("Create relationship…", Message::AddRelation, true));
|
||||
entries.push(item(
|
||||
"Create counterpart / review prose…",
|
||||
if p.layer_of(id) == Some(Layer::Oa) {
|
||||
"Create System counterpart…"
|
||||
} else {
|
||||
"Create Operational counterpart…"
|
||||
},
|
||||
Message::StartTransition,
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Review semantics…",
|
||||
"Review counterpart changes…",
|
||||
Message::StartSemanticReview(id),
|
||||
p.traces.values().any(|t| t.oa == id || t.sa == id),
|
||||
));
|
||||
|
||||
@@ -17,6 +17,8 @@ const MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
mod source5;
|
||||
#[cfg(test)]
|
||||
mod source5_tests;
|
||||
#[cfg(test)]
|
||||
mod source7_tests;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Snapshot {
|
||||
@@ -31,9 +33,16 @@ pub struct Snapshot {
|
||||
behavior: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
workflow: Option<serde_json::Value>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "architecture_draft"
|
||||
)]
|
||||
architecture: Option<crate::app::architecture_authoring::Saved>,
|
||||
selected: Id,
|
||||
}
|
||||
pub struct Restored {
|
||||
pub architecture: Option<crate::app::architecture_authoring::Saved>,
|
||||
pub workflow: Option<crate::workflow::Workflow>,
|
||||
pub document: Document,
|
||||
pub workspace: Workspace,
|
||||
@@ -41,6 +50,12 @@ pub struct Restored {
|
||||
pub behavior: crate::behavior::State,
|
||||
pub selected: Id,
|
||||
}
|
||||
fn architecture_draft<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<crate::app::architecture_authoring::Saved>, D::Error> {
|
||||
// Omission is the legacy shape. An explicit new null field is not erased.
|
||||
crate::app::architecture_authoring::Saved::deserialize(deserializer).map(Some)
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Entry {
|
||||
pub id: Id,
|
||||
@@ -111,6 +126,7 @@ impl Snapshot {
|
||||
property: property.filter(|d| d.dirty()).map(Draft::recovery_snapshot),
|
||||
behavior: behavior.recovery_snapshot()?,
|
||||
workflow: None,
|
||||
architecture: None,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
@@ -123,6 +139,13 @@ impl Snapshot {
|
||||
.transpose()?;
|
||||
Ok(self)
|
||||
}
|
||||
pub fn with_architecture(
|
||||
mut self,
|
||||
state: Option<&crate::app::architecture_authoring::State>,
|
||||
) -> Self {
|
||||
self.architecture = state.map(|s| s.saved.clone());
|
||||
self
|
||||
}
|
||||
pub fn restore(self) -> Result<Restored, String> {
|
||||
if self.version != 1 {
|
||||
return Err("This recovery checkpoint uses an unsupported version.".into());
|
||||
@@ -130,6 +153,23 @@ impl Snapshot {
|
||||
let mut document =
|
||||
Document::from_bundle(self.accepted.clone(), LIMITS).map_err(|e| e.to_string())?;
|
||||
let legacy_base = source5::upgrade_binding(&self.accepted, &document)?;
|
||||
if legacy_base.is_some() && self.architecture.is_some() {
|
||||
return Err(
|
||||
"Architecture authoring recovery requires its original current-source baseline."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let Some(saved) = &self.architecture {
|
||||
crate::app::architecture_authoring::validate_saved(document.project(), saved)?;
|
||||
}
|
||||
if legacy_base.is_some() {
|
||||
if let Some(property) = &self.property {
|
||||
validate_pre7_intent(&serde_json::to_value(property).map_err(|e| e.to_string())?)?;
|
||||
}
|
||||
for value in [&self.behavior, &self.workflow].into_iter().flatten() {
|
||||
validate_pre7_intent(value)?;
|
||||
}
|
||||
}
|
||||
let mut saved_workspace = self.workspace;
|
||||
if legacy_base.is_some() {
|
||||
saved_workspace.upgrade_clean_buffers(&self.accepted, document.bundle());
|
||||
@@ -156,6 +196,7 @@ impl Snapshot {
|
||||
}
|
||||
document.mark_recovered();
|
||||
Ok(Restored {
|
||||
architecture: self.architecture,
|
||||
workflow,
|
||||
document,
|
||||
workspace,
|
||||
@@ -165,6 +206,82 @@ impl Snapshot {
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Private draft payloads use closed typed records, not arbitrary JSON documents.
|
||||
/// Reject new structural intent before serde defaults can erase its presence.
|
||||
/// Only known structural tag/address positions are checked. Authored source,
|
||||
/// names, expressions and raw input strings are never scanned.
|
||||
pub(crate) fn validate_pre7_intent(value: &serde_json::Value) -> Result<(), String> {
|
||||
use serde_json::Value;
|
||||
const CONTEXT: [&str; 4] = [
|
||||
"ContextOwner",
|
||||
"ContextSource",
|
||||
"ContextTarget",
|
||||
"ContextPurpose",
|
||||
];
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
let new_field = fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"operation"
|
||||
| "disabled_operation"
|
||||
| "contextual"
|
||||
| "operation_omissions"
|
||||
| "operation_omission"
|
||||
| "ContextualLink"
|
||||
)
|
||||
});
|
||||
let new_tag = fields.get("kind").and_then(Value::as_str) == Some("ContextualLink")
|
||||
|| fields.get("tool").and_then(Value::as_str) == Some("Contextual");
|
||||
// Constructor caches retain inactive raw branches. Their addresses,
|
||||
// and input-map keys, are structural paths rather than user prose.
|
||||
let new_path = fields
|
||||
.get("inputs")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|inputs| inputs.keys().any(|key| source7_draft_path(key)))
|
||||
|| fields
|
||||
.get("constructors")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|rows| {
|
||||
rows.iter().any(|row| {
|
||||
row.get(0)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(source7_draft_path)
|
||||
|| row.get(3).and_then(Value::as_object).is_some_and(|inputs| {
|
||||
inputs.keys().any(|key| source7_draft_path(key))
|
||||
})
|
||||
})
|
||||
});
|
||||
let new_facet = fields
|
||||
.get("Occurrence")
|
||||
.is_some_and(|v| v.as_str() == Some("Operation") || v.get("Operation").is_some())
|
||||
|| fields.get("Relation").is_some_and(|v| {
|
||||
v.as_str()
|
||||
.is_some_and(|s| s == "ContextualLink" || CONTEXT.contains(&s))
|
||||
|| CONTEXT.iter().any(|key| v.get(*key).is_some())
|
||||
});
|
||||
if new_field || new_tag || new_path || new_facet {
|
||||
return Err("Legacy checkpoint contains source-7 draft fields; it has been retained without interpreting new intent.".into());
|
||||
}
|
||||
for child in fields.values() {
|
||||
validate_pre7_intent(child)?;
|
||||
}
|
||||
}
|
||||
Value::Array(children) => {
|
||||
for child in children {
|
||||
validate_pre7_intent(child)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn source7_draft_path(path: &str) -> bool {
|
||||
path.ends_with("/ContextualLink")
|
||||
|| path.contains("/ContextualLink/")
|
||||
|| path.ends_with("/Exchange/operation")
|
||||
|| path.contains("/Exchange/operation/")
|
||||
}
|
||||
fn path(directory: &Path, id: Id) -> PathBuf {
|
||||
directory.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
These are frozen historical source-5/source-6 recovery profiles. Do not update them to the current schema or regenerate them with a newer serializer.
|
||||
|
||||
`harness/recovery-profile-fixtures.rs` produced them using the actual schema-6 core library on 2026-09-06. The generator explicitly refuses any other current schema. The library SHA256 was `E53A68018AB470F3D1E3C024A06FC0B5F1618D1495E6095716E9C4D9105680E0`; generation output is retained in `.local/source7-recovery-frozen-profiles.log`. Model JSON uses exact historical struct field order, not alphabetically reordered JSON. `profiles.json` records SHA256 and independent Python FNV-1a checks of the frozen bytes; tests compare the literal FNV values against both the historical bytes and the new recovery adapter.
|
||||
|
||||
Both profiles retain 30 authored modules, Unicode comments, mixed CRLF/LF, Call/Reply with values, SequenceLinks, and more than 60 semantic traces. Source 6 retains communication means, exchange categories and nondefault view selectors. Source 5 removes those unavailable records, uses activity-viewpoint operational messages and the historical diagram shape without view options; SA typed ports/interface messages remain. Its model was independently admitted and compared with its compiled source using the historical decoder.
|
||||
|
||||
Recovery must alter only the entry version token, preserve all other source bytes, reproduce the model admitted by the current historical decoder, bind authored drafts using the corresponding exact old fingerprint, and replan without restoring executable commands or prior impact approvals.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"5": {
|
||||
"fingerprint": "31b56f1d17c2d9cf",
|
||||
"model_sha256": "5535a706df9ec21715e8b6a5aa6dbd79636ae461450aee61305559016167612b",
|
||||
"bundle_sha256": "12715995e6f7f35414aea53b3ad720b9b31873ffdeaf4444231db1a5ac3c463e"
|
||||
},
|
||||
"6": {
|
||||
"fingerprint": "88c1149e445e8676",
|
||||
"model_sha256": "5bb8db0b6394276666323483f977ac3586f88554c81a148c6d508fca0ef4576f",
|
||||
"bundle_sha256": "ea88890a33f47d67dfb06d6dbbcc179058cf28dad76ae7d615c00aaf30dbbbfe"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
//! Narrow compatibility for private checkpoints written by the source-5 desktop.
|
||||
//! Lossless compatibility for private checkpoints written by source-5/6 desktops.
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
Diagram, Project,
|
||||
Diagram, Project, SCHEMA_VERSION,
|
||||
language::{self, TokenClass},
|
||||
};
|
||||
use serde::ser::{SerializeStruct, Serializer};
|
||||
@@ -16,7 +16,9 @@ pub(super) fn upgrade_binding(
|
||||
return Ok(None);
|
||||
}
|
||||
let unsupported = || {
|
||||
"Checkpoint accepted source cannot be recovered exactly; only a lossless source 5 to 6 upgrade is supported. The checkpoint has been retained.".to_owned()
|
||||
format!(
|
||||
"Checkpoint accepted source cannot be recovered exactly; only a lossless source 5 or 6 to {SCHEMA_VERSION} upgrade is supported. The checkpoint has been retained."
|
||||
)
|
||||
};
|
||||
let report = document
|
||||
.compiled()
|
||||
@@ -24,7 +26,7 @@ pub(super) fn upgrade_binding(
|
||||
.migration
|
||||
.as_ref()
|
||||
.ok_or_else(unsupported)?;
|
||||
if (report.from_schema, report.to_schema) != (5, 6) {
|
||||
if !matches!(report.from_schema, 5 | 6) || report.to_schema != SCHEMA_VERSION {
|
||||
return Err(unsupported());
|
||||
}
|
||||
let source = accepted
|
||||
@@ -39,7 +41,7 @@ pub(super) fn upgrade_binding(
|
||||
if tokens.len() != 3
|
||||
|| &source[tokens[0].span.start..tokens[0].span.end] != "arcadia"
|
||||
|| tokens[1].class != TokenClass::Number
|
||||
|| &source[tokens[1].span.start..tokens[1].span.end] != "5"
|
||||
|| source[tokens[1].span.start..tokens[1].span.end] != report.from_schema.to_string()
|
||||
|| &source[tokens[2].span.start..tokens[2].span.end] != ";"
|
||||
{
|
||||
return Err(unsupported());
|
||||
@@ -49,24 +51,51 @@ pub(super) fn upgrade_binding(
|
||||
.files
|
||||
.get_mut(&accepted.entry)
|
||||
.ok_or_else(unsupported)?
|
||||
.replace_range(tokens[1].span.start..tokens[1].span.end, "6");
|
||||
.replace_range(
|
||||
tokens[1].span.start..tokens[1].span.end,
|
||||
&SCHEMA_VERSION.to_string(),
|
||||
);
|
||||
if document.bundle() != &expected {
|
||||
return Err(unsupported());
|
||||
}
|
||||
// The old source admission rejects all source-6 constructs. Exact token-only
|
||||
// migration establishes that borrowed records below retain their old shape.
|
||||
legacy_fingerprint(document.project()).map(Some)
|
||||
// Source admission proves authored-version eligibility; historical JSON
|
||||
// admission below independently proves that omitted fields lose no facts.
|
||||
legacy_fingerprint(document.project(), report.from_schema).map(Some)
|
||||
}
|
||||
|
||||
fn legacy_fingerprint(p: &Project) -> Result<String, String> {
|
||||
if p.schema_version != 6
|
||||
|| p.diagrams
|
||||
.values()
|
||||
.any(|d| d.view_options != Default::default())
|
||||
fn legacy_fingerprint(p: &Project, schema: u32) -> Result<String, String> {
|
||||
if p.schema_version != SCHEMA_VERSION
|
||||
|| !matches!(schema, 5 | 6)
|
||||
|| (schema == 5
|
||||
&& p.diagrams
|
||||
.values()
|
||||
.any(|d| d.view_options != Default::default()))
|
||||
{
|
||||
return Err("Checkpoint migration does not have a source-5 model shape.".into());
|
||||
return Err(format!(
|
||||
"Checkpoint migration does not have a source-{schema} model shape."
|
||||
));
|
||||
}
|
||||
let bytes =
|
||||
serde_json::to_vec(&HistoricalProject { project: p, schema }).map_err(|e| e.to_string())?;
|
||||
// This path runs only during explicit recovery, never a periodic write.
|
||||
// The model already passed source limits. Its internal JSON expansion may
|
||||
// be larger than authored text, so bound records/references as before while
|
||||
// admitting exactly the internally serialized byte length.
|
||||
let old = oa_core::persistence::decode_with_report_limited(
|
||||
&bytes,
|
||||
language::ParseLimits {
|
||||
max_source_bytes: bytes.len(),
|
||||
..LIMITS.model
|
||||
},
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!("Checkpoint source-{schema} fingerprint shape is not valid: {error}")
|
||||
})?;
|
||||
if &old.project != p {
|
||||
return Err(format!(
|
||||
"Checkpoint source-{schema} fingerprint would discard or insert model facts."
|
||||
));
|
||||
}
|
||||
let bytes = serde_json::to_vec(&Project5(p)).map_err(|e| e.to_string())?;
|
||||
let hash = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
@@ -75,21 +104,28 @@ fn legacy_fingerprint(p: &Project) -> Result<String, String> {
|
||||
|
||||
// Preserve the exact historical serde field order (2b2d2bc), including empty
|
||||
// maps and routes. Serializing through Value would reorder struct fields.
|
||||
struct Project5<'a>(&'a Project);
|
||||
impl Serialize for Project5<'_> {
|
||||
struct HistoricalProject<'a> {
|
||||
project: &'a Project,
|
||||
schema: u32,
|
||||
}
|
||||
impl Serialize for HistoricalProject<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let diagrams: std::collections::BTreeMap<_, _> =
|
||||
p.diagrams.iter().map(|(id, d)| (id, Diagram5(d))).collect();
|
||||
let p = self.project;
|
||||
let mut s = serializer.serialize_struct("Project", 10)?;
|
||||
s.serialize_field("schema_version", &5u32)?;
|
||||
s.serialize_field("schema_version", &self.schema)?;
|
||||
s.serialize_field("id", &p.id)?;
|
||||
s.serialize_field("alias", &p.alias)?;
|
||||
s.serialize_field("name", &p.name)?;
|
||||
s.serialize_field("description", &p.description)?;
|
||||
s.serialize_field("elements", &p.elements)?;
|
||||
s.serialize_field("relations", &p.relations)?;
|
||||
s.serialize_field("diagrams", &diagrams)?;
|
||||
if self.schema == 5 {
|
||||
let diagrams: std::collections::BTreeMap<_, _> =
|
||||
p.diagrams.iter().map(|(id, d)| (id, Diagram5(d))).collect();
|
||||
s.serialize_field("diagrams", &diagrams)?;
|
||||
} else {
|
||||
s.serialize_field("diagrams", &p.diagrams)?;
|
||||
}
|
||||
s.serialize_field("artifact_links", &p.artifact_links)?;
|
||||
s.serialize_field("traces", &p.traces)?;
|
||||
s.end()
|
||||
@@ -122,11 +158,18 @@ mod tests {
|
||||
// value was independently calculated from these UTF-8 bytes with Python.
|
||||
let bytes = r#"{"schema_version":5,"id":"00000000-0000-0000-0000-000000000001","alias":"p","name":"Legacy","description":"","elements":{},"relations":{},"diagrams":{"00000000-0000-0000-0000-000000000002":{"id":"00000000-0000-0000-0000-000000000002","alias":"v","name":"View","description":"","layer":"Oa","projection":"Overview","nodes":{},"routes":[]}},"artifact_links":{},"traces":{}}"#;
|
||||
let mut p: Project = serde_json::from_str(bytes).unwrap();
|
||||
p.schema_version = 6;
|
||||
assert_eq!(serde_json::to_string(&Project5(&p)).unwrap(), bytes);
|
||||
assert_eq!(legacy_fingerprint(&p).unwrap(), "4140e158d5091161");
|
||||
p.schema_version = SCHEMA_VERSION;
|
||||
assert_eq!(
|
||||
serde_json::to_string(&HistoricalProject {
|
||||
project: &p,
|
||||
schema: 5
|
||||
})
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
assert_eq!(legacy_fingerprint(&p, 5).unwrap(), "4140e158d5091161");
|
||||
assert_ne!(
|
||||
legacy_fingerprint(&p).unwrap(),
|
||||
legacy_fingerprint(&p, 5).unwrap(),
|
||||
oa_core::transitions::project_fingerprint(&p)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ fn source5_snapshot() -> Snapshot {
|
||||
property: None,
|
||||
behavior: None,
|
||||
workflow: None,
|
||||
architecture: None,
|
||||
selected: Id::from_u128(0x40008000000000000001),
|
||||
}
|
||||
}
|
||||
@@ -39,7 +40,7 @@ fn source5_checkpoint_upgrades_only_header_and_clean_editor_baseline() {
|
||||
expected.entry.clone(),
|
||||
saved.accepted.files[&saved.accepted.entry].replacen(
|
||||
"arcadia // header\r\n 5;",
|
||||
"arcadia // header\r\n 6;",
|
||||
"arcadia // header\r\n 7;",
|
||||
1,
|
||||
),
|
||||
);
|
||||
@@ -52,7 +53,10 @@ fn source5_checkpoint_upgrades_only_header_and_clean_editor_baseline() {
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (5, 6));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(5, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(restored.workspace.text(), expected.files[&expected.entry]);
|
||||
assert!(!restored.workspace.dirty());
|
||||
assert!(restored.document.dirty());
|
||||
@@ -76,7 +80,7 @@ fn source5_checkpoint_preserves_dirty_invalid_text_and_baseline_exactly() {
|
||||
);
|
||||
assert_eq!(restored.workspace.text(), text);
|
||||
assert!(restored.workspace.dirty());
|
||||
assert!(restored.document.bundle().files[&entry].contains("arcadia // header\r\n 6;"));
|
||||
assert!(restored.document.bundle().files[&entry].contains("arcadia // header\r\n 7;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Frozen, nonempty historical profiles: never synthesize their fingerprint
|
||||
//! from the serializer whose compatibility this test is meant to verify.
|
||||
use super::*;
|
||||
use oa_core::{Project, SCHEMA_VERSION};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn profile(version: u32) -> (Snapshot, &'static [u8], &'static str) {
|
||||
let (bundle, model, fingerprint): (&[u8], &[u8], &str) = match version {
|
||||
5 => (
|
||||
include_bytes!("fixtures/source5-bundle.json"),
|
||||
include_bytes!("fixtures/source5-model.json"),
|
||||
"31b56f1d17c2d9cf",
|
||||
),
|
||||
6 => (
|
||||
include_bytes!("fixtures/source6-bundle.json"),
|
||||
include_bytes!("fixtures/source6-model.json"),
|
||||
"88c1149e445e8676",
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let accepted: SourceBundle = serde_json::from_slice(bundle).unwrap();
|
||||
let saved = Snapshot {
|
||||
version: 1,
|
||||
id: Id::from_u128(700 + u128::from(version)),
|
||||
name: "Historical pressure restoration".into(),
|
||||
origin: None,
|
||||
modified: 1,
|
||||
selected: Id::nil(),
|
||||
workspace: Workspace::new(&accepted, true).recovery_snapshot(),
|
||||
accepted,
|
||||
property: None,
|
||||
behavior: None,
|
||||
workflow: None,
|
||||
architecture: None,
|
||||
};
|
||||
(saved, model, fingerprint)
|
||||
}
|
||||
fn alias(p: &Project, name: &str) -> Id {
|
||||
p.elements.values().find(|e| e.alias == name).unwrap().id
|
||||
}
|
||||
fn expected_bundle(saved: &Snapshot, version: u32) -> SourceBundle {
|
||||
let mut bundle = saved.accepted.clone();
|
||||
let text = bundle.files.get_mut(&bundle.entry).unwrap();
|
||||
let marker = format!("arcadia // profile\r\n {version};");
|
||||
assert_eq!(text.matches(&marker).count(), 1);
|
||||
*text = text.replacen(
|
||||
&marker,
|
||||
&format!("arcadia // profile\r\n {SCHEMA_VERSION};"),
|
||||
1,
|
||||
);
|
||||
bundle
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_recovers_nonempty_frozen_5_and_6_profiles_with_exact_historical_binding() {
|
||||
for version in [5, 6] {
|
||||
let (saved, bytes, fingerprint) = profile(version);
|
||||
let independently_hashed = bytes.iter().fold(0xcbf29ce484222325u64, |h, b| {
|
||||
(h ^ u64::from(*b)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
assert_eq!(format!("{independently_hashed:016x}"), fingerprint);
|
||||
let historical: Value = serde_json::from_slice(bytes).unwrap();
|
||||
let serialized = String::from_utf8_lossy(bytes);
|
||||
for required in ["\"Call\"", "\"Reply\"", "\"SequenceLink\"", "\"values\""] {
|
||||
assert!(serialized.contains(required), "missing {required}");
|
||||
}
|
||||
assert!(historical["traces"].as_object().unwrap().len() > 60);
|
||||
assert_eq!(saved.accepted.files.len(), 30);
|
||||
let expected = expected_bundle(&saved, version);
|
||||
let doc = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
assert_eq!(
|
||||
source5::upgrade_binding(&saved.accepted, &doc)
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some(fingerprint)
|
||||
);
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
restored.document.project(),
|
||||
&oa_core::persistence::decode(bytes).unwrap()
|
||||
);
|
||||
assert_eq!(restored.document.bundle(), &expected);
|
||||
let report = restored
|
||||
.document
|
||||
.compiled()
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(version, SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(restored.workspace.text(), expected.files[&expected.entry]);
|
||||
assert!(!restored.workspace.dirty());
|
||||
assert!(restored.document.dirty());
|
||||
assert!(!restored.document.can_undo());
|
||||
assert_eq!(
|
||||
restored
|
||||
.document
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.any(|d| d.view_options != Default::default()),
|
||||
version == 6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_legacy_profiles_preserve_dirty_source_property_and_behavior_raw_fields() {
|
||||
use crate::behavior::{Event, scenario};
|
||||
for version in [5, 6] {
|
||||
let (mut saved, _, _) = profile(version);
|
||||
let doc = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let mut workspace = Workspace::new(&saved.accepted, true);
|
||||
let cursor = workspace.files[&workspace.entry].cursor;
|
||||
workspace.edit(format!("{}\r\nunfinished {{ 雨", workspace.text()), cursor);
|
||||
workspace.new_path = "raw/not-created.arcadia".into();
|
||||
saved.workspace = workspace.recovery_snapshot();
|
||||
let workspace_before = serde_json::to_value(&saved.workspace).unwrap();
|
||||
let call = alias(doc.project(), "oa_call");
|
||||
let mut property = Draft::from_project(doc.project(), call).unwrap();
|
||||
property.input("/name", " Unfinished operation:null text 雨 ".into());
|
||||
saved.property = Some(property.recovery_snapshot());
|
||||
let property_before = serde_json::to_value(&saved.property).unwrap();
|
||||
let owner = alias(doc.project(), "oa_conversation");
|
||||
let mut behavior = crate::behavior::State::default();
|
||||
for event in [
|
||||
scenario::Event::Start(owner, scenario::Tool::Message),
|
||||
scenario::Event::Expression("- 雨".into()),
|
||||
] {
|
||||
behavior
|
||||
.update(doc.project(), Event::Scenario(event))
|
||||
.unwrap();
|
||||
}
|
||||
let mut raw = behavior.recovery_snapshot().unwrap().unwrap();
|
||||
// Genuine old payloads had no operation key, even when its value is None.
|
||||
raw["draft"].as_object_mut().unwrap().remove("operation");
|
||||
raw["draft"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("disabled_operation");
|
||||
saved.behavior = Some(raw.clone());
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
workspace_before
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.property.unwrap().recovery_snapshot()).unwrap(),
|
||||
property_before
|
||||
);
|
||||
let mut after = restored.behavior.recovery_snapshot().unwrap().unwrap();
|
||||
assert!(after["draft"]["operation"].is_null());
|
||||
after["draft"].as_object_mut().unwrap().remove("operation");
|
||||
assert!(after["draft"]["disabled_operation"].is_null());
|
||||
after["draft"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("disabled_operation");
|
||||
assert_eq!(after, raw);
|
||||
assert!(
|
||||
restored
|
||||
.behavior
|
||||
.prepare_pending(restored.document.project())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_legacy_profile_workflow_and_semantic_drafts_replan_with_exact_base() {
|
||||
use crate::{
|
||||
semantic_review::{RowEvent, State as Review},
|
||||
workflow::Workflow,
|
||||
};
|
||||
use oa_core::semantic::{FacetSelector, HeaderFacet, SemanticDecision};
|
||||
for version in [5, 6] {
|
||||
let (saved, _, fingerprint) = profile(version);
|
||||
let doc = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let work = alias(doc.project(), "oa_confirm");
|
||||
let mut transition = Workflow::transition(doc.project(), work)
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
.unwrap();
|
||||
transition["Transition"]["base"] = fingerprint.into();
|
||||
let facet = FacetSelector::Header(HeaderFacet::Name);
|
||||
let mut semantic = Review::new(doc.project(), work).unwrap();
|
||||
for event in [
|
||||
RowEvent::Select(facet.clone()),
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Input("/name".into(), " preserved 雨 ".into()),
|
||||
] {
|
||||
semantic.update(doc.project(), event);
|
||||
}
|
||||
let mut semantic = semantic.recovery_snapshot().unwrap();
|
||||
// The new source-7 raw omission control did not exist in old rows.
|
||||
for row in semantic["rows"].as_array_mut().unwrap() {
|
||||
row["draft"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("operation_omission");
|
||||
}
|
||||
if let Some(pending) = semantic.get_mut("pending").and_then(Value::as_object_mut) {
|
||||
pending.remove("operation_omission");
|
||||
}
|
||||
semantic["base_fingerprint"] = fingerprint.into();
|
||||
for workflow in [transition, json!({"Semantic": semantic})] {
|
||||
let mut snapshot = saved.clone();
|
||||
snapshot.workflow = Some(workflow.clone());
|
||||
let restored = snapshot.restore().unwrap();
|
||||
let current = restored.workflow.unwrap().recovery_snapshot().unwrap();
|
||||
let base = oa_core::transitions::project_fingerprint(restored.document.project());
|
||||
if current.get("Semantic").is_some() {
|
||||
assert_eq!(current["Semantic"]["base_fingerprint"], base);
|
||||
assert_eq!(current["Semantic"]["accepted_impacts"], json!([]));
|
||||
assert!(current.to_string().contains(" preserved 雨 "));
|
||||
} else {
|
||||
assert_eq!(current["Transition"]["base"], base);
|
||||
}
|
||||
let mut bad = saved.clone();
|
||||
let mut workflow = workflow;
|
||||
if workflow.get("Semantic").is_some() {
|
||||
workflow["Semantic"]["base_fingerprint"] = "wrong base".into();
|
||||
} else {
|
||||
workflow["Transition"]["base"] = "wrong base".into();
|
||||
}
|
||||
bad.workflow = Some(workflow);
|
||||
assert!(bad.restore().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_legacy_structural_intent_rejects_even_null_but_never_scans_raw_strings() {
|
||||
for value in [
|
||||
json!({"operation":null}),
|
||||
json!({"contextual":null}),
|
||||
json!({"operation_omissions":[]}),
|
||||
json!({"ContextualLink":{}}),
|
||||
json!({"Relation":"ContextualLink"}),
|
||||
json!({"kind":"ContextualLink"}),
|
||||
json!({"tool":"Contextual"}),
|
||||
json!({"Occurrence":"Operation"}),
|
||||
json!({"Relation":"ContextOwner"}),
|
||||
json!({"Relation":{"ContextPurpose":null}}),
|
||||
] {
|
||||
assert!(validate_pre7_intent(&value).is_err(), "{value}");
|
||||
}
|
||||
validate_pre7_intent(&json!({"expression":"{\"operation\":null}", "name":"ContextOwner", "inputs":{"/name":"ContextualLink"}})).unwrap();
|
||||
for version in [5, 6] {
|
||||
let (mut saved, _, _) = profile(version);
|
||||
saved.behavior = Some(json!({"version":1,"kind":"Scenario","draft":{"operation":null}}));
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
let error = read(directory.path(), saved.id)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("source-7 draft fields"));
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_legacy_inactive_constructor_path_is_structural_but_raw_prose_is_not() {
|
||||
let (mut saved, _, _) = profile(6);
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let call = alias(document.project(), "sa_call");
|
||||
let mut draft = Draft::from_project(document.project(), call).unwrap();
|
||||
let prose = " /body/ContextualLink/purpose and tool Contextual are literal prose 雨 ";
|
||||
draft.input("/name", prose.into());
|
||||
let mut raw = serde_json::to_value(draft.recovery_snapshot()).unwrap();
|
||||
validate_pre7_intent(&raw).unwrap();
|
||||
raw["constructors"] = json!([["/body/ContextualLink/purpose", "Precedence", {"Precedence":{"guard":"unfinished &&"}}, {}]]);
|
||||
let property: RecoveryDraft = serde_json::from_value(raw).unwrap();
|
||||
// Current recovery intentionally retains incomplete inactive branches;
|
||||
// compatibility must reject an impossible historical address beforehand.
|
||||
assert!(Draft::from_recovery(document.project(), property.clone()).is_ok());
|
||||
saved.property = Some(property);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
let error = read(directory.path(), saved.id)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("source-7 draft fields"), "{error}");
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
saved.property = Some(draft.recovery_snapshot());
|
||||
assert_eq!(
|
||||
saved.restore().unwrap().property.unwrap().inputs["/name"].raw,
|
||||
prose
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_explicit_new_fields_cannot_enter_frozen_old_model_profiles() {
|
||||
for version in [5, 6] {
|
||||
let (_, bytes, _) = profile(version);
|
||||
let mut model: Value = serde_json::from_slice(bytes).unwrap();
|
||||
let call = model["elements"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
.find(|e| e["alias"] == "oa_call")
|
||||
.unwrap();
|
||||
let body = &mut call["body"]["Oa"]["Shared"]["Occurrence"]["body"]["Exchange"];
|
||||
assert!(body.is_object());
|
||||
body["operation"] = Value::Null;
|
||||
assert!(oa_core::persistence::decode(&serde_json::to_vec(&model).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_legacy_view_selector_refuses_new_relation_kind_without_rewriting_checkpoint() {
|
||||
let (mut saved, _, _) = profile(6);
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let diagram = *document.project().diagrams.keys().next().unwrap();
|
||||
let mut draft = Draft::from_project(document.project(), diagram).unwrap();
|
||||
draft.input(
|
||||
"/name",
|
||||
" ContextualLink remains ordinary name text 雨 ".into(),
|
||||
);
|
||||
let ordinary = draft.recovery_snapshot();
|
||||
validate_pre7_intent(&serde_json::to_value(&ordinary).unwrap()).unwrap();
|
||||
draft
|
||||
.shape(
|
||||
"/view_options/relation_kinds",
|
||||
json!({"Only":[{"Relation":"ContextualLink"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = draft.recovery_snapshot();
|
||||
// This is a valid current draft: only its claimed historical origin makes
|
||||
// it impossible. Serde/defaults alone cannot enforce the old enum catalog.
|
||||
assert!(Draft::from_recovery(document.project(), raw.clone()).is_ok());
|
||||
saved.property = Some(raw);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
let error = read(directory.path(), saved.id)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("source-7 draft fields"), "{error}");
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
saved.property = Some(ordinary);
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
restored.property.unwrap().inputs["/name"].raw,
|
||||
" ContextualLink remains ordinary name text 雨 "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source7_current_combined_contextual_and_operation_drafts_survive_without_execution() {
|
||||
use crate::behavior::{
|
||||
Event,
|
||||
topology::{self, contextual},
|
||||
};
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
let (old, _, _) = profile(6);
|
||||
let document = Document::from_bundle(expected_bundle(&old, 6), LIMITS).unwrap();
|
||||
let project = document.project();
|
||||
let mut property = Draft::from_project(project, alias(project, "sa_call")).unwrap();
|
||||
property
|
||||
.shape(
|
||||
"/body/Sa/Shared/Occurrence/body/Exchange/operation",
|
||||
json!({"allocation":null}),
|
||||
)
|
||||
.unwrap();
|
||||
property.input("/name", " pending operation 雨 ".into());
|
||||
let mut behavior = crate::behavior::State::default();
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![alias(project, "oa_use_field")],
|
||||
occurrence: alias(project, "oa_repair_step"),
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: alias(project, "oa_confirm_step"),
|
||||
};
|
||||
for event in [
|
||||
topology::Event::Start(
|
||||
alias(project, "oa_response"),
|
||||
topology::Tool::Contextual,
|
||||
None,
|
||||
),
|
||||
topology::Event::Contextual(contextual::Event::Pick(source.clone())),
|
||||
topology::Event::Contextual(contextual::Event::Side(true)),
|
||||
topology::Event::Contextual(contextual::Event::Pick(target.clone())),
|
||||
topology::Event::Contextual(contextual::Event::Guard("unfinished && 雨".into())),
|
||||
] {
|
||||
behavior.update(project, Event::Topology(event)).unwrap();
|
||||
}
|
||||
let workspace = Workspace::new(document.bundle(), true);
|
||||
let snapshot = Snapshot::capture(
|
||||
Id::from_u128(707),
|
||||
&document,
|
||||
&workspace,
|
||||
Some(&property),
|
||||
&behavior,
|
||||
None,
|
||||
project.id,
|
||||
)
|
||||
.unwrap();
|
||||
let property_before = serde_json::to_value(&snapshot.property).unwrap();
|
||||
let behavior_before = snapshot.behavior.clone();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
write(directory.path(), &snapshot).unwrap();
|
||||
let before = fs::read(path(directory.path(), snapshot.id)).unwrap();
|
||||
let restored = read(directory.path(), snapshot.id)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.unwrap();
|
||||
assert_eq!(restored.document.project(), project);
|
||||
assert_eq!(restored.document.bundle(), document.bundle());
|
||||
assert!(restored.document.compiled().decoded.migration.is_none());
|
||||
assert!(!restored.document.can_undo());
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.property.unwrap().recovery_snapshot()).unwrap(),
|
||||
property_before
|
||||
);
|
||||
assert_eq!(
|
||||
restored.behavior.recovery_snapshot().unwrap(),
|
||||
behavior_before
|
||||
);
|
||||
assert_eq!(
|
||||
behavior_before.as_ref().unwrap()["draft"]["contextual"]["source"],
|
||||
json!(source)
|
||||
);
|
||||
assert_eq!(
|
||||
behavior_before.as_ref().unwrap()["draft"]["contextual"]["target"],
|
||||
json!(target)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(path(directory.path(), snapshot.id)).unwrap(),
|
||||
before
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,8 @@ mod ordered;
|
||||
mod recovery;
|
||||
mod row;
|
||||
mod scalar;
|
||||
#[cfg(test)]
|
||||
mod source7_tests;
|
||||
mod view;
|
||||
pub(crate) use view::summary;
|
||||
#[cfg(test)]
|
||||
@@ -54,6 +56,19 @@ pub enum Tab {
|
||||
History,
|
||||
Impacts,
|
||||
}
|
||||
impl std::fmt::Display for Tab {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Facts => "Properties",
|
||||
Self::References => "Reference mapping",
|
||||
Self::Members => "Contents and order",
|
||||
Self::Attachments => "Owned elements and relationships",
|
||||
Self::Artifacts => "Evidence links",
|
||||
Self::History => "Review history",
|
||||
Self::Impacts => "Additional changes",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RowEvent {
|
||||
@@ -72,6 +87,8 @@ pub enum RowEvent {
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
ValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
ReferenceBrowse(String, String, usize),
|
||||
OperationOmission(bool),
|
||||
OperationRationale(String),
|
||||
Tab(Tab),
|
||||
ShowUnchanged(bool),
|
||||
ReferenceOa(Choice),
|
||||
|
||||
@@ -104,6 +104,17 @@ pub(super) fn path(project: &Project, facet: &FacetSelector, layer: Layer) -> Op
|
||||
Family::Source | Family::Target if !project.relations.contains_key(&id) => return None,
|
||||
_ => {}
|
||||
}
|
||||
if project
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::ContextualLink)
|
||||
{
|
||||
return match family(facet)? {
|
||||
Family::Source => Some("/body/ContextualLink/source/occurrence".into()),
|
||||
Family::Target => Some("/body/ContextualLink/target/occurrence".into()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
scalar::binding(project, id, &slot)
|
||||
}
|
||||
pub(super) fn label(facet: &FacetSelector) -> &'static str {
|
||||
|
||||
@@ -562,9 +562,14 @@ fn absent_attachment_payloads_migrate_v1_and_v2_and_source5_upgrade_requires_exa
|
||||
] {
|
||||
if value["version"] == 1 {
|
||||
value.as_object_mut().unwrap().remove("attachments");
|
||||
value.as_object_mut().unwrap().remove("operation_omission");
|
||||
} else {
|
||||
for row in value["rows"].as_array_mut().unwrap() {
|
||||
row["draft"].as_object_mut().unwrap().remove("attachments");
|
||||
row["draft"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("operation_omission");
|
||||
}
|
||||
}
|
||||
let restored = State::from_recovery(&f.p, value.clone()).unwrap();
|
||||
|
||||
@@ -668,12 +668,82 @@ impl State {
|
||||
.cloned();
|
||||
let direction = self.direction;
|
||||
let base = &self.base;
|
||||
let (source_layer, target_layer) = if direction == TransitionDirection::OaToSa {
|
||||
("Operational", "System")
|
||||
} else {
|
||||
("System", "Operational")
|
||||
};
|
||||
let mut header = column![
|
||||
text(format!("Review semantics · {} included rows", self.included_addresses().len())).size(22),
|
||||
row![pick_list(available, self.add_trace.clone(), move |choice| message(Event::ChooseTrace(choice))).placeholder("Choose another trace to add or reopen").width(Fill).text_size(12).padding(7), button(text("Open row").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::OpenTrace)), button(text(if direction == TransitionDirection::OaToSa { "OA → SA" } else { "SA → OA" }).size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })))].spacing(7),
|
||||
row![pick_list(choices, selected, move |choice| message(Event::Focus(RowAddress::from_trace(&base.traces[&choice.id], direction)))).placeholder("Choose a trace / review history").width(Fill).text_size(12).padding(7), checkbox(self.active_address.is_some_and(|address| self.included.contains(&address))).label("Include this row").on_toggle_maybe(self.active_address.map(|address| move |included| message(Event::Include(address, included))))].spacing(10),
|
||||
text("Switching rows retains every draft. Only included rows contribute to the one combined preview.").size(10).color(crate::style::MUTED),
|
||||
text("Review counterpart changes").size(22),
|
||||
text("Compare existing linked OA and SA objects, then choose which values to copy. This is for updating counterparts after editing; creating a counterpart is a separate action.").size(12),
|
||||
row![
|
||||
text(format!("Copy selected changes: {source_layer} → {target_layer}")).size(13),
|
||||
button(text("Reverse direction").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa }))),
|
||||
].spacing(10),
|
||||
].spacing(6);
|
||||
if available.len() > 1 || self.active_address.is_none() {
|
||||
header = header.push(
|
||||
row![
|
||||
pick_list(available, self.add_trace.clone(), move |choice| message(
|
||||
Event::ChooseTrace(choice)
|
||||
))
|
||||
.placeholder("Choose a linked object to review…")
|
||||
.width(Fill)
|
||||
.text_size(12)
|
||||
.padding(7),
|
||||
button(text("Review object").size(12))
|
||||
.padding(7)
|
||||
.style(crate::style::nav)
|
||||
.on_press(message(Event::OpenTrace)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
let active: iced::Element<'a, Message> = if choices.len() > 1 {
|
||||
pick_list(choices, selected, move |choice| {
|
||||
message(Event::Focus(RowAddress::from_trace(
|
||||
&base.traces[&choice.id],
|
||||
direction,
|
||||
)))
|
||||
})
|
||||
.placeholder("Choose an object already in this review")
|
||||
.width(Fill)
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.into()
|
||||
} else if let Some(address) = self.active_address {
|
||||
text(format!(
|
||||
"{} → {}",
|
||||
super::view::choice(base, address.source).label,
|
||||
super::view::choice(base, address.target).label
|
||||
))
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.into()
|
||||
} else {
|
||||
text("Select a linked object above to compare its values.")
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.into()
|
||||
};
|
||||
header = header.push(
|
||||
row![
|
||||
active,
|
||||
checkbox(
|
||||
self.active_address
|
||||
.is_some_and(|address| self.included.contains(&address))
|
||||
)
|
||||
.label("Include this object")
|
||||
.on_toggle_maybe(
|
||||
self.active_address
|
||||
.map(|address| move |included| message(Event::Include(address, included)))
|
||||
),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
if self.included_addresses().len() > 1 {
|
||||
header = header.push(text(format!("{} objects included. Apply saves their selected decisions together; switching objects keeps your drafts.", self.included_addresses().len())).size(11).color(crate::style::MUTED));
|
||||
}
|
||||
if let Some(notice) = &self.notice {
|
||||
header = header.push(text(notice).size(11));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ use super::*;
|
||||
mod diagnostic_tests;
|
||||
|
||||
type Slot = (Id, FacetSelector);
|
||||
fn context_leaf_path(project: &Project, slot: &Slot) -> Option<&'static str> {
|
||||
if project.relations.get(&slot.0)?.kind() != RelationKind::ContextualLink {
|
||||
return None;
|
||||
}
|
||||
match slot.1 {
|
||||
FacetSelector::Relation(RelationFacet::Source) => {
|
||||
Some("/body/ContextualLink/source/occurrence")
|
||||
}
|
||||
FacetSelector::Relation(RelationFacet::Target) => {
|
||||
Some("/body/ContextualLink/target/occurrence")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
struct Intent {
|
||||
value: Value,
|
||||
row: RowAddress,
|
||||
@@ -47,6 +61,13 @@ fn canonical(facet: &FacetSelector, layer: Layer) -> Option<FacetSelector> {
|
||||
})
|
||||
}
|
||||
fn observe(project: &Project, slot: &Slot) -> Result<Value, String> {
|
||||
if let Some(path) = context_leaf_path(project, slot) {
|
||||
let value = record_value(project, slot.0)?
|
||||
.pointer(path)
|
||||
.cloned()
|
||||
.ok_or("Qualified endpoint leaf is absent.")?;
|
||||
return Ok(serde_json::json!({"Value":value}));
|
||||
}
|
||||
if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
return Ok(artifacts::observation(
|
||||
project.artifact_links.get(&slot.0),
|
||||
@@ -297,10 +318,9 @@ fn write_slot(project: &mut Project, slot: &Slot, observation: &Value) -> Result
|
||||
serde_json::to_value(order).expect("Order");
|
||||
} else {
|
||||
let path = scalar::binding(project, slot.0, &slot.1)
|
||||
.or_else(|| context_leaf_path(project, slot).map(str::to_owned))
|
||||
.ok_or("A selected property was retired by another row's constructor proposal.")?;
|
||||
*value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Selected property is no longer available.")? = supplied;
|
||||
crate::forms::set_shape_value(&mut value, &path, supplied)?;
|
||||
}
|
||||
install(project, slot.0, value)
|
||||
}
|
||||
@@ -325,6 +345,22 @@ pub(super) fn request(state: &State) -> Result<SemanticTransitionRequest, String
|
||||
})
|
||||
.collect();
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
let mut omissions = BTreeMap::new();
|
||||
for address in &addresses {
|
||||
if let Some(raw) = state
|
||||
.row(*address)
|
||||
.and_then(|row| row.operation_omission.as_ref())
|
||||
&& omissions
|
||||
.insert(address.source, raw.clone())
|
||||
.is_some_and(|prior| prior != *raw)
|
||||
{
|
||||
return Err("Rows reviewing the same operation disagree on the explicit omission rationale. Retain one consistent decision before Apply.".into());
|
||||
}
|
||||
}
|
||||
request.transition.operation_omissions = omissions
|
||||
.into_iter()
|
||||
.map(|(source, rationale)| OperationOmission { source, rationale })
|
||||
.collect();
|
||||
request.references = references.clone();
|
||||
request.accepted_impacts = state.accepted_impacts.clone();
|
||||
let mut slots = BTreeMap::new();
|
||||
@@ -435,6 +471,32 @@ pub(super) fn request(state: &State) -> Result<SemanticTransitionRequest, String
|
||||
observe(&proposed, &slot)?
|
||||
};
|
||||
agree(state, &mut slots, slot.clone(), observed, address, facet)?;
|
||||
if matches!(
|
||||
facet,
|
||||
FacetSelector::Relation(
|
||||
RelationFacet::ContextSource | RelationFacet::ContextTarget
|
||||
)
|
||||
) {
|
||||
let leaf = (
|
||||
address.target,
|
||||
FacetSelector::Relation(
|
||||
if matches!(facet, FacetSelector::Relation(RelationFacet::ContextSource)) {
|
||||
RelationFacet::Source
|
||||
} else {
|
||||
RelationFacet::Target
|
||||
},
|
||||
),
|
||||
);
|
||||
let observed = observe(
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
base
|
||||
} else {
|
||||
&proposed
|
||||
},
|
||||
&leaf,
|
||||
)?;
|
||||
agree(state, &mut slots, leaf, observed, address, facet)?;
|
||||
}
|
||||
let first_equal = row.can_acknowledge_constructor(base, facet)
|
||||
&& (*decision != SemanticDecision::Candidate
|
||||
|| observe(base, &slot)? == observe(&proposed, &slot)?);
|
||||
|
||||
@@ -463,6 +463,7 @@ fn occurrence_and_event_candidate_variants_cover_closed_families_without_touchin
|
||||
lifeline: None,
|
||||
},
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -519,6 +520,7 @@ fn occurrence_and_event_candidate_variants_cover_closed_families_without_touchin
|
||||
body: OccurrenceBody::Action { .. },
|
||||
..
|
||||
} => occurrence(OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -725,6 +727,7 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
#[test]
|
||||
fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retain_both_drafts() {
|
||||
let original = occurrence(OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -1319,6 +1322,7 @@ fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_an
|
||||
fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_expression_text() {
|
||||
for reverse in [false, true] {
|
||||
let message = occurrence(OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -137,6 +137,7 @@ fn fixture(binding: bool) -> Fixture {
|
||||
});
|
||||
locals.push(leaf);
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -14,6 +14,7 @@ fn fixture(oa_kind: MessageKind, sa_kind: MessageKind) -> (Project, Id, Id, Id)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -358,6 +359,7 @@ fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_dir
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&call).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -35,6 +35,8 @@ pub(super) struct Saved {
|
||||
attachments: attachments::Saved,
|
||||
#[serde(default)]
|
||||
artifacts: artifacts::Saved,
|
||||
#[serde(default)]
|
||||
operation_omission: Option<String>,
|
||||
reference_oa: Option<Id>,
|
||||
reference_sa: Option<Id>,
|
||||
reference_search: String,
|
||||
@@ -68,6 +70,8 @@ struct RowPayload {
|
||||
attachments: attachments::Saved,
|
||||
#[serde(default)]
|
||||
artifacts: artifacts::Saved,
|
||||
#[serde(default)]
|
||||
operation_omission: Option<String>,
|
||||
reference_oa: Option<Id>,
|
||||
reference_sa: Option<Id>,
|
||||
reference_search: String,
|
||||
@@ -120,6 +124,7 @@ impl RowPayload {
|
||||
orders: saved.orders,
|
||||
attachments: saved.attachments,
|
||||
artifacts: saved.artifacts,
|
||||
operation_omission: saved.operation_omission,
|
||||
reference_oa: saved.reference_oa,
|
||||
reference_sa: saved.reference_sa,
|
||||
reference_search: saved.reference_search,
|
||||
@@ -160,6 +165,7 @@ impl RowPayload {
|
||||
orders: self.orders,
|
||||
attachments: self.attachments,
|
||||
artifacts: self.artifacts,
|
||||
operation_omission: self.operation_omission,
|
||||
reference_oa: self.reference_oa,
|
||||
reference_sa: self.reference_sa,
|
||||
reference_search: self.reference_search,
|
||||
@@ -234,6 +240,7 @@ impl ReviewRow {
|
||||
.collect(),
|
||||
attachments: self.attachments.recovery_snapshot(),
|
||||
artifacts: self.artifacts.recovery_snapshot(),
|
||||
operation_omission: self.operation_omission.clone(),
|
||||
reference_oa: self.reference_oa.as_ref().map(|c| c.id),
|
||||
orders: self
|
||||
.order_drafts
|
||||
@@ -378,6 +385,7 @@ impl ReviewRow {
|
||||
return Err("Recovered artifact drafts need an exact owner trace.".into());
|
||||
}
|
||||
state.reference_sa = saved.reference_sa.map(|id| view::choice(project, id));
|
||||
state.operation_omission = saved.operation_omission;
|
||||
state.reference_search = saved.reference_search;
|
||||
state.accepted_impacts = saved.accepted_impacts;
|
||||
// Invalid numbers and incomplete mappings are recoverable authored input.
|
||||
@@ -414,7 +422,7 @@ fn batch_limits(value: &Value) -> Result<(), String> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// A source-5 upgrade may reconstruct the same accepted records with a new
|
||||
/// A source-5/6 upgrade may reconstruct the same accepted records with a new
|
||||
/// schema fingerprint. The caller supplies the independently verified legacy
|
||||
/// base; every saved row is still decoded and replanned through strict restore.
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
@@ -423,13 +431,13 @@ impl State {
|
||||
value: Value,
|
||||
) -> Result<Self, String> {
|
||||
batch_limits(&value)?;
|
||||
crate::recovery::validate_pre7_intent(&value)?;
|
||||
let value = match value.get("version").and_then(Value::as_u64) {
|
||||
Some(1) => {
|
||||
let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
|
||||
return Err(
|
||||
"Semantic source-5 recovery does not match the verified legacy base."
|
||||
.into(),
|
||||
"Semantic legacy recovery does not match the verified legacy base.".into(),
|
||||
);
|
||||
}
|
||||
saved.base_fingerprint = project_fingerprint(project);
|
||||
@@ -441,8 +449,7 @@ impl State {
|
||||
serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
|
||||
return Err(
|
||||
"Semantic source-5 recovery does not match the verified legacy base."
|
||||
.into(),
|
||||
"Semantic legacy recovery does not match the verified legacy base.".into(),
|
||||
);
|
||||
}
|
||||
saved.base_fingerprint = project_fingerprint(project);
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct ReviewRow {
|
||||
pub selected: Option<FacetSelector>,
|
||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
pub operation_omission: Option<String>,
|
||||
pub resets: BTreeSet<FacetSelector>,
|
||||
pub staged: BTreeSet<FacetSelector>,
|
||||
pub presence: BTreeMap<FacetSelector, bool>,
|
||||
@@ -73,6 +74,7 @@ impl ReviewRow {
|
||||
selected: None,
|
||||
decisions: BTreeMap::new(),
|
||||
references: BTreeMap::new(),
|
||||
operation_omission: None,
|
||||
resets: BTreeSet::new(),
|
||||
staged: BTreeSet::new(),
|
||||
presence: BTreeMap::new(),
|
||||
@@ -133,6 +135,7 @@ impl ReviewRow {
|
||||
self.selected = None;
|
||||
self.accepted_impacts.clear();
|
||||
self.references.clear();
|
||||
self.operation_omission = None;
|
||||
self.draft = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id));
|
||||
@@ -163,6 +166,15 @@ impl ReviewRow {
|
||||
pub fn update(&mut self, project: &Project, event: RowEvent) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
RowEvent::OperationOmission(enabled) => {
|
||||
self.operation_omission =
|
||||
enabled.then(|| self.operation_omission.clone().unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::OperationRationale(raw) => {
|
||||
self.operation_omission = Some(raw);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
RowEvent::Trace(choice) => {
|
||||
if !project
|
||||
@@ -734,9 +746,7 @@ impl ReviewRow {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
|
||||
let mut supplied = original.clone();
|
||||
*supplied
|
||||
.pointer_mut(&roots[0])
|
||||
.ok_or("Source property is unavailable.")? = value;
|
||||
crate::forms::set_shape_value(&mut supplied, &roots[0], value)?;
|
||||
supplied
|
||||
}
|
||||
};
|
||||
@@ -744,11 +754,15 @@ impl ReviewRow {
|
||||
let value = supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
(matches!(facet, FacetSelector::Occurrence(OccurrenceFacet::Operation)))
|
||||
.then_some(Value::Null)
|
||||
})
|
||||
.ok_or("Candidate property is no longer available.")?;
|
||||
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
|
||||
group_candidates.push((path.clone(), value.clone()));
|
||||
}
|
||||
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
|
||||
crate::forms::set_shape_value(&mut final_value, &path, value)?;
|
||||
}
|
||||
}
|
||||
if !form_context {
|
||||
|
||||
@@ -16,7 +16,7 @@ fn enum_tag(value: &Value) -> Option<&str> {
|
||||
}
|
||||
impl Own {
|
||||
fn get(project: &Project, id: Id) -> Option<Self> {
|
||||
let (whole, element) = if let Some(e) = project.elements.get(&id) {
|
||||
let (mut whole, element) = if let Some(e) = project.elements.get(&id) {
|
||||
(serde_json::to_value(e).ok()?, true)
|
||||
} else {
|
||||
(
|
||||
@@ -24,6 +24,35 @@ impl Own {
|
||||
false,
|
||||
)
|
||||
};
|
||||
if project.elements.get(&id).is_some_and(|e| {
|
||||
e.layer() == Layer::Sa
|
||||
&& matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
}) && validation::ancestor_of_kind(project, id, Kind::Scenario)
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
&& let Some(fields) = whole
|
||||
.pointer_mut("/body/Sa/Shared/Occurrence/body/Exchange")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
fields.entry("operation").or_insert(Value::Null);
|
||||
}
|
||||
let mut prefix = vec!["body".to_string()];
|
||||
let mut value = &whole["body"];
|
||||
if element {
|
||||
@@ -114,6 +143,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Occurrence(OccurrenceFacet::ReplyTo) => {
|
||||
("Occurrence", &["body", "Exchange", "reply_to"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Operation) => {
|
||||
("Occurrence", &["body", "Exchange", "operation"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
|
||||
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
|
||||
("Occurrence", &["body", "Action", "lifeline"])
|
||||
@@ -142,8 +174,12 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::State(StateFacet::Exit) => ("State", &["exit"]),
|
||||
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
|
||||
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
|
||||
F::Relation(RelationFacet::Source) => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]),
|
||||
F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]),
|
||||
F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]),
|
||||
F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]),
|
||||
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
|
||||
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
|
||||
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Source7 desktop composition falsifiers, using the public planner as final oracle.
|
||||
use super::*;
|
||||
|
||||
fn operation_fixture() -> Project {
|
||||
persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
fn operation_id(p: &Project, n: u32) -> Id {
|
||||
let alias = format!("operation_probe_{n}");
|
||||
p.elements
|
||||
.values()
|
||||
.find(|e| e.alias == alias)
|
||||
.map(|e| e.id)
|
||||
.or_else(|| {
|
||||
p.relations
|
||||
.values()
|
||||
.find(|r| r.alias == alias)
|
||||
.map(|r| r.id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
state.update(p, state.address_event(event));
|
||||
}
|
||||
|
||||
fn occurrence(p: &mut Project, layer: Layer, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let mut element = p
|
||||
.make_element(Kind::Occurrence, layer, name, Some(owner))
|
||||
.unwrap();
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
body,
|
||||
};
|
||||
let id = element.id;
|
||||
*p = execute_batch(p, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_incident_leaf_keep_allows_path_change_but_conflicts_with_changed_leaf_both_directions()
|
||||
{
|
||||
let mut p = Project::empty();
|
||||
let mut layer_ids = Vec::new();
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Caller", None).unwrap();
|
||||
let definition = p.add_element(kind, layer, "Reusable checks", None).unwrap();
|
||||
let a = occurrence(
|
||||
&mut p,
|
||||
layer,
|
||||
definition,
|
||||
"First check",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let b = occurrence(
|
||||
&mut p,
|
||||
layer,
|
||||
definition,
|
||||
"Second check",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let r1 = occurrence(
|
||||
&mut p,
|
||||
layer,
|
||||
owner,
|
||||
"First use",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(definition),
|
||||
},
|
||||
);
|
||||
let r2 = occurrence(
|
||||
&mut p,
|
||||
layer,
|
||||
owner,
|
||||
"Second use",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(definition),
|
||||
},
|
||||
);
|
||||
let id = Id::new_v4();
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: p.fresh_alias("caller_constraint", "relation", id),
|
||||
name: "Caller constraint".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![r1],
|
||||
occurrence: a,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![r2],
|
||||
occurrence: b,
|
||||
},
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: String::new(),
|
||||
},
|
||||
},
|
||||
};
|
||||
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
|
||||
layer_ids.push([owner, definition, a, b, r1, r2, id]);
|
||||
}
|
||||
let attached_trace = tests::add_trace(
|
||||
&mut p,
|
||||
layer_ids[0][2],
|
||||
layer_ids[1][2],
|
||||
"Incident leaf review",
|
||||
);
|
||||
let direct_trace = tests::add_trace(
|
||||
&mut p,
|
||||
layer_ids[0][6],
|
||||
layer_ids[1][6],
|
||||
"Qualified path review",
|
||||
);
|
||||
let incident = FacetSelector::Incident {
|
||||
keys: KeyPair {
|
||||
oa: Some(layer_ids[0][6]),
|
||||
sa: Some(layer_ids[1][6]),
|
||||
},
|
||||
end: IncidentEnd::Source,
|
||||
};
|
||||
let direct = FacetSelector::Relation(RelationFacet::ContextSource);
|
||||
for reverse in [false, true] {
|
||||
let target = usize::from(!reverse);
|
||||
let mut state = State::new(&p, attached_trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&p, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
send(&mut state, &p, RowEvent::EditPair(incident.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Attachment(attachments::Event::Stage),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(incident.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
state.update(&p, Event::ChooseTrace(view::choice(&p, direct_trace)));
|
||||
state.update(&p, Event::OpenTrace);
|
||||
for (&oa, &sa) in layer_ids[0].iter().zip(&layer_ids[1]).take(7) {
|
||||
state.references.insert(oa, sa);
|
||||
}
|
||||
state.refresh(&p);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(direct.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Select(direct.clone()));
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![layer_ids[target][5]],
|
||||
occurrence: layer_ids[target][2],
|
||||
};
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Shape(
|
||||
"/body/ContextualLink/source".into(),
|
||||
serde_json::to_value(&endpoint).unwrap(),
|
||||
),
|
||||
);
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
let expected = state.proposed.as_ref().unwrap().clone();
|
||||
assert!(
|
||||
matches!(&expected.relations[&layer_ids[target][6]].body, RelationBody::ContextualLink { source, .. } if *source == endpoint)
|
||||
);
|
||||
assert_eq!(
|
||||
execute_batch(&p, &[state.command(&p).unwrap()]).unwrap(),
|
||||
expected
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(restored.can_apply());
|
||||
let conflicting = OccurrenceEndpoint {
|
||||
references: vec![layer_ids[target][4]],
|
||||
occurrence: layer_ids[target][3],
|
||||
};
|
||||
send(
|
||||
&mut restored,
|
||||
&p,
|
||||
RowEvent::Shape(
|
||||
"/body/ContextualLink/source".into(),
|
||||
serde_json::to_value(conflicting).unwrap(),
|
||||
),
|
||||
);
|
||||
assert!(!restored.can_apply());
|
||||
assert!(
|
||||
restored
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Independent row proposals disagree"),
|
||||
"{:?}",
|
||||
restored.error
|
||||
);
|
||||
assert!(restored.command(&p).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_operation_virtual_none_candidate_uses_exact_allocation_and_preserves_accepted_bytes() {
|
||||
let mut p = operation_fixture();
|
||||
let call = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "operation_probe_43")
|
||||
.unwrap()
|
||||
.id;
|
||||
let allocation = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "operation_probe_30")
|
||||
.unwrap()
|
||||
.id;
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Operational conversation", None)
|
||||
.unwrap();
|
||||
let oa = occurrence(
|
||||
&mut p,
|
||||
Layer::Oa,
|
||||
owner,
|
||||
"Operational request",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Call,
|
||||
reply_to: None,
|
||||
operation: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
let trace = tests::add_trace(&mut p, oa, call, "Service rationale");
|
||||
let before = serde_json::to_value(&p).unwrap();
|
||||
let facet = FacetSelector::Occurrence(OccurrenceFacet::Operation);
|
||||
let path = scalar::binding(&p, call, &facet).unwrap();
|
||||
assert!(
|
||||
serde_json::to_value(&p.elements[&call])
|
||||
.unwrap()
|
||||
.pointer(&path)
|
||||
.is_none()
|
||||
);
|
||||
let mut state = State::new(&p, trace).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Select(facet));
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Shape(path, serde_json::json!({"allocation":allocation})),
|
||||
);
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
let actual = state.proposed.as_ref().unwrap();
|
||||
assert!(
|
||||
matches!(oa_core::operations::resolve(actual, call).unwrap(), oa_core::operations::OperationResolution::Selected(value) if value.allocation == allocation)
|
||||
);
|
||||
assert_eq!(serde_json::to_value(&p).unwrap(), before);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(restored.proposed, state.proposed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_operation_omission_gui_requires_raw_rationale_and_matches_one_core_batch_after_recovery()
|
||||
{
|
||||
use crate::workflow::{Event as W, Workflow};
|
||||
let p = operation_fixture();
|
||||
let call = operation_id(&p, 43);
|
||||
let mut message = p.elements[&call].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = message.shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(operation_id(&p, 30)),
|
||||
});
|
||||
let p = execute_batch(&p, &[Command::EditElement(call, message)]).unwrap();
|
||||
let mut workflow = Workflow::transition(&p, operation_id(&p, 40)).unwrap();
|
||||
assert!(workflow.update(&p, W::Apply).is_err());
|
||||
workflow
|
||||
.update(&p, W::OperationOmission(call, true))
|
||||
.unwrap();
|
||||
workflow
|
||||
.update(&p, W::OperationRationale(call, " \n ".into()))
|
||||
.unwrap();
|
||||
let saved = workflow.recovery_snapshot().unwrap();
|
||||
let mut workflow = Workflow::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(workflow.recovery_snapshot().unwrap(), saved);
|
||||
assert!(workflow.update(&p, W::Apply).is_err());
|
||||
for (n, kind) in [(10, Kind::Actor), (11, Kind::Entity)] {
|
||||
workflow
|
||||
.update(&p, W::MappingKind(operation_id(&p, n), kind))
|
||||
.unwrap();
|
||||
}
|
||||
let rationale = " The operational conversation abstracts the technical service signature. ";
|
||||
workflow
|
||||
.update(&p, W::OperationRationale(call, rationale.into()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(draft) = &workflow else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(draft.request.operation_omissions[0].rationale, rationale);
|
||||
assert!(
|
||||
draft.plan.conflicts.is_empty(),
|
||||
"{:?}",
|
||||
draft.plan.conflicts
|
||||
);
|
||||
let expected = execute_batch(&p, &draft.plan.commands).unwrap();
|
||||
let target = draft
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == call)
|
||||
.unwrap()
|
||||
.target;
|
||||
let actual = workflow.update(&p, W::Apply).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Behavior root adds no implicit architectural placements"
|
||||
);
|
||||
assert!(matches!(
|
||||
oa_core::operations::resolve(&actual, target).unwrap(),
|
||||
oa_core::operations::OperationResolution::Ordinary
|
||||
));
|
||||
assert_eq!(actual.elements[&call], p.elements[&call]);
|
||||
}
|
||||
@@ -919,6 +919,7 @@ fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approva
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -278,32 +278,22 @@ impl ReviewRow {
|
||||
let mut content = column![
|
||||
header,
|
||||
row![
|
||||
action("Properties", RowEvent::Tab(Tab::Facts)),
|
||||
action("Members / rows", RowEvent::Tab(Tab::Members)),
|
||||
action("Attachments", RowEvent::Tab(Tab::Attachments)),
|
||||
action("Artifact links", RowEvent::Tab(Tab::Artifacts)),
|
||||
action(
|
||||
format!(
|
||||
"History ({}) · {} resets",
|
||||
trace
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.map_or(0, |b| b.facets.len()),
|
||||
self.resets.len()
|
||||
),
|
||||
RowEvent::Tab(Tab::History)
|
||||
),
|
||||
action(
|
||||
format!("Reference pairs ({})", self.references.len()),
|
||||
RowEvent::Tab(Tab::References)
|
||||
),
|
||||
action(
|
||||
format!(
|
||||
"Additional changes ({})",
|
||||
self.plan.as_ref().map_or(0, |p| p.impacts.len())
|
||||
),
|
||||
RowEvent::Tab(Tab::Impacts)
|
||||
),
|
||||
text("Review area").size(12),
|
||||
pick_list(
|
||||
vec![
|
||||
Tab::Facts,
|
||||
Tab::Members,
|
||||
Tab::Attachments,
|
||||
Tab::Artifacts,
|
||||
Tab::History,
|
||||
Tab::References,
|
||||
Tab::Impacts,
|
||||
],
|
||||
Some(self.tab),
|
||||
|tab| event(RowEvent::Tab(tab)),
|
||||
)
|
||||
.text_size(12)
|
||||
.padding(7),
|
||||
Space::new().width(Fill),
|
||||
action("Refresh preview", RowEvent::Preview)
|
||||
]
|
||||
@@ -311,6 +301,42 @@ impl ReviewRow {
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(8);
|
||||
let impacts = self.plan.as_ref().map_or(0, |plan| plan.impacts.len());
|
||||
if impacts > 0 && self.tab != Tab::Impacts {
|
||||
content = content.push(action(
|
||||
format!("View {impacts} additional changes"),
|
||||
RowEvent::Tab(Tab::Impacts),
|
||||
));
|
||||
}
|
||||
if self.direction == TransitionDirection::SaToOa
|
||||
&& trace.is_some_and(|trace| {
|
||||
matches!(
|
||||
project.elements.get(&trace.sa).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
{
|
||||
content = content.push(
|
||||
checkbox(self.operation_omission.is_some())
|
||||
.label("Explicitly omit the technical operation in the OA counterpart")
|
||||
.on_toggle(|enabled| event(RowEvent::OperationOmission(enabled))),
|
||||
);
|
||||
if let Some(raw) = &self.operation_omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", raw)
|
||||
.on_input(|raw| event(RowEvent::OperationRationale(raw)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
let body = match self.tab {
|
||||
Tab::Facts => self.facts_view(project),
|
||||
Tab::References => self.references_view(project),
|
||||
|
||||
+151
-54
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod traceability;
|
||||
use crate::{
|
||||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||||
diagram::DiagramCanvas,
|
||||
@@ -192,9 +193,11 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.push(crate::explorer_view::sidebar(app))
|
||||
.push(creation)
|
||||
.push(action("Project properties", Message::SelectProject).width(Fill));
|
||||
.push(crate::explorer_view::sidebar(app));
|
||||
if app.page != Page::Diagram {
|
||||
nav = nav.push(creation);
|
||||
}
|
||||
nav = nav.push(action("Project properties", Message::SelectProject).width(Fill));
|
||||
let sidebar = container(if app.page == Page::Text {
|
||||
source_files(app)
|
||||
} else {
|
||||
@@ -224,12 +227,33 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
Page::Validation => checks(app),
|
||||
Page::Guide => crate::guide::view(&app.guide),
|
||||
};
|
||||
let process_layout = app.topology_context().is_some();
|
||||
let focus_process = process_layout && app.behavior.topology.focused;
|
||||
let mut body = if app.page == Page::Home {
|
||||
row![container(main).width(Fill).height(Fill)].height(Fill)
|
||||
} else if process_layout {
|
||||
// Keep main at the same widget-tree path across Focus toggles. The
|
||||
// collapsed slot has no controls, so it cannot paint over or hit the canvas.
|
||||
let process_sidebar: View<'_, Message> = if focus_process {
|
||||
Space::new().width(0).into()
|
||||
} else {
|
||||
sidebar.into()
|
||||
};
|
||||
// Row::push would discard the zero-width slot and shift main again.
|
||||
widget::Row::from_vec(vec![
|
||||
container(process_sidebar)
|
||||
.width(if focus_process { 0 } else { 260 })
|
||||
.height(Fill)
|
||||
.clip(true)
|
||||
.into(),
|
||||
container(main).width(Fill).height(Fill).into(),
|
||||
])
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
} else {
|
||||
row![sidebar, container(main).width(Fill).height(Fill)].height(Fill)
|
||||
};
|
||||
if !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
|
||||
if !focus_process && !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
|
||||
body = body.push(inspector(app));
|
||||
}
|
||||
let footer = container(
|
||||
@@ -259,6 +283,13 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
.padding([4, 16])
|
||||
.style(style::panel);
|
||||
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
|
||||
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::architecture_authoring::dialog(app),
|
||||
(app.window_size.width - 50.).clamp(500., 820.),
|
||||
);
|
||||
}
|
||||
if let Some(workflow) = &app.workflow {
|
||||
if matches!(workflow, crate::workflow::Workflow::Semantic(_)) {
|
||||
return modal_width(
|
||||
@@ -345,23 +376,17 @@ fn modal_width<'a>(
|
||||
.into()
|
||||
}
|
||||
fn diagram(app: &App) -> View<'_, Message> {
|
||||
let diagrams = app
|
||||
let mut diagrams = app
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||||
.map(|d| Choice {
|
||||
id: d.id,
|
||||
label: short(
|
||||
&d.name,
|
||||
if app.window_size.width < 1250. {
|
||||
26
|
||||
} else {
|
||||
48
|
||||
},
|
||||
),
|
||||
label: d.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
diagrams.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
|
||||
let selected = diagrams
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == app.active_diagram)
|
||||
@@ -427,16 +452,26 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
.map(|(_, i)| i),
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: app.architecture.as_ref().is_some_and(|s| s.saved.placing),
|
||||
connection: app.connection.as_ref(),
|
||||
};
|
||||
widget::sensor(widget::stack![
|
||||
let mut scene = widget::stack![
|
||||
widget::canvas(program).width(Fill).height(Fill),
|
||||
widget::canvas(crate::diagram::PortLabels(program))
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
])
|
||||
.on_show(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized)
|
||||
];
|
||||
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
|
||||
scene = scene.push(editor);
|
||||
}
|
||||
row![
|
||||
widget::sensor(scene)
|
||||
.on_show(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized),
|
||||
crate::app::architecture_authoring::palette(app)
|
||||
]
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.into()
|
||||
} else {
|
||||
container(
|
||||
@@ -459,20 +494,10 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.center(Fill)
|
||||
.into()
|
||||
};
|
||||
let choose = |tool: Option<crate::connection_tool::Tool>| {
|
||||
let active = app.connection.as_ref().map(|s| s.tool) == tool;
|
||||
button(text(tool.map_or("Select / move", |t| t.label())).size(12))
|
||||
.padding([8, 10])
|
||||
.on_press(Message::Connection(crate::connection_edit::Event::Choose(
|
||||
tool,
|
||||
)))
|
||||
.style(if active { button::primary } else { style::nav })
|
||||
};
|
||||
let mut palette = row![choose(None)].spacing(4);
|
||||
for tool in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
palette = palette.push(choose(Some(*tool)));
|
||||
let mut tools = column![toolbar].spacing(8);
|
||||
if app.active_diagram.is_some() {
|
||||
tools = tools.push(modes);
|
||||
}
|
||||
let mut tools = column![toolbar, modes, palette].spacing(8);
|
||||
if let Some(diagram) = app.active_diagram {
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||
@@ -726,12 +751,22 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
);
|
||||
}
|
||||
if let Some(r) = p.relations.get(&app.selected) {
|
||||
body = body.push(text(r.kind().to_string()).size(17)).push(
|
||||
pick_list(RelationKind::ALL, Some(r.kind()), Message::RelationKind)
|
||||
body = body.push(text(r.kind().to_string()).size(17));
|
||||
if r.kind() != RelationKind::ContextualLink {
|
||||
body = body.push(
|
||||
pick_list(
|
||||
RelationKind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| *kind != RelationKind::ContextualLink)
|
||||
.collect::<Vec<_>>(),
|
||||
Some(r.kind()),
|
||||
Message::RelationKind,
|
||||
)
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
body = body.push(
|
||||
row![
|
||||
@@ -771,27 +806,81 @@ fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn traces(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let mut out = column![
|
||||
use traceability::{Event as E, Filter};
|
||||
let (traces, artifacts) = app.traceability.matching(p);
|
||||
let mut tabs = row![].spacing(6);
|
||||
for (filter, label) in [
|
||||
(
|
||||
Filter::All,
|
||||
format!("All ({})", p.traces.len() + p.artifact_links.len()),
|
||||
),
|
||||
(Filter::Traces, format!("Layer traces ({})", p.traces.len())),
|
||||
(
|
||||
Filter::Artifacts,
|
||||
format!("Development artifacts ({})", p.artifact_links.len()),
|
||||
),
|
||||
] {
|
||||
tabs = tabs.push(
|
||||
button(text(label).size(12))
|
||||
.style(if app.traceability.filter == filter {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::Traceability(E::Filter(filter))),
|
||||
);
|
||||
}
|
||||
let header = column![
|
||||
heading(
|
||||
"Traceability",
|
||||
"Connect operational needs, system responsibilities and development evidence."
|
||||
),
|
||||
row![
|
||||
button(text("Create counterpart / review prose…").size(12))
|
||||
.padding(9)
|
||||
.on_press(Message::StartTransition),
|
||||
button(
|
||||
text(
|
||||
if p.layer_of(app.selected).unwrap_or(app.layer) == Layer::Oa {
|
||||
"Create System counterpart…"
|
||||
} else {
|
||||
"Create Operational counterpart…"
|
||||
}
|
||||
)
|
||||
.size(12)
|
||||
)
|
||||
.padding(9)
|
||||
.on_press(Message::StartTransition),
|
||||
action("+ Manual trace", Message::AddTrace),
|
||||
action(
|
||||
"Review semantics…",
|
||||
"Review counterpart changes…",
|
||||
Message::StartSemanticReview(app.selected)
|
||||
),
|
||||
action("+ Artifact", Message::AddArtifact)
|
||||
]
|
||||
.spacing(8)
|
||||
.wrap()
|
||||
.wrap(),
|
||||
tabs.wrap(),
|
||||
row![
|
||||
text_input(
|
||||
"Search names, aliases, owners or artifact URI…",
|
||||
&app.traceability.query
|
||||
)
|
||||
.on_input(|raw| Message::Traceability(E::Search(raw)))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
button("Clear search").on_press(Message::Traceability(E::Search(String::new())))
|
||||
]
|
||||
.spacing(6),
|
||||
label(format!(
|
||||
"{} matching traces · {} matching artifacts",
|
||||
traces.len(),
|
||||
artifacts.len()
|
||||
))
|
||||
]
|
||||
.spacing(16);
|
||||
for t in p.traces.values() {
|
||||
.spacing(10)
|
||||
.padding(24);
|
||||
let mut out = column![].spacing(16);
|
||||
for id in &traces {
|
||||
let t = &p.traces[id];
|
||||
let current = match (&t.baseline, p.properties(t.oa), p.properties(t.sa)) {
|
||||
(Some(b), Some(oa), Some(sa)) if b.oa == oa && b.sa == sa => "Reviewed",
|
||||
(Some(_), _, _) => "Review needed",
|
||||
@@ -810,7 +899,10 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
row![
|
||||
label(format!("{:?} · Prose: {current}", t.kind)),
|
||||
Space::new().width(Fill),
|
||||
action("Review semantics…", Message::StartSemanticReview(t.id)),
|
||||
action(
|
||||
"Review counterpart changes…",
|
||||
Message::StartSemanticReview(t.id)
|
||||
),
|
||||
action("Edit link", Message::Select(t.id))
|
||||
],
|
||||
text(crate::semantic_review::summary(p, t))
|
||||
@@ -824,17 +916,12 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
.style(style::card),
|
||||
);
|
||||
}
|
||||
out = out.push(text("Development artifacts").size(18));
|
||||
for a in p.artifact_links.values() {
|
||||
let owner = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find_map(|d| {
|
||||
d.nodes
|
||||
.get(&a.owner)
|
||||
.map(|n| format!("Placement of {} · {}", p.named(n.element), d.name))
|
||||
})
|
||||
.unwrap_or_else(|| p.named(a.owner).to_owned());
|
||||
if !artifacts.is_empty() {
|
||||
out = out.push(text("Development artifacts").size(18));
|
||||
}
|
||||
for id in &artifacts {
|
||||
let a = &p.artifact_links[id];
|
||||
let owner = traceability::owner_label(p, a.owner);
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
@@ -860,8 +947,18 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
if p.traces.is_empty() && p.artifact_links.is_empty() {
|
||||
out=out.push(text("Select a model element, then connect it to the other perspective or attach a code, CAD, schematic or document reference.").size(14).color(style::MUTED));
|
||||
} else if traces.is_empty() && artifacts.is_empty() {
|
||||
out = out
|
||||
.push(text("No matching records. Clear the search or choose another filter.").size(13));
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
column![
|
||||
header,
|
||||
scrollable(out.padding(iced::Padding::from(24).right(42)))
|
||||
.id("traceability-list")
|
||||
.height(Fill)
|
||||
]
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn source_files(app: &App) -> View<'_, Message> {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Transient browsing over the complete trace/artifact inventory.
|
||||
use oa_core::{Id, Project};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Filter {
|
||||
#[default]
|
||||
All,
|
||||
Traces,
|
||||
Artifacts,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Filter(Filter),
|
||||
Search(String),
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
pub filter: Filter,
|
||||
pub query: String,
|
||||
}
|
||||
impl State {
|
||||
pub fn update(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Filter(filter) => self.filter = filter,
|
||||
Event::Search(query) => self.query = query,
|
||||
}
|
||||
}
|
||||
pub fn matching(&self, p: &Project) -> (Vec<Id>, Vec<Id>) {
|
||||
let query = self.query.trim().to_lowercase();
|
||||
let aliases: std::collections::BTreeMap<_, _> = p.identities().into_iter().collect();
|
||||
let identity = |id| {
|
||||
format!(
|
||||
"{} {} {id}",
|
||||
p.named(id),
|
||||
aliases.get(&id).copied().unwrap_or_default()
|
||||
)
|
||||
};
|
||||
let mut traces = if self.filter == Filter::Artifacts {
|
||||
vec![]
|
||||
} else {
|
||||
p.traces
|
||||
.values()
|
||||
.filter(|t| {
|
||||
format!(
|
||||
"{} {} {} {} {}",
|
||||
t.name,
|
||||
t.alias,
|
||||
t.description,
|
||||
identity(t.oa),
|
||||
identity(t.sa)
|
||||
)
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
})
|
||||
.map(|t| t.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut artifacts = if self.filter == Filter::Traces {
|
||||
vec![]
|
||||
} else {
|
||||
p.artifact_links
|
||||
.values()
|
||||
.filter(|a| {
|
||||
format!(
|
||||
"{} {} {} {} {} {} {} {} {:?}",
|
||||
a.name,
|
||||
a.alias,
|
||||
a.description,
|
||||
a.uri,
|
||||
a.revision.as_deref().unwrap_or_default(),
|
||||
a.fragment.as_deref().unwrap_or_default(),
|
||||
identity(a.owner),
|
||||
owner_label(p, a.owner),
|
||||
a.relation
|
||||
)
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
traces.sort_by(|a, b| p.traces[a].name.cmp(&p.traces[b].name).then(a.cmp(b)));
|
||||
artifacts.sort_by(|a, b| {
|
||||
p.artifact_links[a]
|
||||
.name
|
||||
.cmp(&p.artifact_links[b].name)
|
||||
.then(a.cmp(b))
|
||||
});
|
||||
(traces, artifacts)
|
||||
}
|
||||
}
|
||||
pub(super) fn owner_label(p: &Project, owner: Id) -> String {
|
||||
p.diagrams
|
||||
.values()
|
||||
.find_map(|d| {
|
||||
d.nodes
|
||||
.get(&owner)
|
||||
.map(|node| format!("Placement of {} · {}", p.named(node.element), d.name))
|
||||
})
|
||||
.unwrap_or_else(|| p.named(owner).to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{App, Message};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (Project, Id, [Id; 3]) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Rescue dispatch", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Dispatch controller", None)
|
||||
.unwrap();
|
||||
for n in 0..68 {
|
||||
let target = if n == 0 {
|
||||
sa
|
||||
} else {
|
||||
p.add_element(
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
&format!("Dispatch function {n:02}"),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("review_{n}"),
|
||||
name: format!("Review {n:02}"),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa: target,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let ids = [Id::from_u128(502), Id::from_u128(501), Id::from_u128(503)];
|
||||
for (index, id) in ids.iter().copied().enumerate() {
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: format!("evidence_{index}"),
|
||||
name: if index == 2 {
|
||||
"Zulu schematic"
|
||||
} else {
|
||||
"Acceptance evidence"
|
||||
}
|
||||
.into(),
|
||||
description: "Engineering evidence".into(),
|
||||
owner: oa,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: format!("./evidence/document_{index}.md"),
|
||||
revision: Some(format!("revision-{index}")),
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
validate_candidate(&p).unwrap();
|
||||
(p, oa, ids)
|
||||
}
|
||||
#[test]
|
||||
fn traceability_filter_reaches_artifacts_after_68_traces_and_searches_all_metadata() {
|
||||
let (p, _, ids) = fixture();
|
||||
let mut state = State::default();
|
||||
assert_eq!(state.matching(&p).0.len(), 68);
|
||||
state.update(Event::Filter(Filter::Artifacts));
|
||||
assert_eq!(state.matching(&p), (vec![], vec![ids[1], ids[0], ids[2]]));
|
||||
for query in ["document_2", "evidence_2", "revision-2", "Zulu schematic"] {
|
||||
state.update(Event::Search(query.into()));
|
||||
assert_eq!(state.matching(&p), (vec![], vec![ids[2]]));
|
||||
}
|
||||
state.update(Event::Search("Rescue dispatch".into()));
|
||||
assert_eq!(state.matching(&p).1.len(), 3);
|
||||
state.update(Event::Filter(Filter::Traces));
|
||||
assert_eq!(state.matching(&p).0.len(), 68);
|
||||
assert!(state.matching(&p).1.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn traceability_browsing_retains_invalid_property_draft_and_existing_select_guard() {
|
||||
let (p, owner, ids) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Field("/alias".into(), "invalid alias".into()));
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
for event in [
|
||||
Event::Filter(Filter::Artifacts),
|
||||
Event::Search("document_2".into()),
|
||||
Event::Filter(Filter::All),
|
||||
] {
|
||||
let _ = app.update(Message::Traceability(event));
|
||||
assert_eq!(app.selected, owner);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &before));
|
||||
}
|
||||
let _ = app.update(Message::Select(ids[2]));
|
||||
assert_eq!(app.selected, owner);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &before));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
mod communication;
|
||||
pub(crate) mod recovery;
|
||||
#[cfg(test)]
|
||||
mod transition_tests;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
@@ -30,9 +32,12 @@ pub enum Event {
|
||||
Source(Choice),
|
||||
Target(Choice),
|
||||
Scope(String),
|
||||
Destination(Choice),
|
||||
MappingTarget(Id, Choice),
|
||||
MappingKind(Id, Kind),
|
||||
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
||||
OperationOmission(Id, bool),
|
||||
OperationRationale(Id, String),
|
||||
Resolve(Id, TransitionField, FieldDecision),
|
||||
Preview,
|
||||
}
|
||||
@@ -265,6 +270,157 @@ pub struct Transition {
|
||||
pub plan: TransitionPlan,
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
}
|
||||
impl Transition {
|
||||
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
||||
let mut candidate = p.clone();
|
||||
for command in &self.plan.commands {
|
||||
match command {
|
||||
Command::CreateElement(e) | Command::EditElement(_, e) => {
|
||||
candidate.elements.insert(e.id, e.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let targets = self
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.target)
|
||||
.filter(|id| candidate.elements.contains_key(id))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
(candidate, targets)
|
||||
}
|
||||
fn automatic_behavior(&self, p: &Project) -> bool {
|
||||
self.request
|
||||
.sources
|
||||
.first()
|
||||
.is_some_and(|id| crate::behavior::selected_behavior(p, *id).is_some())
|
||||
}
|
||||
fn new_counterpart(&self, p: &Project) -> bool {
|
||||
self.request.sources.first().is_some_and(|source| {
|
||||
self.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == *source)
|
||||
.map_or_else(
|
||||
|| {
|
||||
!p.traces
|
||||
.values()
|
||||
.any(|t| t.oa == *source || t.sa == *source)
|
||||
&& !self
|
||||
.request
|
||||
.mappings
|
||||
.iter()
|
||||
.any(|m| m.source == *source && m.target.is_some())
|
||||
},
|
||||
|c| c.action == TransitionAction::Create,
|
||||
)
|
||||
})
|
||||
}
|
||||
fn boundary_choice(&self, p: &Project, conflict: &TransitionConflict) -> bool {
|
||||
conflict.field.is_none()
|
||||
&& conflict.target.is_none()
|
||||
&& conflict.message
|
||||
== "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred."
|
||||
&& conflict
|
||||
.source
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.kind(),
|
||||
Kind::Entity | Kind::Actor | Kind::System | Kind::SystemActor
|
||||
)
|
||||
})
|
||||
}
|
||||
fn destination_error(&self, p: &Project) -> Option<String> {
|
||||
let id = self.request.destination_diagram?;
|
||||
let (candidate, targets) = self.presentation(p);
|
||||
match candidate.diagrams.get(&id) {
|
||||
None => Some("The chosen result diagram is missing. Your choice is retained; choose another diagram.".into()),
|
||||
Some(view) if !result_view_compatible(&candidate, view, &targets, self.request.direction.target_layer()) =>
|
||||
Some("The chosen result diagram cannot show every transitioned element. Choose another diagram; no model changes have been applied.".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
|
||||
(diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
|
||||
&& (diagram.projection.state_machine().is_none() || !targets.is_empty())
|
||||
&& targets.iter().all(|id| {
|
||||
p.elements.get(id).is_some_and(|e| {
|
||||
diagram.projection.includes(e.kind())
|
||||
&& diagram.projection.state_machine().is_none_or(|machine| {
|
||||
oa_core::projection::state_machine_contains(p, machine, *id)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
fn automatic_result_view(p: &Project, targets: &[Id], layer: Layer) -> Option<Id> {
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer && result_view_compatible(p, d, targets, layer))
|
||||
.filter_map(|d| {
|
||||
Some((
|
||||
match d.projection {
|
||||
ProjectionKind::Allocation => 0,
|
||||
ProjectionKind::Overview => 1,
|
||||
ProjectionKind::Decomposition => 2,
|
||||
_ => return None,
|
||||
},
|
||||
d.id,
|
||||
))
|
||||
})
|
||||
.min()
|
||||
.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
fn place_transition_results(
|
||||
before: &Project,
|
||||
candidate: &mut Project,
|
||||
diagram: Id,
|
||||
targets: &[Id],
|
||||
) {
|
||||
// Keep every accepted placement and route. Only newly introduced placements
|
||||
// receive space below the accepted projected bounds, including tall/nested nodes.
|
||||
let accepted_nodes = before.diagrams.get(&diagram).map(|d| &d.nodes);
|
||||
let mut occupied = candidate.diagrams[&diagram].clone();
|
||||
occupied
|
||||
.nodes
|
||||
.retain(|id, _| accepted_nodes.is_some_and(|nodes| nodes.contains_key(id)));
|
||||
let projected = oa_core::projection::project_diagram(candidate, &occupied);
|
||||
let mut bottom = projected
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| n.position.y + n.size.height)
|
||||
.chain(
|
||||
occupied
|
||||
.nodes
|
||||
.values()
|
||||
.map(|n| n.position.y + n.size.height),
|
||||
)
|
||||
.fold(20.0_f32, f32::max)
|
||||
+ 40.;
|
||||
for target in targets {
|
||||
if candidate.diagrams[&diagram].nodes.values().any(|n| {
|
||||
n.element == *target && accepted_nodes.is_some_and(|nodes| nodes.contains_key(&n.id))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let node = candidate.place(diagram, *target, Position { x: 60., y: bottom });
|
||||
let placement = candidate
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap();
|
||||
placement.position = Position { x: 60., y: bottom };
|
||||
bottom += placement.size.height + 40.;
|
||||
}
|
||||
}
|
||||
impl Workflow {
|
||||
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
|
||||
if p.layer_of(selected).is_none()
|
||||
@@ -458,7 +614,34 @@ impl Workflow {
|
||||
_ => {}
|
||||
},
|
||||
Self::Transition(d) => {
|
||||
match &event {
|
||||
Event::OperationOmission(source, enabled) => {
|
||||
d.request
|
||||
.operation_omissions
|
||||
.retain(|v| v.source != *source);
|
||||
if *enabled {
|
||||
d.request.operation_omissions.push(OperationOmission {
|
||||
source: *source,
|
||||
rationale: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::OperationRationale(source, raw) => {
|
||||
if let Some(decision) = d
|
||||
.request
|
||||
.operation_omissions
|
||||
.iter_mut()
|
||||
.find(|v| v.source == *source)
|
||||
{
|
||||
decision.rationale = raw.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match event {
|
||||
Event::Destination(choice) => {
|
||||
d.request.destination_diagram = (!choice.id.is_nil()).then_some(choice.id);
|
||||
}
|
||||
Event::CommunicationPort(exchange, source, value) => {
|
||||
d.choose_communication_port(p, exchange, source, value)?;
|
||||
}
|
||||
@@ -580,29 +763,27 @@ impl Workflow {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
let layer = d.request.direction.target_layer();
|
||||
let diagram = d
|
||||
.request
|
||||
.destination_diagram
|
||||
.or_else(|| {
|
||||
candidate
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == layer)
|
||||
.map(|d| d.id)
|
||||
})
|
||||
.unwrap_or_else(|| candidate.ensure_diagram(layer));
|
||||
for change in &d.plan.changes {
|
||||
if candidate.elements.contains_key(&change.target) {
|
||||
let index = candidate.diagrams[&diagram].nodes.len();
|
||||
candidate.place(
|
||||
diagram,
|
||||
change.target,
|
||||
Position {
|
||||
x: 60. + (index % 3) as f32 * 280.,
|
||||
y: 60. + (index / 3) as f32 * 150.,
|
||||
},
|
||||
);
|
||||
let targets: Vec<_> = d
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.target)
|
||||
.filter(|id| candidate.elements.contains_key(id))
|
||||
.collect();
|
||||
let diagram = if let Some(id) = d.request.destination_diagram {
|
||||
let view = candidate.diagrams.get(&id).ok_or("The chosen result diagram no longer exists. Choose another result diagram.")?;
|
||||
if !result_view_compatible(&candidate, view, &targets, layer) {
|
||||
return Err("The chosen result diagram cannot show every transitioned element. Choose a compatible result diagram.".into());
|
||||
}
|
||||
id
|
||||
} else {
|
||||
automatic_result_view(&candidate, &targets, layer)
|
||||
.unwrap_or_else(|| candidate.ensure_diagram(layer))
|
||||
};
|
||||
// Explicit behavior destinations retain the core plan's
|
||||
// placements; free-space placement is an architecture aid.
|
||||
if !d.automatic_behavior(p) {
|
||||
place_transition_results(p, &mut candidate, diagram, &targets);
|
||||
}
|
||||
return execute_batch(&candidate, &[])
|
||||
.map(Some)
|
||||
@@ -671,6 +852,9 @@ impl Workflow {
|
||||
let kinds = RelationKind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| {
|
||||
if *kind == RelationKind::ContextualLink {
|
||||
return false;
|
||||
}
|
||||
*kind == RelationKind::ExchangeAllocation
|
||||
|| Kind::ALL.into_iter().any(|source| {
|
||||
source.supports(d.layer)
|
||||
@@ -827,7 +1011,11 @@ impl Workflow {
|
||||
}
|
||||
}
|
||||
Self::Transition(d) => {
|
||||
content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED));
|
||||
content=content.push(text(if d.new_counterpart(p) {
|
||||
"Create a linked counterpart in the other layer. The source element stays in its current layer."
|
||||
} else {
|
||||
"Review names and descriptions of existing traced counterparts. For structural changes, use Review counterpart changes…"
|
||||
}).size(12).color(style::MUTED));
|
||||
let scope = match d.request.scope {
|
||||
DependencyScope::SelectedOnly => "Selected only",
|
||||
DependencyScope::Required => "Required references",
|
||||
@@ -856,6 +1044,63 @@ impl Workflow {
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
let (presentation, targets) = d.presentation(p);
|
||||
let target_layer = d.request.direction.target_layer();
|
||||
let automatic = if d.automatic_behavior(p) {
|
||||
"Automatic · open the counterpart in Behavior".into()
|
||||
} else if let Some(id) =
|
||||
automatic_result_view(&presentation, &targets, target_layer)
|
||||
{
|
||||
format!("Automatic · {}", p.named(id))
|
||||
} else {
|
||||
format!("Automatic · create {target_layer} overview")
|
||||
};
|
||||
let mut diagrams = vec![Choice {
|
||||
id: Id::nil(),
|
||||
label: automatic,
|
||||
}];
|
||||
diagrams.extend(
|
||||
presentation
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|view| {
|
||||
result_view_compatible(&presentation, view, &targets, target_layer)
|
||||
})
|
||||
.map(|view| Choice {
|
||||
id: view.id,
|
||||
label: format!("{} · {}", view.name, view.projection),
|
||||
}),
|
||||
);
|
||||
let selected = d.request.destination_diagram.unwrap_or(Id::nil());
|
||||
if !diagrams.iter().any(|choice| choice.id == selected) {
|
||||
diagrams.push(Choice {
|
||||
id: selected,
|
||||
label: format!("Unavailable result diagram · {}", p.named(selected)),
|
||||
});
|
||||
}
|
||||
let selected = diagrams
|
||||
.iter()
|
||||
.find(|choice| choice.id == selected)
|
||||
.cloned();
|
||||
content = content.push(
|
||||
column![
|
||||
text("Show result in").size(12),
|
||||
pick_list(diagrams, selected, |choice| Message::Workflow(
|
||||
Event::Destination(choice)
|
||||
))
|
||||
.width(Fill)
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
if let Some(error) = d.destination_error(p) {
|
||||
content = content.push(
|
||||
text(error)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
let sources = d
|
||||
.request
|
||||
.sources
|
||||
@@ -865,6 +1110,38 @@ impl Workflow {
|
||||
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
|
||||
.collect::<BTreeSet<_>>();
|
||||
for source in sources {
|
||||
if d.request.direction == TransitionDirection::SaToOa
|
||||
&& matches!(
|
||||
p.elements.get(&source).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
let omission = d
|
||||
.request
|
||||
.operation_omissions
|
||||
.iter()
|
||||
.find(|v| v.source == source);
|
||||
content=content.push(text(format!("Technical operation · {}",p.named(source))).size(13))
|
||||
.push(checkbox(omission.is_some()).label("Explicitly omit the interface operation in the OA counterpart").on_toggle(move|enabled|Message::Workflow(Event::OperationOmission(source,enabled))));
|
||||
if let Some(omission) = omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", &omission.rationale)
|
||||
.on_input(move |raw| {
|
||||
Message::Workflow(Event::OperationRationale(source, raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
|
||||
}
|
||||
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
||||
let existing = p
|
||||
.traces
|
||||
@@ -947,6 +1224,11 @@ impl Workflow {
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
if matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||
&& d.request.direction == TransitionDirection::OaToSa
|
||||
{
|
||||
item = item.push(text("System is the system of interest being designed. System actor remains outside that boundary. Choose the intended role; it is never inferred from the name.").size(11));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ports) = d.communication_view(p, source) {
|
||||
@@ -995,7 +1277,7 @@ impl Workflow {
|
||||
fields.sort_by_key(|(source, field)| (*source, *field as u8));
|
||||
let mut general_conflicts = column![].spacing(8);
|
||||
for conflict in &d.plan.conflicts {
|
||||
if conflict.field.is_none() {
|
||||
if conflict.field.is_none() && !d.boundary_choice(p, conflict) {
|
||||
general_conflicts = general_conflicts.push(
|
||||
text(&conflict.message)
|
||||
.size(12)
|
||||
@@ -1128,9 +1410,10 @@ impl Workflow {
|
||||
}
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} changes · {} unresolved conflicts · {} modeling warnings",
|
||||
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
|
||||
d.plan.changes.len(),
|
||||
d.plan.conflicts.len(),
|
||||
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
|
||||
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
|
||||
d.plan.warnings.len()
|
||||
))
|
||||
.size(12),
|
||||
@@ -1142,7 +1425,7 @@ impl Workflow {
|
||||
Self::Ports(_) => false,
|
||||
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
|
||||
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
|
||||
Self::Transition(d) => d.plan.conflicts.is_empty(),
|
||||
Self::Transition(d) => d.plan.conflicts.is_empty() && d.destination_error(p).is_none(),
|
||||
};
|
||||
if let Some(Err(error)) = relation_plan {
|
||||
content = content.push(
|
||||
@@ -1163,9 +1446,18 @@ impl Workflow {
|
||||
.padding(10)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(Event::Cancel)),
|
||||
button(text("Apply reviewed change").size(12))
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
button(
|
||||
text(
|
||||
if matches!(self, Self::Transition(d) if d.new_counterpart(p)) {
|
||||
"Create counterpart"
|
||||
} else {
|
||||
"Apply reviewed change"
|
||||
}
|
||||
)
|
||||
.size(12)
|
||||
)
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
]
|
||||
.spacing(10)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
use super::*;
|
||||
|
||||
fn view(p: &mut Project, number: u128, projection: ProjectionKind) -> Id {
|
||||
let id = Id::from_u128(number);
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{number}"),
|
||||
name: format!("View {number}"),
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Water service", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Existing behavior", None)
|
||||
.unwrap();
|
||||
let state = view(&mut p, 1, ProjectionKind::StateMachine { machine });
|
||||
p.place(state, machine, Position { x: 60., y: 60. });
|
||||
(p, source, state)
|
||||
}
|
||||
fn choose_system(p: &Project, source: Id) -> Workflow {
|
||||
let mut w = Workflow::transition(p, source).unwrap();
|
||||
w.update(p, Event::MappingKind(source, Kind::System))
|
||||
.unwrap();
|
||||
w
|
||||
}
|
||||
fn target(w: &Workflow, source: Id) -> Id {
|
||||
let Workflow::Transition(t) = w else { panic!() };
|
||||
t.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == source)
|
||||
.unwrap()
|
||||
.target
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_skips_lowest_uuid_state_view_and_preserves_oa() {
|
||||
let (mut p, source, state) = fixture();
|
||||
let architecture = view(&mut p, 20, ProjectionKind::Allocation);
|
||||
let mut w = choose_system(&p, source);
|
||||
let result = target(&w, source);
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.elements[&source], p.elements[&source]);
|
||||
assert_eq!(accepted.diagrams[&state], p.diagrams[&state]);
|
||||
assert!(
|
||||
accepted.diagrams[&architecture]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == result)
|
||||
);
|
||||
assert!(
|
||||
accepted
|
||||
.traces
|
||||
.values()
|
||||
.any(|t| t.oa == source && t.sa == result)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_creates_overview_when_existing_views_cannot_show_result() {
|
||||
let (mut p, source, state) = fixture();
|
||||
let dataflow = view(&mut p, 2, ProjectionKind::FunctionalDataflow);
|
||||
let mut w = choose_system(&p, source);
|
||||
let result = target(&w, source);
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.diagrams[&state], p.diagrams[&state]);
|
||||
assert_eq!(accepted.diagrams[&dataflow], p.diagrams[&dataflow]);
|
||||
let added: Vec<_> = accepted
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| !p.diagrams.contains_key(&d.id))
|
||||
.collect();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0].projection, ProjectionKind::Overview);
|
||||
assert!(added[0].nodes.values().any(|n| n.element == result));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_explicit_destination_appends_below_existing_bounds_and_is_one_source_edit() {
|
||||
let (mut p, source, _) = fixture();
|
||||
let automatic = view(&mut p, 20, ProjectionKind::Allocation);
|
||||
let explicit = view(&mut p, 30, ProjectionKind::Overview);
|
||||
let old = p
|
||||
.add_element(
|
||||
Kind::SystemActor,
|
||||
Layer::Sa,
|
||||
"Existing external operator",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let node = p.place(explicit, old, Position { x: 60., y: 600. });
|
||||
p.diagrams
|
||||
.get_mut(&explicit)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.size
|
||||
.height = 450.;
|
||||
let mut w = choose_system(&p, source);
|
||||
w.update(
|
||||
&p,
|
||||
Event::Destination(Choice {
|
||||
id: explicit,
|
||||
label: "Chosen overview".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let result = target(&w, source);
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.diagrams[&automatic], p.diagrams[&automatic]);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&explicit].nodes[&node],
|
||||
p.diagrams[&explicit].nodes[&node]
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&explicit].routes,
|
||||
p.diagrams[&explicit].routes
|
||||
);
|
||||
let new = accepted.diagrams[&explicit]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == result)
|
||||
.unwrap();
|
||||
assert!(
|
||||
new.position.y >= 1090.,
|
||||
"New placement overlaps the existing tall node: {new:?}"
|
||||
);
|
||||
let mut document =
|
||||
crate::document::Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let before = document.bundle().clone();
|
||||
document
|
||||
.execute(Command::Replace(accepted.clone()))
|
||||
.unwrap();
|
||||
let after = document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
accepted
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert!(!document.undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_invalid_explicit_destination_retains_choice_and_refuses() {
|
||||
let (p, source, state) = fixture();
|
||||
for destination in [state, Id::from_u128(999)] {
|
||||
let mut w = choose_system(&p, source);
|
||||
w.update(
|
||||
&p,
|
||||
Event::Destination(Choice {
|
||||
id: destination,
|
||||
label: "Unavailable result view".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(w.update(&p, Event::Apply).is_err());
|
||||
let restored = Workflow::from_recovery(&p, w.recovery_snapshot().unwrap()).unwrap();
|
||||
let Workflow::Transition(restored) = restored else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(restored.request.destination_diagram, Some(destination));
|
||||
let Workflow::Transition(t) = w else { panic!() };
|
||||
assert_eq!(t.request.destination_diagram, Some(destination));
|
||||
assert_eq!(t.request.mappings[0].target_kind, Some(Kind::System));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_boundary_choice_is_required_without_an_inferred_system() {
|
||||
let (p, source, _) = fixture();
|
||||
let mut w = Workflow::transition(&p, source).unwrap();
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
assert!(t.new_counterpart(&p));
|
||||
assert!(t.plan.conflicts.iter().any(|c| t.boundary_choice(&p, c)));
|
||||
assert!(t.request.mappings.is_empty());
|
||||
assert!(w.update(&p, Event::Apply).is_err());
|
||||
w.update(&p, Event::MappingKind(source, Kind::SystemActor))
|
||||
.unwrap();
|
||||
let result = target(&w, source);
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.elements[&result].kind(), Kind::SystemActor);
|
||||
assert_eq!(accepted.elements[&source], p.elements[&source]);
|
||||
assert!(!accepted.elements.values().any(|e| e.kind() == Kind::System));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_exact() {
|
||||
let (mut p, source, _) = fixture();
|
||||
view(&mut p, 20, ProjectionKind::Allocation);
|
||||
let mut first = choose_system(&p, source);
|
||||
let result = target(&first, source);
|
||||
let mut p = first.update(&p, Event::Apply).unwrap().unwrap();
|
||||
let explicit = view(&mut p, 30, ProjectionKind::Overview);
|
||||
let node = p.place(explicit, result, Position { x: 820., y: 912. });
|
||||
p.diagrams
|
||||
.get_mut(&explicit)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let trace = p
|
||||
.traces
|
||||
.values()
|
||||
.find(|t| t.oa == source && t.sa == result)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&explicit)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace,
|
||||
waypoints: vec![Position { x: 301., y: 507. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: Position { x: 19., y: -14. },
|
||||
}),
|
||||
});
|
||||
let mut w = Workflow::transition(&p, source).unwrap();
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
assert!(!t.new_counterpart(&p));
|
||||
w.update(
|
||||
&p,
|
||||
Event::Destination(Choice {
|
||||
id: explicit,
|
||||
label: "Existing chosen view".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.diagrams, p.diagrams);
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ pub mod error;
|
||||
mod routes;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod preparation_http_tests;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{DefaultBodyLimit, Request, State},
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use crate::{
|
||||
app,
|
||||
store::{Store, StoredProject},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{sync::oneshot, time::timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_source_http_rechecks_revision_without_holding_catalog() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let store = Arc::new(
|
||||
tokio::task::spawn_blocking(move || Store::open(path))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}/api/v1", listener.local_addr().unwrap());
|
||||
let (stop, done) = oneshot::channel();
|
||||
let router = app(store.clone(), None);
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let client = Client::new();
|
||||
let created = client
|
||||
.post(format!("{base}/projects"))
|
||||
.json(&json!({"name":"Initial review"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let etag = created.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let summary: Value = created.json().await.unwrap();
|
||||
let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let project_url = format!("{base}/projects/{id}");
|
||||
let initial: StoredProject = client
|
||||
.get(&project_url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let (entered, release) = store.pause_next_replacement(id, etag.clone());
|
||||
// Real parsing, legacy admission and source preparation all run. Runtime
|
||||
// cost is irrelevant: the gate proves the exact pre-commit phase instead.
|
||||
let source = "arcadia 2; project prepared \"Prepared stale head\" { oa_activity root \"Root\" {} oa_activity child \"Child\" { parent @root; } }";
|
||||
let proposed_client = client.clone();
|
||||
let proposed_url = format!("{project_url}/source");
|
||||
let proposed_etag = etag.clone();
|
||||
let proposed = tokio::spawn(async move {
|
||||
proposed_client
|
||||
.put(proposed_url)
|
||||
.header("if-match", proposed_etag)
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
let evidence = timeout(Duration::from_secs(10), entered)
|
||||
.await
|
||||
.expect("source did not reach validated pre-commit phase")
|
||||
.unwrap();
|
||||
assert_eq!(evidence.model.name, "Prepared stale head");
|
||||
assert_eq!(evidence.model.elements.len(), 2);
|
||||
let compiled =
|
||||
oa_core::source_project::compile_bundle(&evidence.bundle, crate::store::SOURCE_LIMITS)
|
||||
.unwrap();
|
||||
assert_eq!(compiled.decoded.project, evidence.model);
|
||||
assert!(
|
||||
!proposed.is_finished(),
|
||||
"prepared request must still overlap the winner"
|
||||
);
|
||||
let catalog = timeout(
|
||||
Duration::from_secs(2),
|
||||
client.get(format!("{base}/projects")).send(),
|
||||
)
|
||||
.await
|
||||
.expect("catalog locked during admitted candidate lifetime")
|
||||
.unwrap();
|
||||
assert_eq!(catalog.status(), StatusCode::OK);
|
||||
let winner = timeout(
|
||||
Duration::from_secs(2),
|
||||
client
|
||||
.patch(&project_url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"name":"Concurrent accepted review"}))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.expect("prepared worker held the commit lock")
|
||||
.unwrap();
|
||||
assert_eq!(winner.status(), StatusCode::OK);
|
||||
let winner_etag = winner.headers()["etag"].to_str().unwrap().to_owned();
|
||||
assert!(
|
||||
!proposed.is_finished(),
|
||||
"stale candidate must await explicit release"
|
||||
);
|
||||
release.release();
|
||||
let loser = timeout(Duration::from_secs(10), proposed)
|
||||
.await
|
||||
.expect("released candidate did not finish")
|
||||
.unwrap();
|
||||
assert_eq!(loser.status(), StatusCode::PRECONDITION_FAILED);
|
||||
let failure: Value = loser.json().await.unwrap();
|
||||
assert_eq!(failure["code"], "revision_conflict");
|
||||
assert_eq!(failure["current_revision"], 2);
|
||||
let committed_response = client.get(&project_url).send().await.unwrap();
|
||||
assert_eq!(
|
||||
committed_response.headers()["etag"].to_str().unwrap(),
|
||||
winner_etag
|
||||
);
|
||||
let committed: StoredProject = committed_response
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected_model: Project = initial.model;
|
||||
expected_model.name = "Concurrent accepted review".into();
|
||||
assert_eq!(committed.model, expected_model);
|
||||
assert_eq!(committed.revision, 2);
|
||||
assert!(committed.model.elements.is_empty());
|
||||
let installed: StoredProject = serde_json::from_slice(
|
||||
&std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(installed.model, committed.model);
|
||||
assert_eq!(installed.source_project, committed.source_project);
|
||||
assert_eq!(installed.revision, 2);
|
||||
assert_eq!(installed.resource_id, committed.resource_id);
|
||||
assert_eq!(installed.migration, committed.migration);
|
||||
let _ = stop.send(());
|
||||
drop(client);
|
||||
timeout(Duration::from_secs(5), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user