Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "oa-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,710 @@
|
||||
use crate::*;
|
||||
use std::collections::BTreeMap;
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, name, parent).unwrap()
|
||||
}
|
||||
fn occurrence(
|
||||
p: &mut Project,
|
||||
owner: Id,
|
||||
layer: Layer,
|
||||
name: &str,
|
||||
exchange: Id,
|
||||
sender: Option<Id>,
|
||||
receiver: Option<Id>,
|
||||
) -> Id {
|
||||
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender,
|
||||
receiver,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(owner));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
direction,
|
||||
interface: Some(interface),
|
||||
};
|
||||
id
|
||||
}
|
||||
fn diagram(
|
||||
p: &mut Project,
|
||||
layer: Layer,
|
||||
projection: ProjectionKind,
|
||||
name: &str,
|
||||
elements: &[Id],
|
||||
) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let alias = p.fresh_alias(name, "diagram", id);
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias,
|
||||
name: name.into(),
|
||||
description: "A focused projection of the shared engineering model.".into(),
|
||||
layer,
|
||||
projection,
|
||||
nodes: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
for (index, e) in elements.iter().enumerate() {
|
||||
p.place(
|
||||
id,
|
||||
*e,
|
||||
Position {
|
||||
x: 60. + (index % 4) as f32 * 280.,
|
||||
y: 70. + (index / 4) as f32 * 170.,
|
||||
},
|
||||
);
|
||||
}
|
||||
id
|
||||
}
|
||||
fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
|
||||
let id = Id::new_v4();
|
||||
let name = format!("{} ↔ {}", p.named(oa), p.named(sa));
|
||||
let alias = p.fresh_alias(&format!("trace_{}", p.named(oa)), "trace", id);
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description: "Reviewed OA/SA correspondence in the coastal rescue example.".into(),
|
||||
oa,
|
||||
sa,
|
||||
kind,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn coastal_rescue() -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.name = "Coastal rescue — operational need to system behavior".into();
|
||||
p.description="A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.".into();
|
||||
let coordination = add(&mut p, Kind::Entity, Layer::Oa, "Rescue coordination", None);
|
||||
let vessel = add(&mut p, Kind::Actor, Layer::Oa, "Vessel in distress", None);
|
||||
let role = add(&mut p, Kind::Role, Layer::Oa, "Incident coordinator", None);
|
||||
let oa_parent = add(
|
||||
&mut p,
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Respond to distress",
|
||||
None,
|
||||
);
|
||||
let report = add(
|
||||
&mut p,
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Report distress",
|
||||
Some(oa_parent),
|
||||
);
|
||||
let assess = add(
|
||||
&mut p,
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Assess incident",
|
||||
Some(oa_parent),
|
||||
);
|
||||
let oa_cap = add(
|
||||
&mut p,
|
||||
Kind::Capability,
|
||||
Layer::Oa,
|
||||
"Protect life at sea",
|
||||
None,
|
||||
);
|
||||
let process = add(
|
||||
&mut p,
|
||||
Kind::Process,
|
||||
Layer::Oa,
|
||||
"Distress to response",
|
||||
None,
|
||||
);
|
||||
let oa_item = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
Layer::Oa,
|
||||
"Distress report",
|
||||
None,
|
||||
);
|
||||
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Assesses incident",
|
||||
coordination,
|
||||
assess,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::RoleRealization,
|
||||
"Fulfils coordinator role",
|
||||
coordination,
|
||||
role,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::RoleAssignment,
|
||||
"Coordinates assessment",
|
||||
role,
|
||||
assess,
|
||||
);
|
||||
let interaction = p.relate(RelationKind::Interaction, "Distress signal", report, assess);
|
||||
if let RelationBody::Interaction { items, .. } =
|
||||
&mut p.relations.get_mut(&interaction).unwrap().body
|
||||
{
|
||||
items.push(oa_item);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::CapabilityInvolvement,
|
||||
"Operational contribution",
|
||||
assess,
|
||||
oa_cap,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Operational realization",
|
||||
process,
|
||||
oa_cap,
|
||||
);
|
||||
let oa_occurrence = occurrence(
|
||||
&mut p,
|
||||
process,
|
||||
Layer::Oa,
|
||||
"Distress received",
|
||||
interaction,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let system = add(
|
||||
&mut p,
|
||||
Kind::System,
|
||||
Layer::Sa,
|
||||
"Rescue coordination system",
|
||||
None,
|
||||
);
|
||||
let actor = add(
|
||||
&mut p,
|
||||
Kind::SystemActor,
|
||||
Layer::Sa,
|
||||
"Vessel communication service",
|
||||
None,
|
||||
);
|
||||
let sa_parent = add(
|
||||
&mut p,
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
"Manage distress response",
|
||||
None,
|
||||
);
|
||||
let transmit = add(
|
||||
&mut p,
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
"Transmit distress report",
|
||||
Some(sa_parent),
|
||||
);
|
||||
let coordinate = add(
|
||||
&mut p,
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
"Classify incident and alert operator",
|
||||
Some(sa_parent),
|
||||
);
|
||||
let sa_cap = add(
|
||||
&mut p,
|
||||
Kind::SystemCapability,
|
||||
Layer::Sa,
|
||||
"Coordinate rescue response",
|
||||
None,
|
||||
);
|
||||
let chain = add(
|
||||
&mut p,
|
||||
Kind::FunctionalChain,
|
||||
Layer::Sa,
|
||||
"Digital distress handling",
|
||||
None,
|
||||
);
|
||||
let mission = add(
|
||||
&mut p,
|
||||
Kind::Mission,
|
||||
Layer::Sa,
|
||||
"Support timely rescue decisions",
|
||||
None,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Vessel transmission",
|
||||
actor,
|
||||
transmit,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"System assessment",
|
||||
system,
|
||||
coordinate,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::CapabilityInvolvement,
|
||||
"System capability support",
|
||||
coordinate,
|
||||
sa_cap,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Functional chain support",
|
||||
chain,
|
||||
sa_cap,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::MissionCapability,
|
||||
"Mission capability",
|
||||
mission,
|
||||
sa_cap,
|
||||
);
|
||||
let information = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
Layer::Sa,
|
||||
"Digital distress report",
|
||||
None,
|
||||
);
|
||||
let material = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
Layer::Sa,
|
||||
"Rescue supply package",
|
||||
None,
|
||||
);
|
||||
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
};
|
||||
let energy = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
Layer::Sa,
|
||||
"Backup electrical energy",
|
||||
None,
|
||||
);
|
||||
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
};
|
||||
let field = add(
|
||||
&mut p,
|
||||
Kind::DataField,
|
||||
Layer::Sa,
|
||||
"Reported latitude",
|
||||
Some(information),
|
||||
);
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Real,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: "degrees north".into(),
|
||||
};
|
||||
let interface = add(
|
||||
&mut p,
|
||||
Kind::Interface,
|
||||
Layer::Sa,
|
||||
"Distress reporting interface",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface {
|
||||
items: vec![information],
|
||||
};
|
||||
let vessel_port = port(
|
||||
&mut p,
|
||||
actor,
|
||||
"Vessel reporting output",
|
||||
PortDirection::Out,
|
||||
interface,
|
||||
);
|
||||
let system_port = port(
|
||||
&mut p,
|
||||
system,
|
||||
"System reporting input",
|
||||
PortDirection::In,
|
||||
interface,
|
||||
);
|
||||
let function_out = port(
|
||||
&mut p,
|
||||
transmit,
|
||||
"Report function output",
|
||||
PortDirection::Out,
|
||||
interface,
|
||||
);
|
||||
let function_in = port(
|
||||
&mut p,
|
||||
coordinate,
|
||||
"Assessment function input",
|
||||
PortDirection::In,
|
||||
interface,
|
||||
);
|
||||
let exchange = p.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Validated distress report",
|
||||
transmit,
|
||||
coordinate,
|
||||
);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: transmit,
|
||||
target: coordinate,
|
||||
source_port: Some(function_out),
|
||||
target_port: Some(function_in),
|
||||
items: vec![information],
|
||||
};
|
||||
let boundary = p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Distress reporting connection",
|
||||
vessel_port,
|
||||
system_port,
|
||||
);
|
||||
if let RelationBody::BoundaryExchange { items, .. } =
|
||||
&mut p.relations.get_mut(&boundary).unwrap().body
|
||||
{
|
||||
items.push(information);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::ExchangeAllocation,
|
||||
"Report carried by connection",
|
||||
boundary,
|
||||
exchange,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Reporting service provided",
|
||||
actor,
|
||||
interface,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Reporting service required",
|
||||
system,
|
||||
interface,
|
||||
);
|
||||
let sa_occurrence = occurrence(
|
||||
&mut p,
|
||||
chain,
|
||||
Layer::Sa,
|
||||
"Report handled",
|
||||
exchange,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let scenario = add(
|
||||
&mut p,
|
||||
Kind::Scenario,
|
||||
Layer::Sa,
|
||||
"Repeated distress reports",
|
||||
None,
|
||||
);
|
||||
*p.elements.get_mut(&scenario).unwrap().shared_mut().unwrap() = SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
context: Some(sa_cap),
|
||||
precondition: "A vessel has detected an emergency and the reporting service is available."
|
||||
.into(),
|
||||
postcondition: "The operator has a classified incident with the latest vessel report."
|
||||
.into(),
|
||||
steps: vec![],
|
||||
};
|
||||
let vessel_line = add(
|
||||
&mut p,
|
||||
Kind::Lifeline,
|
||||
Layer::Sa,
|
||||
"Vessel service lifeline",
|
||||
Some(scenario),
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&vessel_line)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(actor),
|
||||
};
|
||||
let system_line = add(
|
||||
&mut p,
|
||||
Kind::Lifeline,
|
||||
Layer::Sa,
|
||||
"Coordination system lifeline",
|
||||
Some(scenario),
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&system_line)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(system),
|
||||
};
|
||||
occurrence(
|
||||
&mut p,
|
||||
scenario,
|
||||
Layer::Sa,
|
||||
"Initial distress report",
|
||||
exchange,
|
||||
Some(vessel_line),
|
||||
Some(system_line),
|
||||
);
|
||||
let update = occurrence(
|
||||
&mut p,
|
||||
scenario,
|
||||
Layer::Sa,
|
||||
"Updated distress report",
|
||||
exchange,
|
||||
Some(vessel_line),
|
||||
Some(system_line),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
precondition,
|
||||
postcondition,
|
||||
..
|
||||
}) = p.elements.get_mut(&update).unwrap().shared_mut()
|
||||
{
|
||||
*precondition = "The vessel has more recent position information.".into();
|
||||
*postcondition =
|
||||
"The incident position is refreshed without creating a second incident.".into();
|
||||
}
|
||||
|
||||
let machine = add(
|
||||
&mut p,
|
||||
Kind::StateMachine,
|
||||
Layer::Sa,
|
||||
"Reporting operation modes",
|
||||
Some(system),
|
||||
);
|
||||
let region = add(
|
||||
&mut p,
|
||||
Kind::Region,
|
||||
Layer::Sa,
|
||||
"Reporting quality region",
|
||||
Some(machine),
|
||||
);
|
||||
let initial = add(
|
||||
&mut p,
|
||||
Kind::Initial,
|
||||
Layer::Sa,
|
||||
"Start reporting",
|
||||
Some(region),
|
||||
);
|
||||
let normal = add(
|
||||
&mut p,
|
||||
Kind::Mode,
|
||||
Layer::Sa,
|
||||
"Normal reporting",
|
||||
Some(region),
|
||||
);
|
||||
let degraded = add(
|
||||
&mut p,
|
||||
Kind::Mode,
|
||||
Layer::Sa,
|
||||
"Degraded reporting",
|
||||
Some(region),
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Initialize reporting",
|
||||
initial,
|
||||
normal,
|
||||
);
|
||||
let degrade = p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Signal degraded",
|
||||
normal,
|
||||
degraded,
|
||||
);
|
||||
let timeout = add(&mut p, Kind::Event, Layer::Sa, "Report timeout", None);
|
||||
*p.elements.get_mut(&timeout).unwrap().shared_mut().unwrap() = SharedBody::Event {
|
||||
body: EventBody::Time {
|
||||
kind: TimeEventKind::After,
|
||||
expression: "30 s".into(),
|
||||
},
|
||||
};
|
||||
p.relations.get_mut(°rade).unwrap().body = RelationBody::StateTransition {
|
||||
source: normal,
|
||||
target: degraded,
|
||||
transition_kind: TransitionKind::External,
|
||||
triggers: vec![timeout],
|
||||
effects: vec![],
|
||||
trigger: "report_timeout".into(),
|
||||
guard: "seconds_since_last_report > 30".into(),
|
||||
effect: "Flag the incident and request an operator review.".into(),
|
||||
};
|
||||
let restore = p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Signal restored",
|
||||
degraded,
|
||||
normal,
|
||||
);
|
||||
let report_received = add(
|
||||
&mut p,
|
||||
Kind::Event,
|
||||
Layer::Sa,
|
||||
"Validated report received",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&report_received)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Event {
|
||||
body: EventBody::Signal {
|
||||
items: vec![information],
|
||||
},
|
||||
};
|
||||
p.relations.get_mut(&restore).unwrap().body = RelationBody::StateTransition {
|
||||
source: degraded,
|
||||
target: normal,
|
||||
transition_kind: TransitionKind::External,
|
||||
triggers: vec![report_received],
|
||||
effects: vec![],
|
||||
trigger: "validated_report_received".into(),
|
||||
guard: "position_is_current".into(),
|
||||
effect: "Clear the degraded-reporting alert.".into(),
|
||||
};
|
||||
p.relate(
|
||||
RelationKind::Availability,
|
||||
"Assessment in normal mode",
|
||||
coordinate,
|
||||
normal,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::Availability,
|
||||
"Assessment in degraded mode",
|
||||
coordinate,
|
||||
degraded,
|
||||
);
|
||||
let action = p.relate(
|
||||
RelationKind::StateAction,
|
||||
"Review on degraded entry",
|
||||
degraded,
|
||||
coordinate,
|
||||
);
|
||||
p.relations.get_mut(&action).unwrap().body = RelationBody::StateAction {
|
||||
source: degraded,
|
||||
target: coordinate,
|
||||
phase: StateActionKind::Entry,
|
||||
};
|
||||
let constraint = add(
|
||||
&mut p,
|
||||
Kind::Constraint,
|
||||
Layer::Sa,
|
||||
"Response time budget",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Constraint {
|
||||
expression: "incident_alert_latency <= 120 s".into(),
|
||||
constrained: vec![coordinate, exchange],
|
||||
};
|
||||
for (owner, name, uri, revision, relation) in [
|
||||
(
|
||||
coordinate,
|
||||
"Response implementation",
|
||||
"git+https://example.org/rescue/control?path=src/response.rs",
|
||||
"design-v2",
|
||||
ArtifactRelation::Implements,
|
||||
),
|
||||
(
|
||||
system,
|
||||
"Console enclosure CAD",
|
||||
"cad://rescue-console/enclosure.step",
|
||||
"B.3",
|
||||
ArtifactRelation::References,
|
||||
),
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
let alias = p.fresh_alias(name, "artifact", id);
|
||||
p.artifact_links.insert(id,ArtifactLink{id,alias,name:name.into(),description:"Illustrative external engineering reference; opening a model does not fetch it.".into(),owner,relation,uri:uri.into(),revision:Some(revision.into()),fragment:None});
|
||||
}
|
||||
for (oa, sa, kind) in [
|
||||
(coordination, system, TraceKind::Participant),
|
||||
(vessel, actor, TraceKind::Participant),
|
||||
(oa_parent, sa_parent, TraceKind::Refinement),
|
||||
(report, transmit, TraceKind::Refinement),
|
||||
(assess, coordinate, TraceKind::Refinement),
|
||||
(oa_cap, sa_cap, TraceKind::Capability),
|
||||
(process, chain, TraceKind::Sequence),
|
||||
(oa_occurrence, sa_occurrence, TraceKind::Scenario),
|
||||
(interaction, exchange, TraceKind::Exchange),
|
||||
(oa_item, information, TraceKind::Item),
|
||||
] {
|
||||
trace(&mut p, oa, sa, kind);
|
||||
}
|
||||
let oa_view = diagram(
|
||||
&mut p,
|
||||
Layer::Oa,
|
||||
ProjectionKind::Allocation,
|
||||
"OA participants and activities",
|
||||
&[coordination, vessel, report, assess],
|
||||
);
|
||||
let sa_view = diagram(
|
||||
&mut p,
|
||||
Layer::Sa,
|
||||
ProjectionKind::Allocation,
|
||||
"SA system boundary and functions",
|
||||
&[system, actor, transmit, coordinate],
|
||||
);
|
||||
let functions = diagram(
|
||||
&mut p,
|
||||
Layer::Sa,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
"SA functional decomposition",
|
||||
&[sa_parent, transmit, coordinate],
|
||||
);
|
||||
let oa_capabilities = diagram(
|
||||
&mut p,
|
||||
Layer::Oa,
|
||||
ProjectionKind::Capabilities,
|
||||
"OA capabilities and participants",
|
||||
&[coordination, vessel, role, oa_cap],
|
||||
);
|
||||
let sa_capabilities = diagram(
|
||||
&mut p,
|
||||
Layer::Sa,
|
||||
ProjectionKind::Capabilities,
|
||||
"SA missions and capabilities",
|
||||
&[system, actor, mission, sa_cap],
|
||||
);
|
||||
let activities = diagram(
|
||||
&mut p,
|
||||
Layer::Oa,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
"OA activity dataflow",
|
||||
&[oa_parent, report, assess],
|
||||
);
|
||||
execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::Layout(oa_view),
|
||||
Command::Layout(sa_view),
|
||||
Command::Layout(functions),
|
||||
Command::Layout(oa_capabilities),
|
||||
Command::Layout(sa_capabilities),
|
||||
Command::Layout(activities),
|
||||
],
|
||||
)
|
||||
.expect("The bundled OA/SA example is structurally valid")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod persistence;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod session;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub use model::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
mod state_validation;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,529 @@
|
||||
use crate::*;
|
||||
use std::{io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FileError {
|
||||
#[error("File operation failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Invalid project JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Invalid Arcadia text: {0}")]
|
||||
Text(String),
|
||||
#[error("Invalid project: {0}")]
|
||||
Invalid(String),
|
||||
#[error(
|
||||
"This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes."
|
||||
)]
|
||||
Conflict,
|
||||
#[error("Model admission limit: {0}")]
|
||||
Limit(String),
|
||||
}
|
||||
pub fn decode(bytes: &[u8]) -> Result<Project, FileError> {
|
||||
decode_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
||||
}
|
||||
|
||||
/// Apply admission budgets before validating a candidate or its migrated form.
|
||||
pub fn decode_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<Project, FileError> {
|
||||
if bytes.len() > limits.max_source_bytes {
|
||||
return Err(FileError::Limit(format!(
|
||||
"Input exceeds {} bytes.",
|
||||
limits.max_source_bytes
|
||||
)));
|
||||
}
|
||||
let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
Some(1) => {
|
||||
let old = serde_json::from_slice(bytes)?;
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
migrate_v1_limited(old, limits).map_err(FileError::Invalid)?
|
||||
}
|
||||
Some(2) => serde_json::from_slice(bytes)?,
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1 or 2.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
|
||||
crate::language::parse_limited(text, limits).map_err(|diagnostics| {
|
||||
let detail = diagnostics
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if diagnostics.iter().any(|diagnostic| {
|
||||
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
|
||||
}) {
|
||||
FileError::Limit(detail)
|
||||
} else {
|
||||
FileError::Text(detail)
|
||||
}
|
||||
})?
|
||||
};
|
||||
limits.check_model(&p).map_err(FileError::Limit)?;
|
||||
validate_project(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
|
||||
/// coordinates survive; every newly required identity is deterministic.
|
||||
pub(crate) fn migrate_v1_limited(
|
||||
mut old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<Project, String> {
|
||||
check_v1_limits(&old, limits)?;
|
||||
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
|
||||
return Err(issue.message);
|
||||
}
|
||||
if old.aliases.is_empty() {
|
||||
old.aliases = crate::language::legacy::parse(&crate::language::legacy::format(&old))
|
||||
.map_err(|e| {
|
||||
e.into_iter()
|
||||
.map(|d| d.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})?
|
||||
.aliases;
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
p.id = old.id;
|
||||
p.name = old.name.clone();
|
||||
p.alias = "v1_project".into();
|
||||
let used = old
|
||||
.aliases
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
while used.contains(&p.alias) {
|
||||
p.alias.push('_');
|
||||
}
|
||||
for e in old.elements.values() {
|
||||
let body = match e.kind {
|
||||
v1::Kind::Entity => OaBody::Entity,
|
||||
v1::Kind::Actor => OaBody::Actor,
|
||||
v1::Kind::Activity => OaBody::Activity,
|
||||
v1::Kind::Capability => OaBody::Capability,
|
||||
v1::Kind::Process => OaBody::Process { steps: vec![] },
|
||||
};
|
||||
p.elements.insert(
|
||||
e.id,
|
||||
Element {
|
||||
id: e.id,
|
||||
alias: old.aliases[&e.id].clone(),
|
||||
name: e.name.clone(),
|
||||
description: e.description.clone(),
|
||||
parent: None,
|
||||
body: ElementBody::Oa(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
for r in old.relations.values() {
|
||||
let kind = match r.kind {
|
||||
v1::RelationKind::Allocation => RelationKind::Allocation,
|
||||
v1::RelationKind::Interaction => RelationKind::Interaction,
|
||||
v1::RelationKind::Contribution => RelationKind::CapabilityInvolvement,
|
||||
v1::RelationKind::Realization => RelationKind::ChainInvolvement,
|
||||
};
|
||||
p.relations.insert(
|
||||
r.id,
|
||||
Relation {
|
||||
id: r.id,
|
||||
alias: old.aliases[&r.id].clone(),
|
||||
name: r.name.clone(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(kind, r.source, r.target),
|
||||
},
|
||||
);
|
||||
}
|
||||
// V1 allowed aliases later reserved by V2. Preserve all valid aliases first;
|
||||
// deterministically suffix only the conflicting names in stable UUID order.
|
||||
let invalid = p
|
||||
.identities()
|
||||
.into_iter()
|
||||
.filter(|(_, alias)| !valid_alias(alias))
|
||||
.map(|(id, alias)| (id, alias.to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
for (id, alias) in invalid {
|
||||
let replacement = p.fresh_alias(&std::format!("{alias}_v1"), "legacy", id);
|
||||
if let Some(e) = p.elements.get_mut(&id) {
|
||||
e.alias = replacement;
|
||||
} else if let Some(r) = p.relations.get_mut(&id) {
|
||||
r.alias = replacement;
|
||||
}
|
||||
}
|
||||
let diagram_id = migration_id(&p, "diagram:oa");
|
||||
let diagram_alias = p.fresh_alias("OA overview", "diagram", diagram_id);
|
||||
p.diagrams.insert(
|
||||
diagram_id,
|
||||
Diagram {
|
||||
id: diagram_id,
|
||||
alias: diagram_alias,
|
||||
name: "OA overview".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
nodes: std::collections::BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
for e in old.elements.values() {
|
||||
let id = migration_id(&p, &std::format!("node:{}", e.id));
|
||||
let alias = p.fresh_alias(&std::format!("{}_view", old.aliases[&e.id]), "node", id);
|
||||
let node = NodePlacement {
|
||||
id,
|
||||
alias,
|
||||
element: e.id,
|
||||
position: e.position,
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
};
|
||||
p.diagrams
|
||||
.get_mut(&diagram_id)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, node);
|
||||
}
|
||||
for e in old
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind == v1::Kind::Process)
|
||||
{
|
||||
let mut steps = Vec::new();
|
||||
for (index, exchange) in e.steps.iter().enumerate() {
|
||||
let id = migration_id(&p, &std::format!("occurrence:{}:{index}", e.id));
|
||||
let alias = p.fresh_alias(
|
||||
&std::format!("{}_step_{}", old.aliases[&e.id], index + 1),
|
||||
"occurrence",
|
||||
id,
|
||||
);
|
||||
let name = old.relations[exchange].name.clone();
|
||||
p.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description: String::new(),
|
||||
parent: Some(e.id),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(*exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
steps.push(id);
|
||||
}
|
||||
*p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps;
|
||||
}
|
||||
if let Some(issue) = validation::validate(&p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(std::format!(
|
||||
"V1 migration cannot satisfy V2 validation: {}",
|
||||
issue.message
|
||||
));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
pub(crate) fn check_v1_limits(
|
||||
old: &v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<(), String> {
|
||||
let steps = old.elements.values().map(|e| e.steps.len()).sum::<usize>();
|
||||
let graph = old
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(old.relations.len())
|
||||
.saturating_add(steps);
|
||||
// Migration adds one diagram, a placement per old element, and one stable
|
||||
// occurrence per process step. Count those before validating the old graph.
|
||||
let records = graph.saturating_add(old.elements.len()).saturating_add(2);
|
||||
limits.check_counts(graph, records, steps)
|
||||
}
|
||||
fn migration_id(p: &Project, role: &str) -> Id {
|
||||
let mut suffix = 0;
|
||||
loop {
|
||||
let id = Id::new_v5(
|
||||
&p.id,
|
||||
std::format!("arcadia-studio:v1:{role}:{suffix}").as_bytes(),
|
||||
);
|
||||
if !p.contains(id) {
|
||||
return id;
|
||||
}
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod v1 {
|
||||
pub use crate::{Id, Position};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
pub enum Kind {
|
||||
Entity,
|
||||
Actor,
|
||||
Activity,
|
||||
Capability,
|
||||
Process,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
Allocation,
|
||||
Interaction,
|
||||
Contribution,
|
||||
Realization,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Element {
|
||||
pub id: Id,
|
||||
pub kind: Kind,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub position: Position,
|
||||
pub steps: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Relation {
|
||||
pub id: Id,
|
||||
pub kind: RelationKind,
|
||||
pub name: String,
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Project {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
pub aliases: BTreeMap<Id, String>,
|
||||
}
|
||||
impl<'de> Deserialize<'de> for Project {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Raw {
|
||||
schema_version: u32,
|
||||
id: Id,
|
||||
name: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
relations: BTreeMap<Id, Relation>,
|
||||
}
|
||||
let r = Raw::deserialize(d)?;
|
||||
Ok(Self {
|
||||
schema_version: r.schema_version,
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
elements: r.elements,
|
||||
relations: r.relations,
|
||||
aliases: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pub mod validation {
|
||||
use super::*;
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
}
|
||||
pub struct Issue {
|
||||
pub severity: Severity,
|
||||
pub element: Option<Id>,
|
||||
pub message: String,
|
||||
}
|
||||
fn issue(id: Option<Id>, message: impl Into<String>) -> Issue {
|
||||
Issue {
|
||||
severity: Severity::Error,
|
||||
element: id,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn string_ok(s: &str) -> bool {
|
||||
s.chars().all(|c| {
|
||||
matches!(c, '\t' | '\n' | '\r')
|
||||
|| matches!(c as u32,0x20..=0xd7ff|0xe000..=0xfffd|0x10000..=0x10ffff)
|
||||
})
|
||||
}
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
if p.schema_version != 1 {
|
||||
out.push(issue(None, "Unsupported schema version; expected 1."));
|
||||
}
|
||||
if !string_ok(&p.name) {
|
||||
out.push(issue(
|
||||
None,
|
||||
"Project name contains a character forbidden by XML 1.0.",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::from([p.id]);
|
||||
let mut allocated = std::collections::BTreeSet::new();
|
||||
for (id, e) in &p.elements {
|
||||
if *id != e.id || !ids.insert(e.id) {
|
||||
out.push(issue(
|
||||
Some(e.id),
|
||||
"Inconsistent or duplicate element identity.",
|
||||
));
|
||||
}
|
||||
if !string_ok(&e.name) || !string_ok(&e.description) {
|
||||
out.push(issue(
|
||||
Some(e.id),
|
||||
"Element text contains a character forbidden by XML 1.0.",
|
||||
));
|
||||
}
|
||||
if !e.position.x.is_finite()
|
||||
|| !e.position.y.is_finite()
|
||||
|| e.position.x.abs() > crate::MAX_COORDINATE
|
||||
|| e.position.y.abs() > crate::MAX_COORDINATE
|
||||
{
|
||||
out.push(issue(
|
||||
Some(e.id),
|
||||
"Coordinates must be finite and between -1000000 and 1000000.",
|
||||
));
|
||||
}
|
||||
if e.kind != Kind::Process && !e.steps.is_empty() {
|
||||
out.push(issue(Some(e.id), "Only processes can contain steps."));
|
||||
}
|
||||
for step in &e.steps {
|
||||
if !p
|
||||
.relations
|
||||
.get(step)
|
||||
.is_some_and(|r| r.kind == RelationKind::Interaction)
|
||||
{
|
||||
out.push(issue(
|
||||
Some(e.id),
|
||||
"Every process step must reference an existing interaction.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, r) in &p.relations {
|
||||
if *id != r.id || !ids.insert(r.id) {
|
||||
out.push(issue(
|
||||
Some(r.id),
|
||||
"Inconsistent or duplicate relation identity.",
|
||||
));
|
||||
}
|
||||
if !string_ok(&r.name) {
|
||||
out.push(issue(
|
||||
Some(r.id),
|
||||
"Relation text contains a character forbidden by XML 1.0.",
|
||||
));
|
||||
}
|
||||
let permitted = p
|
||||
.elements
|
||||
.get(&r.source)
|
||||
.zip(p.elements.get(&r.target))
|
||||
.is_some_and(|(s, t)| match r.kind {
|
||||
RelationKind::Allocation => {
|
||||
matches!(s.kind, Kind::Entity | Kind::Actor) && t.kind == Kind::Activity
|
||||
}
|
||||
RelationKind::Interaction => {
|
||||
s.kind == Kind::Activity && t.kind == Kind::Activity
|
||||
}
|
||||
RelationKind::Contribution => {
|
||||
s.kind == Kind::Activity && t.kind == Kind::Capability
|
||||
}
|
||||
RelationKind::Realization => {
|
||||
s.kind == Kind::Process && t.kind == Kind::Capability
|
||||
}
|
||||
});
|
||||
if !permitted || r.source == r.target {
|
||||
out.push(issue(
|
||||
Some(r.id),
|
||||
std::format!("Invalid {:?} endpoints.", r.kind),
|
||||
));
|
||||
}
|
||||
if r.kind == RelationKind::Allocation && !allocated.insert(r.target) {
|
||||
out.push(issue(
|
||||
Some(r.id),
|
||||
"An activity may have at most one direct allocation.",
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_project(p: &Project) -> Result<(), FileError> {
|
||||
if let Some(i) = validation::validate(p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(FileError::Invalid(i.message));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
decode(&std::fs::read(path)?)
|
||||
}
|
||||
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
||||
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
||||
save_checked(path, p, None).map(|_| ())
|
||||
}
|
||||
/// Return the exact bytes written. When supplied, `expected` is the last read or
|
||||
/// saved file content; an external change/deletion is rejected before replacement.
|
||||
/// This detects ordinary editor/Git conflicts; it is not a filesystem CAS lock.
|
||||
pub fn save_checked(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
validate_project(p)?;
|
||||
let mut bytes = if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
crate::language::format(p).into_bytes()
|
||||
} else {
|
||||
serde_json::to_vec_pretty(p)?
|
||||
};
|
||||
if !bytes.ends_with(b"\n") {
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
temp.write_all(&bytes)?;
|
||||
temp.as_file().sync_all()?;
|
||||
if let Some(expected) = expected {
|
||||
match std::fs::read(path) {
|
||||
Ok(current) if current == expected => {}
|
||||
Ok(_) => return Err(FileError::Conflict),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
|
||||
Err(e) => return Err(FileError::Io(e)),
|
||||
}
|
||||
}
|
||||
temp.persist(path).map_err(|e| FileError::Io(e.error))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectedNode {
|
||||
pub id: Id,
|
||||
pub element: Id,
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
pub visual_parent: Option<Id>,
|
||||
pub depth: usize,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectedEdge {
|
||||
pub id: Id,
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub trace: bool,
|
||||
pub source_port: Option<Id>,
|
||||
pub target_port: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectedPort {
|
||||
pub element: Id,
|
||||
/// Placement identity of the owning visible function/participant.
|
||||
pub owner: Id,
|
||||
pub position: Position,
|
||||
pub direction: PortDirection,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ProjectedView {
|
||||
pub nodes: Vec<ProjectedNode>,
|
||||
pub edges: Vec<ProjectedEdge>,
|
||||
pub ports: Vec<ProjectedPort>,
|
||||
}
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0);
|
||||
if count > 0 {
|
||||
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
||||
let placed: BTreeMap<_, _> = d
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
p.elements
|
||||
.get(&n.element)
|
||||
.is_some_and(|e| e.kind() != Kind::Port && d.projection.includes(e.kind()))
|
||||
})
|
||||
.map(|n| (n.element, n.id))
|
||||
.collect();
|
||||
d.nodes
|
||||
.values()
|
||||
.filter(|n| placed.contains_key(&n.element))
|
||||
.map(|n| {
|
||||
let mut semantic = p.elements.get(&n.element).and_then(|e| e.parent);
|
||||
if d.projection == ProjectionKind::Allocation
|
||||
&& let Some(performer) = crate::validation::allocation_summary(p, n.element)
|
||||
{
|
||||
let keep_parent = semantic.is_some_and(|parent| {
|
||||
crate::validation::allocation_summary(p, parent) == Some(performer)
|
||||
});
|
||||
if !keep_parent {
|
||||
semantic = Some(performer);
|
||||
}
|
||||
}
|
||||
if d.projection == ProjectionKind::Traceability {
|
||||
return (n.id, None);
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut parent = None;
|
||||
while let Some(id) = semantic {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
if let Some(node) = placed.get(&id) {
|
||||
parent = Some(*node);
|
||||
break;
|
||||
}
|
||||
semantic = p.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
(n.id, parent)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec<Id> {
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
return vec![];
|
||||
};
|
||||
let parents = parents(p, d);
|
||||
parents
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|candidate| {
|
||||
let mut current = parents.get(candidate).copied().flatten();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if id == node {
|
||||
return true;
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
false
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// One projection drives drawing, hit testing, Fit, SVG, and grouped movement.
|
||||
pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
return ProjectedView::default();
|
||||
};
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
let Some(e) = p.elements.get(&n.element) else {
|
||||
continue;
|
||||
};
|
||||
if e.kind() == Kind::Port || !d.projection.includes(e.kind()) {
|
||||
continue;
|
||||
}
|
||||
if d.projection != ProjectionKind::Traceability && e.layer() != d.layer {
|
||||
continue;
|
||||
}
|
||||
let mut current = parents.get(&n.id).copied().flatten();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut depth = 0;
|
||||
let mut hidden = false;
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
if d.nodes.get(&id).is_some_and(|n| n.collapsed) {
|
||||
hidden = true;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
if !hidden {
|
||||
let size = minimum_node_size(p, e.id, n.size);
|
||||
nodes.push(ProjectedNode {
|
||||
id: n.id,
|
||||
element: n.element,
|
||||
position: n.position,
|
||||
size,
|
||||
visual_parent: parents.get(&n.id).copied().flatten(),
|
||||
depth,
|
||||
collapsed: n.collapsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
nodes.sort_by_key(|n| (n.depth, n.id));
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
if let Some(parent) = child
|
||||
.visual_parent
|
||||
.and_then(|id| nodes.iter().position(|n| n.id == id))
|
||||
{
|
||||
let p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(p.position.y + p.size.height).max(child.position.y + child.size.height + 24.);
|
||||
p.position.x = p.position.x.min(child.position.x - 24.);
|
||||
// Reserve two readable header lines above the first nested card.
|
||||
p.position.y = p.position.y.min(child.position.y - 64.);
|
||||
p.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect();
|
||||
let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect();
|
||||
let anchor = |element: Id| {
|
||||
let owner = p
|
||||
.elements
|
||||
.get(&element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
.unwrap_or(element);
|
||||
let mut id = placed.get(&owner).copied();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(n) = id {
|
||||
if visible.contains(&n) {
|
||||
return Some(n);
|
||||
}
|
||||
if !seen.insert(n) {
|
||||
break;
|
||||
}
|
||||
id = parents.get(&n).copied().flatten();
|
||||
}
|
||||
None
|
||||
};
|
||||
let mut ports = Vec::new();
|
||||
for node in &nodes {
|
||||
let mut children: Vec<_> = p
|
||||
.children(node.element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
children.sort_by_key(|e| (&e.alias, e.id));
|
||||
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
|
||||
let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect();
|
||||
for (index, port) in side.iter().enumerate() {
|
||||
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
||||
let position = match direction {
|
||||
PortDirection::In => Position {
|
||||
x: node.position.x,
|
||||
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
|
||||
},
|
||||
PortDirection::Out => Position {
|
||||
x: node.position.x + node.size.width,
|
||||
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
|
||||
},
|
||||
PortDirection::InOut => Position {
|
||||
x: node.position.x + node.size.width * fraction,
|
||||
y: node.position.y + node.size.height,
|
||||
},
|
||||
};
|
||||
ports.push(ProjectedPort {
|
||||
element: port.id,
|
||||
owner: node.id,
|
||||
position,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut edges = Vec::new();
|
||||
for r in p.relations.values() {
|
||||
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
|
||||
continue;
|
||||
}
|
||||
let included = match d.projection {
|
||||
ProjectionKind::Allocation => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
),
|
||||
ProjectionKind::FunctionalDataflow => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange
|
||||
),
|
||||
ProjectionKind::Capabilities => matches!(
|
||||
r.kind(),
|
||||
RelationKind::CapabilityInclude
|
||||
| RelationKind::CapabilityExtend
|
||||
| RelationKind::CapabilityGeneralization
|
||||
| RelationKind::MissionCapability
|
||||
| RelationKind::MissionInvolvement
|
||||
| RelationKind::CapabilityInvolvement
|
||||
),
|
||||
_ => true,
|
||||
};
|
||||
if !included {
|
||||
continue;
|
||||
}
|
||||
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
|
||||
&& source != target
|
||||
{
|
||||
let (source_port, target_port) = match &r.body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => (*source_port, *target_port),
|
||||
RelationBody::BoundaryExchange { source, target, .. } => {
|
||||
(Some(*source), Some(*target))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
edges.push(ProjectedEdge {
|
||||
id: r.id,
|
||||
source,
|
||||
target,
|
||||
trace: false,
|
||||
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if d.projection == ProjectionKind::Traceability {
|
||||
for t in p.traces.values() {
|
||||
if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa))
|
||||
&& source != target
|
||||
{
|
||||
edges.push(ProjectedEdge {
|
||||
id: t.id,
|
||||
source,
|
||||
target,
|
||||
trace: true,
|
||||
source_port: None,
|
||||
target_port: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ProjectedView {
|
||||
nodes,
|
||||
edges,
|
||||
ports,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
use crate::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Every live reference in a typed element, including structural ownership.
|
||||
pub fn element_references(e: &Element) -> Vec<Id> {
|
||||
let mut copy = e.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_element_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
pub fn relation_references(r: &Relation) -> Vec<Id> {
|
||||
let mut copy = r.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_relation_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
if let Some(parent) = &mut e.parent {
|
||||
visit(parent);
|
||||
}
|
||||
match &mut e.body {
|
||||
ElementBody::Oa(OaBody::Process { steps })
|
||||
| ElementBody::Sa(SaBody::FunctionalChain { steps }) => {
|
||||
for id in steps {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Interface { items } => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Port {
|
||||
interface: Some(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Scenario { context, steps, .. } => {
|
||||
if let Some(id) = context {
|
||||
visit(id);
|
||||
}
|
||||
for id in steps {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Lifeline {
|
||||
represented: Some(id),
|
||||
} => visit(id),
|
||||
SharedBody::Occurrence { body, .. } => match body {
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} => {
|
||||
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
for value in values {
|
||||
visit(&mut value.field);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
for id in [action, lifeline].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
if let Some(id) = sequence {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Control { .. } => {}
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
if let Some(id) = scenario {
|
||||
visit(id);
|
||||
}
|
||||
for binding in bindings {
|
||||
visit(&mut binding.referenced);
|
||||
visit(&mut binding.local);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::State { state, lifeline } => {
|
||||
for id in [state, lifeline].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Fragment { branches, .. } => {
|
||||
for id in branches {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Branch { occurrences, .. } => {
|
||||
for id in occurrences {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
SharedBody::Constraint { constrained, .. } => {
|
||||
for id in constrained {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
let (mut source, mut target) = r.body.endpoints();
|
||||
visit(&mut source);
|
||||
visit(&mut target);
|
||||
r.body.set_endpoints(source, target);
|
||||
match &mut r.body {
|
||||
RelationBody::StateTransition {
|
||||
triggers, effects, ..
|
||||
} => {
|
||||
for id in triggers.iter_mut().chain(effects) {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} => {
|
||||
for id in [source_port, target_port].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
for r in p.relations.values_mut() {
|
||||
visit_relation_refs(r, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
for n in d.nodes.values_mut() {
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
a.owner = new;
|
||||
}
|
||||
}
|
||||
for t in p.traces.values_mut() {
|
||||
if t.oa == old {
|
||||
t.oa = new;
|
||||
}
|
||||
if t.sa == old {
|
||||
t.sa = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
let mut removed = BTreeSet::from([id]);
|
||||
loop {
|
||||
let before = removed.len();
|
||||
for e in p.elements.values() {
|
||||
let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t));
|
||||
if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type {
|
||||
removed.insert(e.id);
|
||||
}
|
||||
if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r))
|
||||
{
|
||||
removed.insert(e.id);
|
||||
}
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
if removed.contains(&r.source()) || removed.contains(&r.target()) {
|
||||
removed.insert(r.id);
|
||||
}
|
||||
}
|
||||
for d in p.diagrams.values() {
|
||||
for n in d.nodes.values() {
|
||||
if removed.contains(&d.id) || removed.contains(&n.element) {
|
||||
removed.insert(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in p.traces.values() {
|
||||
if removed.contains(&t.oa) || removed.contains(&t.sa) {
|
||||
removed.insert(t.id);
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values() {
|
||||
if removed.contains(&a.owner) {
|
||||
removed.insert(a.id);
|
||||
}
|
||||
}
|
||||
if removed.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn delete_cascade(p: &mut Project, id: Id) {
|
||||
let removed = deletion_impact(p, id);
|
||||
let removed_names: std::collections::BTreeMap<_, _> = removed
|
||||
.iter()
|
||||
.map(|id| (*id, p.named(*id).to_string()))
|
||||
.collect();
|
||||
p.elements.retain(|id, _| !removed.contains(id));
|
||||
p.relations.retain(|id, _| !removed.contains(id));
|
||||
p.diagrams.retain(|id, _| !removed.contains(id));
|
||||
p.traces.retain(|id, _| !removed.contains(id));
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
*v = None;
|
||||
}
|
||||
};
|
||||
for e in p.elements.values_mut() {
|
||||
if let Some(steps) = e.steps_mut() {
|
||||
steps.retain(|id| !removed.contains(id));
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Port { interface, .. } => clear(interface),
|
||||
SharedBody::Scenario { context, .. } => clear(context),
|
||||
SharedBody::Lifeline { represented } => clear(represented),
|
||||
SharedBody::Constraint { constrained, .. } => {
|
||||
constrained.retain(|id| !removed.contains(id))
|
||||
}
|
||||
SharedBody::Occurrence { body, .. } => match body {
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} => {
|
||||
clear(exchange);
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
clear(reply_to);
|
||||
values.retain(|value| !removed.contains(&value.field));
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
clear(action);
|
||||
clear(lifeline);
|
||||
}
|
||||
OccurrenceBody::SequenceReference { sequence } => clear(sequence),
|
||||
OccurrenceBody::Control { .. } => {}
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
clear(scenario);
|
||||
bindings.retain(|binding| {
|
||||
!removed.contains(&binding.referenced)
|
||||
&& !removed.contains(&binding.local)
|
||||
});
|
||||
}
|
||||
OccurrenceBody::State { state, lifeline } => {
|
||||
clear(state);
|
||||
clear(lifeline);
|
||||
}
|
||||
OccurrenceBody::Fragment { branches, .. } => {
|
||||
branches.retain(|id| !removed.contains(id))
|
||||
}
|
||||
OccurrenceBody::Branch { .. } => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in p.relations.values_mut() {
|
||||
match &mut r.body {
|
||||
RelationBody::StateTransition {
|
||||
triggers,
|
||||
effects,
|
||||
trigger,
|
||||
..
|
||||
} => {
|
||||
let deleted: Vec<_> = triggers
|
||||
.iter()
|
||||
.filter(|id| removed.contains(id))
|
||||
.filter_map(|id| removed_names.get(id).cloned())
|
||||
.collect();
|
||||
triggers.retain(|id| !removed.contains(id));
|
||||
effects.retain(|id| !removed.contains(id));
|
||||
if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() {
|
||||
*trigger = format!(
|
||||
"Removed trigger: {}. Choose a replacement trigger.",
|
||||
deleted.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. } => {
|
||||
items.retain(|id| !removed.contains(id))
|
||||
}
|
||||
RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => {
|
||||
items.retain(|id| !removed.contains(id));
|
||||
clear(source_port);
|
||||
clear(target_port);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Removing an item or a containing payload field can disconnect a still-live
|
||||
// record field from an exchange. Its contextual annotation is no longer valid.
|
||||
let allowed: std::collections::BTreeMap<_, _> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
}) => p
|
||||
.relations
|
||||
.get(id)
|
||||
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, fields) in allowed {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
|
||||
{
|
||||
values.retain(|value| fields.contains(&value.field));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
|
||||
#[error("{0}")]
|
||||
pub struct ModelError(pub String);
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Command {
|
||||
Replace(Project),
|
||||
Add(Kind),
|
||||
AddElement {
|
||||
kind: Kind,
|
||||
layer: Layer,
|
||||
name: String,
|
||||
parent: Option<Id>,
|
||||
},
|
||||
CreateElement(Element),
|
||||
EditElement(Id, Element),
|
||||
CreateRelation(Relation),
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
EditDiagram(Id, Diagram),
|
||||
CreateArtifact(ArtifactLink),
|
||||
EditArtifact(Id, ArtifactLink),
|
||||
CreateTrace(Trace),
|
||||
EditTrace(Id, Trace),
|
||||
CreateNode {
|
||||
diagram: Id,
|
||||
node: NodePlacement,
|
||||
},
|
||||
EditNode {
|
||||
diagram: Id,
|
||||
original: Id,
|
||||
node: NodePlacement,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
},
|
||||
EditProject {
|
||||
id: Id,
|
||||
alias: String,
|
||||
name: String,
|
||||
description: String,
|
||||
},
|
||||
Reparent {
|
||||
element: Id,
|
||||
parent: Option<Id>,
|
||||
},
|
||||
Reidentify(Id, Id),
|
||||
Rename(Id, String),
|
||||
Describe(Id, String),
|
||||
SetAlias(Id, String),
|
||||
Connect(RelationKind, String, Id, Id),
|
||||
Delete(Id),
|
||||
Batch(Vec<Command>),
|
||||
AddStep(Id, Id),
|
||||
RemoveStep(Id, usize),
|
||||
MoveStep(Id, usize, usize),
|
||||
SetSteps(Id, Vec<Id>),
|
||||
MoveNode {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
position: Position,
|
||||
with_descendants: bool,
|
||||
},
|
||||
Layout(Id),
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
project: Project,
|
||||
past: Vec<Project>,
|
||||
future: Vec<Project>,
|
||||
saved: Project,
|
||||
revision: u64,
|
||||
}
|
||||
impl Session {
|
||||
pub fn try_new(project: Project) -> Result<Self, ModelError> {
|
||||
validate_candidate(&project)?;
|
||||
Ok(Self {
|
||||
saved: project.clone(),
|
||||
project,
|
||||
past: vec![],
|
||||
future: vec![],
|
||||
revision: 0,
|
||||
})
|
||||
}
|
||||
pub fn new(project: Project) -> Self {
|
||||
Self::try_new(project).expect("Session requires a valid project")
|
||||
}
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.project
|
||||
}
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.project != self.saved
|
||||
}
|
||||
pub fn mark_saved(&mut self, snapshot: Project) {
|
||||
self.saved = snapshot;
|
||||
}
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.past.is_empty()
|
||||
}
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.future.is_empty()
|
||||
}
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(p) = self.past.pop() {
|
||||
self.future.push(std::mem::replace(&mut self.project, p));
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
}
|
||||
}
|
||||
pub fn redo(&mut self) {
|
||||
if let Some(p) = self.future.pop() {
|
||||
self.past.push(std::mem::replace(&mut self.project, p));
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
}
|
||||
}
|
||||
pub fn execute_many(&mut self, commands: &[Command]) -> Result<(), ModelError> {
|
||||
let candidate = execute_batch(&self.project, commands)?;
|
||||
if candidate != self.project {
|
||||
self.past
|
||||
.push(std::mem::replace(&mut self.project, candidate));
|
||||
if self.past.len() > 100 {
|
||||
self.past.remove(0);
|
||||
}
|
||||
self.future.clear();
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, ModelError> {
|
||||
let mut candidate = self.project.clone();
|
||||
let selected = apply_command(&mut candidate, &command, 0)?;
|
||||
validate_candidate(&candidate)?;
|
||||
if candidate != self.project {
|
||||
self.past
|
||||
.push(std::mem::replace(&mut self.project, candidate));
|
||||
if self.past.len() > 100 {
|
||||
self.past.remove(0);
|
||||
}
|
||||
self.future.clear();
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
}
|
||||
pub fn validate_candidate(p: &Project) -> Result<(), ModelError> {
|
||||
if let Some(i) = validation::validate(p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
{
|
||||
Err(ModelError(format!("{}: {}", i.code, i.message)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project, ModelError> {
|
||||
let mut candidate = project.clone();
|
||||
for command in commands {
|
||||
apply_command(&mut candidate, command, 0)?;
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(candidate)
|
||||
}
|
||||
fn vacant(p: &Project, id: Id) -> Result<(), ModelError> {
|
||||
if p.contains(id) {
|
||||
Err(ModelError(
|
||||
"This ID already belongs to another object.".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn missing() -> ModelError {
|
||||
ModelError("Object no longer exists.".into())
|
||||
}
|
||||
fn detach(p: &mut Project, id: Id) {
|
||||
for e in p.elements.values_mut() {
|
||||
if let Some(steps) = e.steps_mut() {
|
||||
steps.retain(|step| *step != id);
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = e.shared_mut()
|
||||
{
|
||||
branches.retain(|branch| *branch != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn attach(p: &mut Project, id: Id) {
|
||||
let Some(e) = p.elements.get(&id) else {
|
||||
return;
|
||||
};
|
||||
if e.kind() != Kind::Occurrence {
|
||||
return;
|
||||
}
|
||||
let parent = e.parent;
|
||||
let branch = matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
);
|
||||
if let Some(owner) = parent.and_then(|id| p.elements.get_mut(&id)) {
|
||||
if let Some(steps) = owner.steps_mut() {
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
} else if branch
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = owner.shared_mut()
|
||||
&& !branches.contains(&id)
|
||||
{
|
||||
branches.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result<Option<Id>, ModelError> {
|
||||
if depth > 64 {
|
||||
return Err(ModelError(
|
||||
"Command batch nesting exceeds 64 levels.".into(),
|
||||
));
|
||||
}
|
||||
let selected = match c {
|
||||
Command::Replace(value) => {
|
||||
*p = value.clone();
|
||||
None
|
||||
}
|
||||
Command::Batch(commands) => {
|
||||
let mut selected = None;
|
||||
for command in commands {
|
||||
let id = apply_command(p, command, depth + 1)?;
|
||||
if id.is_some() {
|
||||
selected = id;
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
Command::Add(kind) => {
|
||||
let layer = if kind.supports(Layer::Oa) {
|
||||
Layer::Oa
|
||||
} else {
|
||||
Layer::Sa
|
||||
};
|
||||
let name = format!("New {kind}");
|
||||
let e = p
|
||||
.make_element(*kind, layer, &name, None)
|
||||
.ok_or_else(|| ModelError("Invalid layer for kind.".into()))?;
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
let d = p.ensure_diagram(layer);
|
||||
let slot = p.diagrams[&d].nodes.len();
|
||||
p.place(
|
||||
d,
|
||||
id,
|
||||
Position {
|
||||
x: 70. + (slot % 4) as f32 * 270.,
|
||||
y: 70. + (slot / 4) as f32 * 150.,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
Command::AddElement {
|
||||
kind,
|
||||
layer,
|
||||
name,
|
||||
parent,
|
||||
} => {
|
||||
let e = p
|
||||
.make_element(*kind, *layer, name, *parent)
|
||||
.ok_or_else(|| ModelError("Invalid layer for kind.".into()))?;
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
attach(p, id);
|
||||
Some(id)
|
||||
}
|
||||
Command::CreateElement(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.elements.insert(value.id, value.clone());
|
||||
attach(p, value.id);
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditElement(old, value) => {
|
||||
let previous = p.elements.get(old).ok_or_else(missing)?;
|
||||
let ownership_change =
|
||||
previous.parent != value.parent || previous.kind() != value.kind();
|
||||
if ownership_change {
|
||||
detach(p, *old);
|
||||
}
|
||||
let mut e = value.clone();
|
||||
e.id = *old;
|
||||
p.elements.insert(*old, e);
|
||||
reidentify(p, *old, value.id)?;
|
||||
if ownership_change {
|
||||
attach(p, value.id);
|
||||
}
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateRelation(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.relations.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditRelation(old, value) => {
|
||||
if !p.relations.contains_key(old) {
|
||||
return Err(missing());
|
||||
}
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.relations.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateDiagram(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.diagrams.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditDiagram(old, value) => {
|
||||
if !p.diagrams.contains_key(old) {
|
||||
return Err(missing());
|
||||
}
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.diagrams.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.artifact_links.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditArtifact(old, value) => {
|
||||
if !p.artifact_links.contains_key(old) {
|
||||
return Err(missing());
|
||||
}
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.artifact_links.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateTrace(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.traces.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditTrace(old, value) => {
|
||||
if !p.traces.contains_key(old) {
|
||||
return Err(missing());
|
||||
}
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.traces.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateNode { diagram, node } => {
|
||||
vacant(p, node.id)?;
|
||||
p.diagrams
|
||||
.get_mut(diagram)
|
||||
.ok_or_else(missing)?
|
||||
.nodes
|
||||
.insert(node.id, node.clone());
|
||||
Some(node.id)
|
||||
}
|
||||
Command::EditNode {
|
||||
diagram,
|
||||
original,
|
||||
node,
|
||||
} => {
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
if !d.nodes.contains_key(original) {
|
||||
return Err(missing());
|
||||
}
|
||||
let mut n = node.clone();
|
||||
n.id = *original;
|
||||
d.nodes.insert(*original, n);
|
||||
reidentify(p, *original, node.id)?;
|
||||
Some(node.id)
|
||||
}
|
||||
Command::RelocateNode { node, diagram } => {
|
||||
if !p.diagrams.contains_key(diagram) {
|
||||
return Err(missing());
|
||||
}
|
||||
let source = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.nodes.contains_key(node))
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(missing)?;
|
||||
if source != *diagram {
|
||||
let placement = p
|
||||
.diagrams
|
||||
.get_mut(&source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(node)
|
||||
.unwrap();
|
||||
p.diagrams
|
||||
.get_mut(diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(*node, placement);
|
||||
}
|
||||
Some(*node)
|
||||
}
|
||||
Command::EditProject {
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
} => {
|
||||
reidentify(p, p.id, *id)?;
|
||||
p.alias = alias.clone();
|
||||
p.name = name.clone();
|
||||
p.description = description.clone();
|
||||
None
|
||||
}
|
||||
Command::Reparent { element, parent } => {
|
||||
let current_parent = p.elements.get(element).ok_or_else(missing)?.parent;
|
||||
if current_parent != *parent {
|
||||
detach(p, *element);
|
||||
p.elements.get_mut(element).unwrap().parent = *parent;
|
||||
attach(p, *element);
|
||||
}
|
||||
Some(*element)
|
||||
}
|
||||
Command::Reidentify(old, new) => {
|
||||
reidentify(p, *old, *new)?;
|
||||
Some(*new)
|
||||
}
|
||||
Command::Rename(id, value) => {
|
||||
edit_header(p, *id, Some(value), None, None)?;
|
||||
Some(*id)
|
||||
}
|
||||
Command::Describe(id, value) => {
|
||||
edit_header(p, *id, None, Some(value), None)?;
|
||||
Some(*id)
|
||||
}
|
||||
Command::SetAlias(id, value) => {
|
||||
edit_header(p, *id, None, None, Some(value))?;
|
||||
Some(*id)
|
||||
}
|
||||
Command::Connect(kind, name, source, target) => {
|
||||
Some(p.relate(*kind, name, *source, *target))
|
||||
}
|
||||
Command::Delete(id) => {
|
||||
if *id == p.id {
|
||||
return Err(ModelError("Use New/Replace to delete a project.".into()));
|
||||
}
|
||||
if !p.contains(*id) {
|
||||
return Err(missing());
|
||||
}
|
||||
crate::references::delete_cascade(p, *id);
|
||||
None
|
||||
}
|
||||
Command::AddStep(owner, exchange) => {
|
||||
let e = p.elements.get(owner).ok_or_else(missing)?;
|
||||
if e.steps().is_none() {
|
||||
return Err(ModelError(
|
||||
"Selected element does not own an ordered sequence.".into(),
|
||||
));
|
||||
}
|
||||
let layer = e.layer();
|
||||
let name = format!("{} occurrence", p.named(*exchange));
|
||||
let mut occ = p
|
||||
.make_element(Kind::Occurrence, layer, &name, Some(*owner))
|
||||
.unwrap();
|
||||
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(*exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let id = occ.id;
|
||||
p.elements.insert(id, occ);
|
||||
attach(p, id);
|
||||
Some(*owner)
|
||||
}
|
||||
Command::RemoveStep(owner, index) => {
|
||||
let id = *p
|
||||
.elements
|
||||
.get(owner)
|
||||
.and_then(Element::steps)
|
||||
.and_then(|s| s.get(*index))
|
||||
.ok_or_else(|| ModelError("Step index is out of range.".into()))?;
|
||||
crate::references::delete_cascade(p, id);
|
||||
Some(*owner)
|
||||
}
|
||||
Command::MoveStep(owner, from, to) => {
|
||||
let steps = p
|
||||
.elements
|
||||
.get_mut(owner)
|
||||
.and_then(Element::steps_mut)
|
||||
.ok_or_else(missing)?;
|
||||
if *from >= steps.len() || *to >= steps.len() {
|
||||
return Err(ModelError("Step index is out of range.".into()));
|
||||
}
|
||||
let step = steps.remove(*from);
|
||||
steps.insert(*to, step);
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetSteps(owner, value) => {
|
||||
*p.elements
|
||||
.get_mut(owner)
|
||||
.and_then(Element::steps_mut)
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::MoveNode {
|
||||
diagram,
|
||||
node,
|
||||
position,
|
||||
with_descendants,
|
||||
} => {
|
||||
let descendants = if *with_descendants {
|
||||
crate::projection::descendant_nodes(p, *diagram, *node)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
let n = d.nodes.get_mut(node).ok_or_else(missing)?;
|
||||
let delta = Position {
|
||||
x: position.x - n.position.x,
|
||||
y: position.y - n.position.y,
|
||||
};
|
||||
n.position = *position;
|
||||
for id in descendants {
|
||||
if let Some(child) = d.nodes.get_mut(&id) {
|
||||
child.position.x += delta.x;
|
||||
child.position.y += delta.y;
|
||||
}
|
||||
}
|
||||
Some(*node)
|
||||
}
|
||||
Command::Layout(diagram) => {
|
||||
layout(p, *diagram)?;
|
||||
Some(*diagram)
|
||||
}
|
||||
};
|
||||
Ok(selected)
|
||||
}
|
||||
fn edit_header(
|
||||
p: &mut Project,
|
||||
id: Id,
|
||||
name: Option<&String>,
|
||||
description: Option<&String>,
|
||||
alias: Option<&String>,
|
||||
) -> Result<(), ModelError> {
|
||||
let assign = |n: &mut String, d: &mut String, a: &mut String| {
|
||||
if let Some(v) = name {
|
||||
*n = v.clone();
|
||||
}
|
||||
if let Some(v) = description {
|
||||
*d = v.clone();
|
||||
}
|
||||
if let Some(v) = alias {
|
||||
*a = v.clone();
|
||||
}
|
||||
};
|
||||
if p.id == id {
|
||||
assign(&mut p.name, &mut p.description, &mut p.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.elements.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.relations.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.diagrams.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.artifact_links.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.traces.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
if let Some(n) = d.nodes.get_mut(&id) {
|
||||
if name.is_some() || description.is_some() {
|
||||
return Err(ModelError(
|
||||
"Diagram nodes have no independent name/description.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(a) = alias {
|
||||
n.alias = a.clone();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(missing())
|
||||
}
|
||||
pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
if !p.contains(old) {
|
||||
return Err(missing());
|
||||
}
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
v.id = new;
|
||||
p.elements.insert(new, v);
|
||||
} else if let Some(mut v) = p.relations.remove(&old) {
|
||||
v.id = new;
|
||||
p.relations.insert(new, v);
|
||||
} else if let Some(mut v) = p.diagrams.remove(&old) {
|
||||
v.id = new;
|
||||
p.diagrams.insert(new, v);
|
||||
} else if let Some(mut v) = p.artifact_links.remove(&old) {
|
||||
v.id = new;
|
||||
p.artifact_links.insert(new, v);
|
||||
} else if let Some(mut v) = p.traces.remove(&old) {
|
||||
v.id = new;
|
||||
p.traces.insert(new, v);
|
||||
} else {
|
||||
for d in p.diagrams.values_mut() {
|
||||
if let Some(mut n) = d.nodes.remove(&old) {
|
||||
n.id = new;
|
||||
d.nodes.insert(new, n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new);
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
|
||||
let parents = crate::projection::parents(p, d);
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for (id, parent) in &parents {
|
||||
children.entry(*parent).or_default().push(*id);
|
||||
}
|
||||
fn measure(
|
||||
p: &Project,
|
||||
id: Id,
|
||||
d: &Diagram,
|
||||
children: &BTreeMap<Option<Id>, Vec<Id>>,
|
||||
sizes: &mut BTreeMap<Id, Size>,
|
||||
seen: &mut BTreeSet<Id>,
|
||||
) -> Result<Size, ModelError> {
|
||||
if !seen.insert(id) {
|
||||
return Err(ModelError("Diagram containment cycle.".into()));
|
||||
}
|
||||
let own = crate::projection::minimum_node_size(p, d.nodes[&id].element, d.nodes[&id].size);
|
||||
let mut width = own.width;
|
||||
let mut height = 64.;
|
||||
let kids = children.get(&Some(id));
|
||||
if let Some(kids) = kids {
|
||||
for child in kids {
|
||||
let size = measure(p, *child, d, children, sizes, seen)?;
|
||||
width = width.max(size.width + 48.);
|
||||
height += size.height + 16.;
|
||||
}
|
||||
height += 8.;
|
||||
} else {
|
||||
height = own.height;
|
||||
}
|
||||
let size = Size {
|
||||
width,
|
||||
height: height.max(own.height),
|
||||
};
|
||||
sizes.insert(id, size);
|
||||
Ok(size)
|
||||
}
|
||||
fn place(
|
||||
id: Id,
|
||||
position: Position,
|
||||
children: &BTreeMap<Option<Id>, Vec<Id>>,
|
||||
sizes: &BTreeMap<Id, Size>,
|
||||
positions: &mut BTreeMap<Id, Position>,
|
||||
) {
|
||||
positions.insert(id, position);
|
||||
let mut y = position.y + 64.;
|
||||
if let Some(kids) = children.get(&Some(id)) {
|
||||
for child in kids {
|
||||
place(
|
||||
*child,
|
||||
Position {
|
||||
x: position.x + 24.,
|
||||
y,
|
||||
},
|
||||
children,
|
||||
sizes,
|
||||
positions,
|
||||
);
|
||||
y += sizes[child].height + 16.;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Orient containers by their actual exchanges. UUID ordering made otherwise
|
||||
// identical models swap source/sink sides and sent boundary ports backwards.
|
||||
let mut remaining: BTreeSet<_> = children
|
||||
.get(&None)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let root_of = |mut node| {
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(parent) = parents.get(&node).copied().flatten() {
|
||||
if !seen.insert(node) {
|
||||
break;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
node
|
||||
};
|
||||
let edges: BTreeSet<_> = crate::projection::project_view(p, diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|edge| {
|
||||
let source = root_of(edge.source);
|
||||
let target = root_of(edge.target);
|
||||
(source != target).then_some((source, target))
|
||||
})
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
while !remaining.is_empty() {
|
||||
let next = remaining
|
||||
.iter()
|
||||
.filter(|id| {
|
||||
!edges
|
||||
.iter()
|
||||
.any(|(source, target)| target == *id && remaining.contains(source))
|
||||
})
|
||||
.min_by_key(|id| (&p.elements[&d.nodes[id].element].alias, **id))
|
||||
.or_else(|| {
|
||||
remaining
|
||||
.iter()
|
||||
.min_by_key(|id| (&p.elements[&d.nodes[id].element].alias, **id))
|
||||
})
|
||||
.copied()
|
||||
.unwrap();
|
||||
remaining.remove(&next);
|
||||
roots.push(next);
|
||||
}
|
||||
let mut sizes = BTreeMap::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for id in &roots {
|
||||
measure(p, *id, d, &children, &mut sizes, &mut seen)?;
|
||||
}
|
||||
let mut positions = BTreeMap::new();
|
||||
let mut x = 60.;
|
||||
let mut y = 60.;
|
||||
let mut row_height = 0f32;
|
||||
for (index, id) in roots.iter().enumerate() {
|
||||
if index > 0 && index % 4 == 0 {
|
||||
x = 60.;
|
||||
y += row_height + 60.;
|
||||
row_height = 0.;
|
||||
}
|
||||
place(*id, Position { x, y }, &children, &sizes, &mut positions);
|
||||
x += sizes[id].width + 60.;
|
||||
row_height = row_height.max(sizes[id].height);
|
||||
}
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
for (id, position) in positions {
|
||||
let n = d.nodes.get_mut(&id).unwrap();
|
||||
n.position = position;
|
||||
n.size = sizes[&id];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Structural state-machine rules. Expressions describe intent; they are not executed.
|
||||
use crate::{
|
||||
validation::{Issue, ancestor_of_kind, error, warning},
|
||||
*,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn ancestors(p: &Project, id: Id) -> Vec<Id> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut seen = BTreeSet::from([id]);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
result.push(id);
|
||||
current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
fn within(p: &Project, id: Id, owner: Id) -> bool {
|
||||
ancestors(p, id).contains(&owner)
|
||||
}
|
||||
fn boundary(p: &Project, vertex: &Element) -> Option<Id> {
|
||||
vertex
|
||||
.parent
|
||||
.and_then(|region| p.elements.get(®ion))
|
||||
.and_then(|region| region.parent)
|
||||
}
|
||||
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
|
||||
ancestors(p, vertex).into_iter().find(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner))
|
||||
})
|
||||
}
|
||||
fn edges<'a>(
|
||||
p: &'a Project,
|
||||
candidate: Option<&'a Relation>,
|
||||
) -> impl Iterator<Item = &'a Relation> {
|
||||
p.relations
|
||||
.values()
|
||||
.filter(move |r| candidate.is_none_or(|c| c.id != r.id))
|
||||
.chain(candidate)
|
||||
.filter(|r| r.kind() == RelationKind::StateTransition)
|
||||
}
|
||||
fn guard(r: &Relation) -> &str {
|
||||
match &r.body {
|
||||
RelationBody::StateTransition { guard, .. } => guard.trim(),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
fn orthogonal(p: &Project, arms: &[Id]) -> bool {
|
||||
let Some(first) = arms.first() else {
|
||||
return true;
|
||||
};
|
||||
if arms.len() < 2 {
|
||||
return true;
|
||||
}
|
||||
let Some(owner) = ancestors(p, *first).into_iter().find(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine)
|
||||
&& arms.iter().all(|arm| within(p, *arm, *id))
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let regions: Option<BTreeSet<_>> = arms
|
||||
.iter()
|
||||
.map(|arm| first_region(p, *arm, owner))
|
||||
.collect();
|
||||
regions.is_some_and(|regions| regions.len() == arms.len())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_transition(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
a: &Element,
|
||||
b: &Element,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
trigger,
|
||||
triggers,
|
||||
guard,
|
||||
..
|
||||
} = &r.body
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let machine = ancestor_of_kind(p, a.id, Kind::StateMachine);
|
||||
if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"transition_machine",
|
||||
"endpoints",
|
||||
"State transition endpoints belong to one state machine.",
|
||||
);
|
||||
}
|
||||
if !a.kind().is_state() || !b.kind().is_state() {
|
||||
return;
|
||||
}
|
||||
if a.id == b.id && !a.kind().is_stable_state() {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"pseudostate_self",
|
||||
"endpoints",
|
||||
"Only a State or Mode can have a self-transition.",
|
||||
);
|
||||
}
|
||||
let valid_kind = match transition_kind {
|
||||
TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id,
|
||||
TransitionKind::Local if a.kind() == Kind::EntryPoint => {
|
||||
boundary(p, a).is_some_and(|owner| within(p, b.id, owner))
|
||||
}
|
||||
TransitionKind::Local => {
|
||||
a.kind().is_stable_state()
|
||||
&& p.children(a.id).any(|e| e.kind() == Kind::Region)
|
||||
&& within(p, b.id, a.id)
|
||||
}
|
||||
TransitionKind::External => a.kind() != Kind::EntryPoint,
|
||||
};
|
||||
if !valid_kind {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"transition_kind",
|
||||
"transition_kind",
|
||||
"Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.",
|
||||
);
|
||||
}
|
||||
if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"pseudostate_direction",
|
||||
"endpoints",
|
||||
"Final/Terminate have no outgoing transitions; Initial has no incoming transition.",
|
||||
);
|
||||
}
|
||||
let triggered = !trigger.trim().is_empty() || !triggers.is_empty();
|
||||
if !a.kind().is_stable_state() && triggered {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
if a.kind() == Kind::Initial {
|
||||
"initial_transition"
|
||||
} else {
|
||||
"pseudostate_trigger"
|
||||
},
|
||||
"triggers",
|
||||
"Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.",
|
||||
);
|
||||
}
|
||||
if (matches!(
|
||||
a.kind(),
|
||||
Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory
|
||||
) || b.kind() == Kind::Join)
|
||||
&& (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered))
|
||||
{
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
if a.kind() == Kind::Initial {
|
||||
"initial_transition"
|
||||
} else {
|
||||
"pseudostate_segment"
|
||||
},
|
||||
"guard",
|
||||
"Initial/history fallback and Fork output/Join input segments have no guard or trigger.",
|
||||
);
|
||||
}
|
||||
if a.kind() == Kind::Fork && !b.kind().is_stable_state()
|
||||
|| b.kind() == Kind::Join && !a.kind().is_stable_state()
|
||||
{
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"orthogonal_endpoint",
|
||||
"endpoints",
|
||||
"Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.",
|
||||
);
|
||||
}
|
||||
if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) {
|
||||
let scope = a.parent.is_some_and(|region| {
|
||||
if a.kind() == Kind::ShallowHistory {
|
||||
b.parent == Some(region)
|
||||
} else {
|
||||
within(p, b.id, region)
|
||||
}
|
||||
});
|
||||
if !b.kind().is_stable_state() || !scope {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"history_scope",
|
||||
"target",
|
||||
"Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (point, other, outgoing) in [(a, b, true), (b, a, false)] {
|
||||
if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) {
|
||||
continue;
|
||||
}
|
||||
let Some(owner) = boundary(p, point) else {
|
||||
continue;
|
||||
};
|
||||
let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing;
|
||||
if within(p, other.id, owner) != needs_inside || other.id == owner {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"boundary_transition",
|
||||
"endpoints",
|
||||
"EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_vertex(
|
||||
p: &Project,
|
||||
e: &Element,
|
||||
candidate: Option<&Relation>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if !e.kind().is_state() {
|
||||
return;
|
||||
}
|
||||
let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect();
|
||||
let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect();
|
||||
let (ins, outs) = (incoming.len(), outgoing.len());
|
||||
let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1);
|
||||
let max_out = matches!(
|
||||
e.kind(),
|
||||
Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint
|
||||
)
|
||||
.then_some(1);
|
||||
if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
if e.kind() == Kind::Initial {
|
||||
"initial_transition_count"
|
||||
} else {
|
||||
"pseudostate_cardinality"
|
||||
},
|
||||
"body",
|
||||
format!(
|
||||
"{} has too many {} transitions.",
|
||||
e.kind(),
|
||||
if max_in.is_some_and(|max| ins > max) {
|
||||
"incoming"
|
||||
} else {
|
||||
"outgoing"
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
let incomplete = match e.kind() {
|
||||
Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0,
|
||||
Kind::Final | Kind::Terminate => ins == 0,
|
||||
Kind::Choice | Kind::Fork => ins == 0 || outs < 2,
|
||||
Kind::Join => ins < 2 || outs == 0,
|
||||
Kind::Junction => ins == 0 || outs == 0,
|
||||
Kind::EntryPoint => outs == 0,
|
||||
Kind::ExitPoint => {
|
||||
ins == 0
|
||||
|| (outs == 0
|
||||
&& boundary(p, e)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| e.kind() != Kind::StateMachine))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if incomplete {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"incomplete_state_vertex",
|
||||
"body",
|
||||
format!(
|
||||
"Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.",
|
||||
e.kind()
|
||||
),
|
||||
);
|
||||
}
|
||||
if e.kind() == Kind::Choice {
|
||||
let defaults = outgoing.iter().filter(|r| guard(r) == "else").count();
|
||||
if defaults > 1 {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"choice_default",
|
||||
"guard",
|
||||
"Choice has at most one literal else branch.",
|
||||
);
|
||||
}
|
||||
if defaults == 0 {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"missing_choice_default",
|
||||
"guard",
|
||||
"Add an else branch, or review and document why no alternative may apply.",
|
||||
);
|
||||
}
|
||||
if outgoing.iter().any(|r| guard(r).is_empty()) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"missing_choice_guard",
|
||||
"guard",
|
||||
"Each Choice alternative needs an explicit guard or literal else.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"junction_guard",
|
||||
"guard",
|
||||
"Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.",
|
||||
);
|
||||
}
|
||||
if matches!(e.kind(), Kind::Fork | Kind::Join) {
|
||||
let arms: Vec<_> = if e.kind() == Kind::Fork {
|
||||
outgoing.iter().map(|r| r.target()).collect()
|
||||
} else {
|
||||
incoming.iter().map(|r| r.source()).collect()
|
||||
};
|
||||
if !orthogonal(p, &arms) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"orthogonal_regions",
|
||||
"endpoints",
|
||||
"Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if e.kind() == Kind::EntryPoint
|
||||
&& let Some(owner) = boundary(p, e)
|
||||
{
|
||||
let regions: Vec<_> = outgoing
|
||||
.iter()
|
||||
.filter_map(|r| first_region(p, r.target(), owner))
|
||||
.collect();
|
||||
let distinct: BTreeSet<_> = regions.iter().copied().collect();
|
||||
if distinct.len() != regions.len() {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"entry_point_cardinality",
|
||||
"target",
|
||||
"EntryPoint has at most one outgoing transition per destination region.",
|
||||
);
|
||||
}
|
||||
if p.children(owner)
|
||||
.filter(|r| r.kind() == Kind::Region)
|
||||
.any(|r| {
|
||||
!distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial)
|
||||
})
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"entry_region_default",
|
||||
"target",
|
||||
"Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_graph(p: &Project, out: &mut Vec<Issue>) {
|
||||
for e in p.elements.values() {
|
||||
validate_vertex(p, e, None, out);
|
||||
if e.kind() == Kind::Region {
|
||||
for (kind, code) in [
|
||||
(Kind::Initial, "multiple_initial"),
|
||||
(Kind::ShallowHistory, "multiple_shallow_history"),
|
||||
(Kind::DeepHistory, "multiple_deep_history"),
|
||||
] {
|
||||
if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
code,
|
||||
"parent",
|
||||
format!("A region has at most one {kind}."),
|
||||
);
|
||||
}
|
||||
}
|
||||
let explicit_entry =
|
||||
edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id));
|
||||
if !p
|
||||
.children(e.id)
|
||||
.any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint))
|
||||
&& !explicit_entry
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"missing_initial",
|
||||
"parent",
|
||||
"Add an Initial default entry or an explicit incoming entry path for this region.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if e.kind() == Kind::StateMachine {
|
||||
validate_reachability(p, e.id, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_reachability(p: &Project, machine: Id, out: &mut Vec<Issue>) {
|
||||
let mut pending: Vec<_> = p
|
||||
.children(machine)
|
||||
.filter(|e| e.kind() == Kind::Region)
|
||||
.flat_map(|region| p.children(region.id))
|
||||
.filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint))
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let mut reached = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !reached.insert(id) {
|
||||
continue;
|
||||
}
|
||||
pending.extend(
|
||||
edges(p, None)
|
||||
.filter(|r| r.source() == id)
|
||||
.map(Relation::target),
|
||||
);
|
||||
// Entering a nested vertex activates its composite ancestors. Reaching a
|
||||
// composite also permits each region's default entry; this deliberately
|
||||
// over-approximates guards/history instead of pretending to execute them.
|
||||
pending.extend(ancestors(p, id).into_iter().filter(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind().is_stable_state())
|
||||
}));
|
||||
pending.extend(
|
||||
p.children(id)
|
||||
.filter(|e| e.kind() == Kind::Region)
|
||||
.flat_map(|region| p.children(region.id))
|
||||
.filter(|e| e.kind() == Kind::Initial)
|
||||
.map(|e| e.id),
|
||||
);
|
||||
}
|
||||
for e in p.elements.values().filter(|e| {
|
||||
e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
|
||||
}) {
|
||||
if !reached.contains(&e.id) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"unreachable_state",
|
||||
"body",
|
||||
"No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransitionDirection {
|
||||
OaToSa,
|
||||
SaToOa,
|
||||
}
|
||||
impl TransitionDirection {
|
||||
pub fn source_layer(self) -> Layer {
|
||||
match self {
|
||||
Self::OaToSa => Layer::Oa,
|
||||
Self::SaToOa => Layer::Sa,
|
||||
}
|
||||
}
|
||||
pub fn target_layer(self) -> Layer {
|
||||
match self {
|
||||
Self::OaToSa => Layer::Sa,
|
||||
Self::SaToOa => Layer::Oa,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DependencyScope {
|
||||
SelectedOnly,
|
||||
Required,
|
||||
Subtree,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransitionField {
|
||||
Name,
|
||||
Description,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionMapping {
|
||||
pub source: Id,
|
||||
pub target: Option<Id>,
|
||||
pub target_kind: Option<Kind>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FieldDecision {
|
||||
TakeSource,
|
||||
KeepTarget,
|
||||
Value(String),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FieldResolution {
|
||||
pub source: Id,
|
||||
pub field: TransitionField,
|
||||
pub decision: FieldDecision,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionRequest {
|
||||
pub direction: TransitionDirection,
|
||||
pub sources: Vec<Id>,
|
||||
pub scope: DependencyScope,
|
||||
pub mappings: Vec<TransitionMapping>,
|
||||
pub fields: Vec<TransitionField>,
|
||||
pub resolutions: Vec<FieldResolution>,
|
||||
pub destination_diagram: Option<Id>,
|
||||
}
|
||||
impl TransitionRequest {
|
||||
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
|
||||
Self {
|
||||
direction,
|
||||
sources,
|
||||
scope: DependencyScope::SelectedOnly,
|
||||
mappings: vec![],
|
||||
fields: vec![TransitionField::Name, TransitionField::Description],
|
||||
resolutions: vec![],
|
||||
destination_diagram: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransitionAction {
|
||||
Create,
|
||||
Update,
|
||||
Reuse,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FieldChange {
|
||||
pub field: TransitionField,
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionChange {
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub action: TransitionAction,
|
||||
pub fields: Vec<FieldChange>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionConflict {
|
||||
pub source: Option<Id>,
|
||||
pub target: Option<Id>,
|
||||
pub field: Option<TransitionField>,
|
||||
pub message: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionPlan {
|
||||
pub base_fingerprint: String,
|
||||
pub request: TransitionRequest,
|
||||
pub changes: Vec<TransitionChange>,
|
||||
pub conflicts: Vec<TransitionConflict>,
|
||||
pub warnings: Vec<String>,
|
||||
pub commands: Vec<Command>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Drift {
|
||||
Unreviewed,
|
||||
Unchanged,
|
||||
OaChanged,
|
||||
SaChanged,
|
||||
BothChanged,
|
||||
}
|
||||
pub fn trace_drift(p: &Project, t: &Trace) -> Drift {
|
||||
let Some(base) = &t.baseline else {
|
||||
return Drift::Unreviewed;
|
||||
};
|
||||
let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else {
|
||||
return Drift::Unreviewed;
|
||||
};
|
||||
match (oa != base.oa, sa != base.sa) {
|
||||
(false, false) => Drift::Unchanged,
|
||||
(true, false) => Drift::OaChanged,
|
||||
(false, true) => Drift::SaChanged,
|
||||
(true, true) => Drift::BothChanged,
|
||||
}
|
||||
}
|
||||
/// Deterministic change token, not a security hash or persisted revision.
|
||||
pub fn project_fingerprint(p: &Project) -> String {
|
||||
let bytes = serde_json::to_vec(p).expect("Project serializes");
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
fn conflict(
|
||||
plan: &mut TransitionPlan,
|
||||
source: Option<Id>,
|
||||
target: Option<Id>,
|
||||
field: Option<TransitionField>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
plan.conflicts.push(TransitionConflict {
|
||||
source,
|
||||
target,
|
||||
field,
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|
||||
Some(match (direction, kind) {
|
||||
(TransitionDirection::OaToSa, Kind::Activity) => Kind::Function,
|
||||
(TransitionDirection::SaToOa, Kind::Function) => Kind::Activity,
|
||||
(TransitionDirection::OaToSa, Kind::Capability) => Kind::SystemCapability,
|
||||
(TransitionDirection::SaToOa, Kind::SystemCapability) => Kind::Capability,
|
||||
(TransitionDirection::OaToSa, Kind::OperationalMission) => Kind::Mission,
|
||||
(TransitionDirection::SaToOa, Kind::Mission) => Kind::OperationalMission,
|
||||
(TransitionDirection::OaToSa, Kind::Process) => Kind::FunctionalChain,
|
||||
(TransitionDirection::SaToOa, Kind::FunctionalChain) => Kind::Process,
|
||||
(_, k)
|
||||
if k.is_state()
|
||||
|| matches!(
|
||||
k,
|
||||
Kind::ExchangeItem
|
||||
| Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::Interface
|
||||
| Kind::Port
|
||||
| Kind::Scenario
|
||||
| Kind::Lifeline
|
||||
| Kind::Occurrence
|
||||
| Kind::StateMachine
|
||||
| Kind::Region
|
||||
| Kind::Event
|
||||
| Kind::Constraint
|
||||
) =>
|
||||
{
|
||||
k
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
||||
if let Some(shared) = e.shared()
|
||||
&& kind == e.kind()
|
||||
{
|
||||
let mut shared = shared.clone();
|
||||
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
|
||||
*viewpoint = match (*viewpoint, layer) {
|
||||
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
|
||||
ScenarioViewpoint::Functional
|
||||
}
|
||||
(ScenarioViewpoint::Functional, Layer::Oa) => {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
}
|
||||
(ScenarioViewpoint::ParticipantInteraction, Layer::Sa) => {
|
||||
ScenarioViewpoint::BoundaryInterface
|
||||
}
|
||||
(ScenarioViewpoint::BoundaryInterface, Layer::Oa) => {
|
||||
ScenarioViewpoint::ParticipantInteraction
|
||||
}
|
||||
(viewpoint, _) => viewpoint,
|
||||
};
|
||||
}
|
||||
return Some(ElementBody::shared_in(layer, shared));
|
||||
}
|
||||
let mut body = ElementBody::new(kind, layer)?;
|
||||
if let (Some(old), Some(new)) = (e.steps(), body.steps_mut()) {
|
||||
*new = old.clone();
|
||||
}
|
||||
Some(body)
|
||||
}
|
||||
fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<RelationBody> {
|
||||
let (source, target) = r.body.endpoints();
|
||||
Some(match (&r.body, direction) {
|
||||
(RelationBody::Interaction { items, .. }, TransitionDirection::OaToSa) => {
|
||||
RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port: None,
|
||||
target_port: None,
|
||||
items: items.clone(),
|
||||
}
|
||||
}
|
||||
(RelationBody::FunctionalExchange { items, .. }, TransitionDirection::SaToOa) => {
|
||||
RelationBody::Interaction {
|
||||
source,
|
||||
target,
|
||||
items: items.clone(),
|
||||
}
|
||||
}
|
||||
(
|
||||
RelationBody::Allocation { .. }
|
||||
| RelationBody::ExchangeAllocation { .. }
|
||||
| RelationBody::CapabilityInvolvement { .. }
|
||||
| RelationBody::CapabilityInclude { .. }
|
||||
| RelationBody::CapabilityExtend { .. }
|
||||
| RelationBody::CapabilityGeneralization { .. }
|
||||
| RelationBody::MissionInvolvement { .. }
|
||||
| RelationBody::MissionCapability { .. }
|
||||
| RelationBody::SequenceLink { .. }
|
||||
| RelationBody::ChainInvolvement { .. }
|
||||
| RelationBody::BoundaryExchange { .. }
|
||||
| RelationBody::InterfaceProvision { .. }
|
||||
| RelationBody::InterfaceRequirement { .. }
|
||||
| RelationBody::StateTransition { .. }
|
||||
| RelationBody::Availability { .. }
|
||||
| RelationBody::StateAction { .. },
|
||||
_,
|
||||
) => r.body.clone(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
|
||||
match (p.elements.get(&oa), p.elements.get(&sa)) {
|
||||
(Some(a), Some(b)) => match (a.kind(), b.kind()) {
|
||||
(Kind::Activity, Kind::Function) => TraceKind::Refinement,
|
||||
(Kind::Entity | Kind::Actor, Kind::System | Kind::SystemActor) => {
|
||||
TraceKind::Participant
|
||||
}
|
||||
(Kind::Capability, Kind::SystemCapability) => TraceKind::Capability,
|
||||
(Kind::OperationalMission, Kind::Mission) => TraceKind::Mission,
|
||||
(Kind::Process, Kind::FunctionalChain) => TraceKind::Sequence,
|
||||
(Kind::Occurrence, Kind::Occurrence)
|
||||
if a.parent
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|owner| owner.kind() == Kind::Process)
|
||||
&& b.parent
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|owner| owner.kind() == Kind::FunctionalChain) =>
|
||||
{
|
||||
TraceKind::Sequence
|
||||
}
|
||||
(
|
||||
Kind::ExchangeItem
|
||||
| Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::Interface
|
||||
| Kind::Port,
|
||||
_,
|
||||
) => TraceKind::Item,
|
||||
(Kind::Scenario | Kind::Lifeline | Kind::Occurrence, _) => TraceKind::Scenario,
|
||||
(kind, other)
|
||||
if kind == other
|
||||
&& (kind.is_state()
|
||||
|| matches!(kind, Kind::StateMachine | Kind::Region | Kind::Event)) =>
|
||||
{
|
||||
TraceKind::State
|
||||
}
|
||||
_ => TraceKind::Manual,
|
||||
},
|
||||
_ => {
|
||||
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
|
||||
{
|
||||
TraceKind::Exchange
|
||||
} else {
|
||||
TraceKind::Manual
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn value(props: &TracedProperties, field: TransitionField) -> &String {
|
||||
match field {
|
||||
TransitionField::Name => &props.name,
|
||||
TransitionField::Description => &props.description,
|
||||
}
|
||||
}
|
||||
fn set(props: &mut TracedProperties, field: TransitionField, value: String) {
|
||||
match field {
|
||||
TransitionField::Name => props.name = value,
|
||||
TransitionField::Description => props.description = value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plan_transition(
|
||||
project: &Project,
|
||||
request: &TransitionRequest,
|
||||
) -> Result<TransitionPlan, ModelError> {
|
||||
validate_candidate(project)?;
|
||||
let mut plan = TransitionPlan {
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
request: request.clone(),
|
||||
changes: vec![],
|
||||
conflicts: vec![],
|
||||
warnings: vec![],
|
||||
commands: vec![],
|
||||
};
|
||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||
if selected.len() != request.sources.len() {
|
||||
conflict(
|
||||
&mut plan,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"Source selection contains duplicate identities.",
|
||||
);
|
||||
}
|
||||
if selected.is_empty() {
|
||||
conflict(
|
||||
&mut plan,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"Select at least one source object.",
|
||||
);
|
||||
}
|
||||
if request.scope != DependencyScope::SelectedOnly {
|
||||
loop {
|
||||
let before = selected.len();
|
||||
let existing: Vec<_> = selected.iter().copied().collect();
|
||||
for id in existing {
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
// Contextual links are owned by the process/chain shared by
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
selected.extend(project.relations.values().filter_map(|relation| {
|
||||
(relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id))
|
||||
.then_some(relation.id)
|
||||
}));
|
||||
}
|
||||
if e.kind() == Kind::StateMachine && request.scope == DependencyScope::Subtree {
|
||||
selected.extend(project.relations.values().filter_map(|relation| {
|
||||
(matches!(
|
||||
relation.kind(),
|
||||
RelationKind::StateTransition | RelationKind::StateAction
|
||||
) && validation::ancestor_of_kind(
|
||||
project,
|
||||
relation.source(),
|
||||
Kind::StateMachine,
|
||||
) == Some(id))
|
||||
.then_some(relation.id)
|
||||
}));
|
||||
}
|
||||
if request.scope == DependencyScope::Subtree
|
||||
|| matches!(e.kind(), Kind::ExchangeItem | Kind::DataType)
|
||||
{
|
||||
selected.extend(project.children(id).map(|e| e.id));
|
||||
}
|
||||
} else if let Some(r) = project.relations.get(&id) {
|
||||
selected.extend(crate::references::relation_references(r));
|
||||
}
|
||||
}
|
||||
if selected.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::new();
|
||||
for mapping in &request.mappings {
|
||||
if choices.insert(mapping.source, mapping).is_some() {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(mapping.source),
|
||||
None,
|
||||
None,
|
||||
"Choose one counterpart per source in this plan; run separately for another existing trace.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut targets = BTreeMap::new();
|
||||
let mut creating = BTreeSet::new();
|
||||
let mut kinds = BTreeMap::new();
|
||||
for source in &selected {
|
||||
if project.layer_of(*source) != Some(request.direction.source_layer()) {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"Source is not a semantic object in the selected source layer.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let explicit = choices.get(source).copied();
|
||||
let existing: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| match request.direction {
|
||||
TransitionDirection::OaToSa if t.oa == *source => Some(t.sa),
|
||||
TransitionDirection::SaToOa if t.sa == *source => Some(t.oa),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let target = if let Some(mapping) = explicit {
|
||||
mapping.target
|
||||
} else if existing.len() == 1 {
|
||||
Some(existing[0])
|
||||
} else if existing.len() > 1 {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"Several traced counterparts exist; select the target explicitly.",
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(target) = target {
|
||||
if project.layer_of(target) != Some(request.direction.target_layer()) {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Selected target must exist in the opposite layer.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if project.elements.contains_key(source) != project.elements.contains_key(&target) {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Elements map to elements and relationships map to relationships.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !compatible_existing(project, *source, target, request.direction) {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"These kinds have no automatic transition mapping; use a manual trace.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
targets.insert(*source, target);
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = project.elements.get(source) {
|
||||
let kind = explicit
|
||||
.and_then(|m| m.target_kind)
|
||||
.or_else(|| inferred_kind(e.kind(), request.direction));
|
||||
let Some(kind) = kind else {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if !kind.supports(request.direction.target_layer())
|
||||
|| !compatible_kinds(e.kind(), kind, request.direction)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"Counterpart kind is not valid for this automatic transition.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
kinds.insert(*source, kind);
|
||||
} else if let Some(r) = project.relations.get(source)
|
||||
&& mapped_relation(r, request.direction).is_none()
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"This relation has no automatic cross-layer mapping; use an explicit manual trace.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
targets.insert(*source, Id::new_v4());
|
||||
creating.insert(*source);
|
||||
}
|
||||
// Explicit mapping choices may provide dependencies without selecting updates.
|
||||
for mapping in &request.mappings {
|
||||
if let Some(target) = mapping.target
|
||||
&& project.layer_of(mapping.source) == Some(request.direction.source_layer())
|
||||
&& project.layer_of(target) == Some(request.direction.target_layer())
|
||||
{
|
||||
targets.entry(mapping.source).or_insert(target);
|
||||
}
|
||||
}
|
||||
let mut reused = BTreeMap::new();
|
||||
for source in &selected {
|
||||
if let Some(target) = targets.get(source)
|
||||
&& let Some(previous) = reused.insert(*target, *source)
|
||||
{
|
||||
let different = match (project.properties(previous), project.properties(*source)) {
|
||||
(Some(a), Some(b)) => request
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| value(&a, *field) != value(&b, *field)),
|
||||
_ => true,
|
||||
};
|
||||
if different {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(*target),
|
||||
None,
|
||||
"Several selected sources propose different values for one target. Reconcile that target explicitly in separate previews.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut working = project.clone();
|
||||
for source in selected.iter().filter(|id| creating.contains(id)) {
|
||||
let Some(target) = targets.get(source).copied() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(e) = project.elements.get(source) {
|
||||
let Some(body) = mapped_body(e, kinds[source], request.direction.target_layer()) else {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Unsupported typed body conversion.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut created = e.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
request.direction.target_layer().to_string().to_lowercase(),
|
||||
e.alias
|
||||
),
|
||||
"element",
|
||||
target,
|
||||
);
|
||||
crate::references::visit_element_refs(&mut created, |id| {
|
||||
if let Some(mapped) = targets.get(id) {
|
||||
*id = *mapped;
|
||||
}
|
||||
});
|
||||
for dependency in crate::references::element_references(&created) {
|
||||
if !targets.values().any(|id| *id == dependency)
|
||||
&& working.layer_of(dependency) != Some(request.direction.target_layer())
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
format!(
|
||||
"Dependency {dependency} needs an explicit counterpart or a broader dependency scope."
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !request.fields.contains(&TransitionField::Description) {
|
||||
created.description.clear();
|
||||
}
|
||||
working.elements.insert(target, created.clone());
|
||||
plan.commands.push(Command::CreateElement(created));
|
||||
} else if let Some(r) = project.relations.get(source) {
|
||||
let Some(body) = mapped_relation(r, request.direction) else {
|
||||
continue;
|
||||
};
|
||||
let mut created = r.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
request.direction.target_layer().to_string().to_lowercase(),
|
||||
r.alias
|
||||
),
|
||||
"relation",
|
||||
target,
|
||||
);
|
||||
crate::references::visit_relation_refs(&mut created, |id| {
|
||||
if let Some(mapped) = targets.get(id) {
|
||||
*id = *mapped;
|
||||
}
|
||||
});
|
||||
for dependency in crate::references::relation_references(&created) {
|
||||
if !targets.values().any(|id| *id == dependency)
|
||||
&& working.layer_of(dependency) != Some(request.direction.target_layer())
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
format!(
|
||||
"Relationship dependency {dependency} needs a counterpart mapping."
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !request.fields.contains(&TransitionField::Description) {
|
||||
created.description.clear();
|
||||
}
|
||||
working.relations.insert(target, created.clone());
|
||||
plan.commands.push(Command::CreateRelation(created));
|
||||
}
|
||||
}
|
||||
for source in &selected {
|
||||
let Some(target) = targets.get(source).copied() else {
|
||||
continue;
|
||||
};
|
||||
let (Some(source_props), Some(mut target_props)) =
|
||||
(project.properties(*source), working.properties(target))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (oa, sa) = match request.direction {
|
||||
TransitionDirection::OaToSa => (*source, target),
|
||||
TransitionDirection::SaToOa => (target, *source),
|
||||
};
|
||||
let existing_trace = project.traces.values().find(|t| t.oa == oa && t.sa == sa);
|
||||
let mut changes = vec![];
|
||||
let mut reviewed_fields = vec![];
|
||||
for field in &request.fields {
|
||||
let before = value(&target_props, *field).clone();
|
||||
let incoming = value(&source_props, *field).clone();
|
||||
if before == incoming {
|
||||
reviewed_fields.push(*field);
|
||||
continue;
|
||||
}
|
||||
let decision = request
|
||||
.resolutions
|
||||
.iter()
|
||||
.find(|r| r.source == *source && r.field == *field)
|
||||
.map(|r| &r.decision);
|
||||
let changed = existing_trace
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.map(|base| {
|
||||
let (origin, destination) = match request.direction {
|
||||
TransitionDirection::OaToSa => (&base.oa, &base.sa),
|
||||
TransitionDirection::SaToOa => (&base.sa, &base.oa),
|
||||
};
|
||||
(
|
||||
value(origin, *field) != &incoming,
|
||||
value(destination, *field) != &before,
|
||||
)
|
||||
});
|
||||
let after = match decision {
|
||||
Some(FieldDecision::TakeSource) => incoming,
|
||||
Some(FieldDecision::KeepTarget) => before.clone(),
|
||||
Some(FieldDecision::Value(v)) => v.clone(),
|
||||
None if creating.contains(source) => incoming,
|
||||
// A reviewed pair may deliberately have different values. No
|
||||
// source edit means no propagation, including when the target
|
||||
// has a local edit that can later propagate in reverse.
|
||||
None if matches!(changed, Some((false, _))) => continue,
|
||||
None if matches!(changed, Some((true, false))) => incoming,
|
||||
None => {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
Some(*field),
|
||||
"Both sides changed or no reviewed baseline exists. Choose take source, keep target, or a merged value.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
reviewed_fields.push(*field);
|
||||
if after != before {
|
||||
set(&mut target_props, *field, after.clone());
|
||||
changes.push(FieldChange {
|
||||
field: *field,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !changes.is_empty() {
|
||||
if let Some(e) = working.elements.get_mut(&target) {
|
||||
e.name = target_props.name.clone();
|
||||
e.description = target_props.description.clone();
|
||||
plan.commands.push(Command::EditElement(target, e.clone()));
|
||||
} else if let Some(r) = working.relations.get_mut(&target) {
|
||||
r.name = target_props.name.clone();
|
||||
r.description = target_props.description.clone();
|
||||
plan.commands.push(Command::EditRelation(target, r.clone()));
|
||||
}
|
||||
}
|
||||
plan.changes.push(TransitionChange {
|
||||
source: *source,
|
||||
target,
|
||||
action: if creating.contains(source) {
|
||||
TransitionAction::Create
|
||||
} else if changes.is_empty() {
|
||||
TransitionAction::Reuse
|
||||
} else {
|
||||
TransitionAction::Update
|
||||
},
|
||||
fields: changes,
|
||||
});
|
||||
let current_oa = working.properties(oa).unwrap();
|
||||
let current_sa = working.properties(sa).unwrap();
|
||||
let mut baseline =
|
||||
existing_trace
|
||||
.and_then(|t| t.baseline.clone())
|
||||
.unwrap_or(TraceBaseline {
|
||||
oa: current_oa.clone(),
|
||||
sa: current_sa.clone(),
|
||||
});
|
||||
for field in &reviewed_fields {
|
||||
set(&mut baseline.oa, *field, value(¤t_oa, *field).clone());
|
||||
set(&mut baseline.sa, *field, value(¤t_sa, *field).clone());
|
||||
}
|
||||
if let Some(old) = existing_trace {
|
||||
let mut trace = old.clone();
|
||||
trace.baseline = Some(baseline);
|
||||
if &trace != old {
|
||||
working.traces.insert(trace.id, trace.clone());
|
||||
plan.commands.push(Command::EditTrace(trace.id, trace));
|
||||
}
|
||||
} else {
|
||||
let id = Id::new_v4();
|
||||
let name = format!("{} ↔ {}", working.named(oa), working.named(sa));
|
||||
let alias = working.fresh_alias(
|
||||
&format!(
|
||||
"trace_{}_{}",
|
||||
working
|
||||
.elements
|
||||
.get(&oa)
|
||||
.map(|e| e.alias.as_str())
|
||||
.unwrap_or("exchange"),
|
||||
working
|
||||
.elements
|
||||
.get(&sa)
|
||||
.map(|e| e.alias.as_str())
|
||||
.unwrap_or("exchange")
|
||||
),
|
||||
"trace",
|
||||
id,
|
||||
);
|
||||
let trace = Trace {
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: trace_kind(&working, oa, sa),
|
||||
baseline: Some(baseline),
|
||||
};
|
||||
working.traces.insert(id, trace.clone());
|
||||
plan.commands.push(Command::CreateTrace(trace));
|
||||
}
|
||||
if let Some(diagram) = request.destination_diagram
|
||||
&& working.elements.contains_key(&target)
|
||||
{
|
||||
match working.diagrams.get(&diagram) {
|
||||
Some(d)
|
||||
if d.layer == request.direction.target_layer()
|
||||
|| d.projection == ProjectionKind::Traceability =>
|
||||
{
|
||||
if !d.nodes.values().any(|n| n.element == target) {
|
||||
let index = d.nodes.len();
|
||||
let id = Id::new_v4();
|
||||
let alias = working.fresh_alias(
|
||||
&format!("view_{}", working.named(target)),
|
||||
"node",
|
||||
id,
|
||||
);
|
||||
let node = NodePlacement {
|
||||
id,
|
||||
alias,
|
||||
element: target,
|
||||
position: Position {
|
||||
x: 60. + (index % 4) as f32 * 270.,
|
||||
y: 60. + (index / 4) as f32 * 160.,
|
||||
},
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
};
|
||||
working
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, node.clone());
|
||||
plan.commands.push(Command::CreateNode { diagram, node });
|
||||
}
|
||||
}
|
||||
_ => conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Destination diagram is missing or belongs to the wrong layer.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final source-order-independent batch execution also applies occurrence ownership rules.
|
||||
match execute_batch(project, &plan.commands) {
|
||||
Ok(candidate) => {
|
||||
for issue in validation::validate(&candidate) {
|
||||
if issue.severity == validation::Severity::Warning {
|
||||
plan.warnings.push(issue.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => conflict(
|
||||
&mut plan,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
format!("Candidate validation: {err}"),
|
||||
),
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Project, ModelError> {
|
||||
if project_fingerprint(project) != plan.base_fingerprint {
|
||||
return Err(ModelError(
|
||||
"Transition preview is stale; create a new preview.".into(),
|
||||
));
|
||||
}
|
||||
if !plan.conflicts.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Resolve transition conflicts before applying.".into(),
|
||||
));
|
||||
}
|
||||
execute_batch(project, &plan.commands)
|
||||
}
|
||||
|
||||
/// Supported explicit element counterparts, in stable kind-catalog order.
|
||||
pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Kind> {
|
||||
let mut kinds: Vec<_> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|target| {
|
||||
target.supports(direction.target_layer())
|
||||
&& compatible_kinds(source, *target, direction)
|
||||
})
|
||||
.collect();
|
||||
// Offer the semantic counterpart first; alternative engineering mappings
|
||||
// remain explicit choices and existing traces remain reusable.
|
||||
let preferred = inferred_kind(source, direction);
|
||||
kinds.sort_by_key(|kind| Some(*kind) != preferred);
|
||||
kinds
|
||||
}
|
||||
|
||||
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
|
||||
if matches!(
|
||||
(direction, source, target),
|
||||
(
|
||||
TransitionDirection::OaToSa,
|
||||
Kind::Entity | Kind::Actor,
|
||||
Kind::System | Kind::SystemActor
|
||||
) | (
|
||||
TransitionDirection::SaToOa,
|
||||
Kind::System | Kind::SystemActor,
|
||||
Kind::Entity | Kind::Actor
|
||||
) | (TransitionDirection::OaToSa, Kind::Capability, Kind::Mission)
|
||||
| (TransitionDirection::SaToOa, Kind::Mission, Kind::Capability)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
inferred_kind(source, direction) == Some(target)
|
||||
}
|
||||
fn compatible_existing(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
target: Id,
|
||||
direction: TransitionDirection,
|
||||
) -> bool {
|
||||
match (p.elements.get(&source), p.elements.get(&target)) {
|
||||
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
|
||||
_ => match (p.relations.get(&source), p.relations.get(&target)) {
|
||||
(Some(a), Some(b)) => {
|
||||
mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,900 @@
|
||||
arcadia 2;
|
||||
project mission "Coastal rescue — operational need to system behavior" id "eceea6cd-108b-45a9-9505-8b7fa462af96" {
|
||||
description "A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.";
|
||||
|
||||
sa_lifeline vessel_service_lifeline "Vessel service lifeline" id "03b71dc5-435f-4c7f-923a-cb6e672212ba" {
|
||||
description "";
|
||||
parent @repeated_distress_reports;
|
||||
represented @vessel_communication_service;
|
||||
}
|
||||
|
||||
sa_initial start_reporting "Start reporting" id "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978" {
|
||||
description "";
|
||||
do_activity "";
|
||||
entry "";
|
||||
exit "";
|
||||
kind Initial;
|
||||
parent @reporting_quality_region;
|
||||
}
|
||||
|
||||
sa_state_machine reporting_operation_modes "Reporting operation modes" id "1a1b215a-c102-4948-9c88-dabfadc8e08e" {
|
||||
description "";
|
||||
parent @rescue_coordination_system;
|
||||
}
|
||||
|
||||
sa_exchange_item backup_electrical_energy "Backup electrical energy" id "1b81d963-06e7-42d3-af31-bf5236829477" {
|
||||
description "";
|
||||
kind Energy;
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_lifeline coordination_system_lifeline "Coordination system lifeline" id "1f7da722-c91e-4d0e-a344-c1a2511dd96b" {
|
||||
description "";
|
||||
parent @repeated_distress_reports;
|
||||
represented @rescue_coordination_system;
|
||||
}
|
||||
|
||||
sa_mission support_timely_rescue_decisions "Support timely rescue decisions" id "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_occurrence updated_distress_report "Updated distress report" id "28b8e56d-7ddf-4950-8c30-7385c044cc72" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @validated_distress_report;
|
||||
receiver @coordination_system_lifeline;
|
||||
sender @vessel_service_lifeline;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @repeated_distress_reports;
|
||||
postcondition "The incident position is refreshed without creating a second incident.";
|
||||
precondition "The vessel has more recent position information.";
|
||||
}
|
||||
|
||||
oa_activity assess_incident "Assess incident" id "3388cc0d-0abc-4479-a948-6740266ec236" {
|
||||
description "";
|
||||
parent @respond_to_distress;
|
||||
}
|
||||
|
||||
sa_constraint response_time_budget "Response time budget" id "3584d050-0b4e-4620-bbea-8c3c87d2a19f" {
|
||||
constrained [@classify_incident_and_alert_operator, @validated_distress_report];
|
||||
description "";
|
||||
expression "incident_alert_latency <= 120 s";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_system rescue_coordination_system "Rescue coordination system" id "47e28c63-803b-4ea6-af92-e778f62ecc3e" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_functional_chain digital_distress_handling "Digital distress handling" id "49743116-7777-4df8-9601-74fd4cba22c0" {
|
||||
description "";
|
||||
parent none;
|
||||
steps [@report_handled];
|
||||
}
|
||||
|
||||
oa_process distress_to_response "Distress to response" id "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5" {
|
||||
description "";
|
||||
parent none;
|
||||
steps [@distress_received];
|
||||
}
|
||||
|
||||
oa_occurrence distress_received "Distress received" id "5e0c0d0e-5eb5-467c-bc35-dfe29d534703" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @distress_signal;
|
||||
receiver none;
|
||||
sender none;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @distress_to_response;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_exchange_item digital_distress_report "Digital distress report" id "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" {
|
||||
description "";
|
||||
kind Information;
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_capability protect_life_at_sea "Protect life at sea" id "6ca5efd6-2e92-4508-a02d-4013793743cb" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_port report_function_output "Report function output" id "6ecf32ed-4009-48ba-b374-3bf156f6c355" {
|
||||
description "";
|
||||
direction Out;
|
||||
interface @distress_reporting_interface;
|
||||
parent @transmit_distress_report;
|
||||
}
|
||||
|
||||
oa_entity rescue_coordination "Rescue coordination" id "721b68ab-5136-408e-890d-925215d5661e" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_capability coordinate_rescue_response "Coordinate rescue response" id "7781bd9f-e788-4049-9af4-9a3eab40d377" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_actor vessel_in_distress "Vessel in distress" id "7e8ea298-1c68-4c9e-994d-6008d47f62ce" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_exchange_item distress_report "Distress report" id "835c2e84-f4e9-4592-b5df-d205803d97b7" {
|
||||
description "";
|
||||
kind Information;
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_port system_reporting_input "System reporting input" id "8477d19d-4a62-43bb-a7e3-845b9b3d1002" {
|
||||
description "";
|
||||
direction In;
|
||||
interface @distress_reporting_interface;
|
||||
parent @rescue_coordination_system;
|
||||
}
|
||||
|
||||
sa_occurrence initial_distress_report "Initial distress report" id "84a0db92-ac74-493a-af29-74ec7715d7fa" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @validated_distress_report;
|
||||
receiver @coordination_system_lifeline;
|
||||
sender @vessel_service_lifeline;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @repeated_distress_reports;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_port vessel_reporting_output "Vessel reporting output" id "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932" {
|
||||
description "";
|
||||
direction Out;
|
||||
interface @distress_reporting_interface;
|
||||
parent @vessel_communication_service;
|
||||
}
|
||||
|
||||
sa_function transmit_distress_report "Transmit distress report" id "b0a649d6-0558-48f5-8437-161be8329159" {
|
||||
description "";
|
||||
parent @manage_distress_response;
|
||||
}
|
||||
|
||||
sa_function classify_incident_and_alert_operator "Classify incident and alert operator" id "b1cc95c7-1d2f-42d0-9d93-587954bd563b" {
|
||||
description "";
|
||||
parent @manage_distress_response;
|
||||
}
|
||||
|
||||
sa_occurrence report_handled "Report handled" id "bcdb57f1-5fa8-46ce-9498-b467cea5d98f" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @validated_distress_report;
|
||||
receiver none;
|
||||
sender none;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @digital_distress_handling;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_function manage_distress_response "Manage distress response" id "c64f93a7-bf47-42b5-b432-7589b9144f2c" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_actor vessel_communication_service "Vessel communication service" id "cfb45cf5-bebe-4632-9bde-0ee023c42583" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_mode normal_reporting "Normal reporting" id "d00b5d17-8660-4955-a70d-988a6e6b6c4e" {
|
||||
description "";
|
||||
do_activity "";
|
||||
entry "";
|
||||
exit "";
|
||||
kind Mode;
|
||||
parent @reporting_quality_region;
|
||||
}
|
||||
|
||||
sa_data_field reported_latitude "Reported latitude" id "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36" {
|
||||
description "";
|
||||
maximum 1;
|
||||
minimum 1;
|
||||
parent @digital_distress_report;
|
||||
type_ref Real;
|
||||
unit "degrees north";
|
||||
}
|
||||
|
||||
oa_activity report_distress "Report distress" id "d50e92c4-840e-4822-bcf7-033feb2eb879" {
|
||||
description "";
|
||||
parent @respond_to_distress;
|
||||
}
|
||||
|
||||
sa_mode degraded_reporting "Degraded reporting" id "d7fad7e9-d799-4fa6-b49f-1304d3285b5e" {
|
||||
description "";
|
||||
do_activity "";
|
||||
entry "";
|
||||
exit "";
|
||||
kind Mode;
|
||||
parent @reporting_quality_region;
|
||||
}
|
||||
|
||||
sa_exchange_item rescue_supply_package "Rescue supply package" id "eb8cf40e-8194-403f-9896-f968790a9504" {
|
||||
description "";
|
||||
kind Material;
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_region reporting_quality_region "Reporting quality region" id "edc1ad70-8fc9-42a1-a668-05c4738bb225" {
|
||||
description "";
|
||||
parent @reporting_operation_modes;
|
||||
}
|
||||
|
||||
sa_interface distress_reporting_interface "Distress reporting interface" id "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" {
|
||||
description "";
|
||||
items [@digital_distress_report];
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_role incident_coordinator "Incident coordinator" id "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_scenario repeated_distress_reports "Repeated distress reports" id "f7514a9c-933c-4133-8b77-37c0dccd935f" {
|
||||
context @coordinate_rescue_response;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "The operator has a classified incident with the latest vessel report.";
|
||||
precondition "A vessel has detected an emergency and the reporting service is available.";
|
||||
steps [@initial_distress_report, @updated_distress_report];
|
||||
}
|
||||
|
||||
oa_activity respond_to_distress "Respond to distress" id "fa23a2a8-6f13-49e3-be47-f757f26d5c9f" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_port assessment_function_input "Assessment function input" id "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f" {
|
||||
description "";
|
||||
direction In;
|
||||
interface @distress_reporting_interface;
|
||||
parent @classify_incident_and_alert_operator;
|
||||
}
|
||||
|
||||
state_transition signal_restored "Signal restored" id "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018" {
|
||||
description "";
|
||||
effect "Clear the degraded-reporting alert.";
|
||||
guard "position_is_current";
|
||||
source @degraded_reporting;
|
||||
target @normal_reporting;
|
||||
trigger "validated_report_received";
|
||||
}
|
||||
|
||||
state_action review_on_degraded_entry "Review on degraded entry" id "044cdc5f-a34a-4e51-9774-c1774a33dc91" {
|
||||
description "";
|
||||
phase Entry;
|
||||
source @degraded_reporting;
|
||||
target @classify_incident_and_alert_operator;
|
||||
}
|
||||
|
||||
allocation assesses_incident "Assesses incident" id "17fef4ab-acc7-4f21-b70d-5d64f6ad1261" {
|
||||
description "";
|
||||
source @rescue_coordination;
|
||||
target @assess_incident;
|
||||
}
|
||||
|
||||
availability assessment_in_degraded_mode "Assessment in degraded mode" id "2c092728-eafb-47e3-bcc7-8b795e36a25e" {
|
||||
description "";
|
||||
source @classify_incident_and_alert_operator;
|
||||
target @degraded_reporting;
|
||||
}
|
||||
|
||||
state_transition signal_degraded "Signal degraded" id "331c78a2-b6b3-452f-a238-84a6a3df3547" {
|
||||
description "";
|
||||
effect "Flag the incident and request an operator review.";
|
||||
guard "seconds_since_last_report > 30";
|
||||
source @normal_reporting;
|
||||
target @degraded_reporting;
|
||||
trigger "report_timeout";
|
||||
}
|
||||
|
||||
capability_involvement system_capability_support "System capability support" id "39ff3786-d94c-40ad-90fa-c009ab6f53f9" {
|
||||
description "";
|
||||
source @classify_incident_and_alert_operator;
|
||||
target @coordinate_rescue_response;
|
||||
}
|
||||
|
||||
chain_involvement operational_realization "Operational realization" id "41d934ba-76f7-4425-b433-2d4d293ab0df" {
|
||||
description "";
|
||||
source @distress_to_response;
|
||||
target @protect_life_at_sea;
|
||||
}
|
||||
|
||||
role_realization fulfils_coordinator_role "Fulfils coordinator role" id "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef" {
|
||||
description "";
|
||||
source @rescue_coordination;
|
||||
target @incident_coordinator;
|
||||
}
|
||||
|
||||
state_transition initialize_reporting "Initialize reporting" id "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b" {
|
||||
description "";
|
||||
effect "";
|
||||
guard "";
|
||||
source @start_reporting;
|
||||
target @normal_reporting;
|
||||
trigger "";
|
||||
}
|
||||
|
||||
capability_involvement operational_contribution "Operational contribution" id "64ab242b-6043-4452-9b28-7205fec309df" {
|
||||
description "";
|
||||
source @assess_incident;
|
||||
target @protect_life_at_sea;
|
||||
}
|
||||
|
||||
interaction distress_signal "Distress signal" id "689a8e09-b7ca-4d97-b551-a21e3b438bfe" {
|
||||
description "";
|
||||
items [@distress_report];
|
||||
source @report_distress;
|
||||
target @assess_incident;
|
||||
}
|
||||
|
||||
allocation vessel_transmission "Vessel transmission" id "6a82e148-d38e-4103-8481-046b4c474952" {
|
||||
description "";
|
||||
source @vessel_communication_service;
|
||||
target @transmit_distress_report;
|
||||
}
|
||||
|
||||
interface_requirement reporting_service_required "Reporting service required" id "7c32cbe5-0821-4793-a37f-9f017fc8a3f4" {
|
||||
description "";
|
||||
source @rescue_coordination_system;
|
||||
target @distress_reporting_interface;
|
||||
}
|
||||
|
||||
chain_involvement functional_chain_support "Functional chain support" id "92468388-63c2-4f2c-a1fd-2c3f27b76398" {
|
||||
description "";
|
||||
source @digital_distress_handling;
|
||||
target @coordinate_rescue_response;
|
||||
}
|
||||
|
||||
allocation system_assessment "System assessment" id "93e3a60d-33c0-4110-83d9-63f2902d5eeb" {
|
||||
description "";
|
||||
source @rescue_coordination_system;
|
||||
target @classify_incident_and_alert_operator;
|
||||
}
|
||||
|
||||
role_assignment coordinates_assessment "Coordinates assessment" id "a06f90b9-361b-4da2-86a9-0447df519e0c" {
|
||||
description "";
|
||||
source @incident_coordinator;
|
||||
target @assess_incident;
|
||||
}
|
||||
|
||||
functional_exchange validated_distress_report "Validated distress report" id "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" {
|
||||
description "";
|
||||
items [@digital_distress_report];
|
||||
source @transmit_distress_report;
|
||||
source_port @report_function_output;
|
||||
target @classify_incident_and_alert_operator;
|
||||
target_port @assessment_function_input;
|
||||
}
|
||||
|
||||
availability assessment_in_normal_mode "Assessment in normal mode" id "bce2587e-f47c-4d85-ac6d-a098170bbaa5" {
|
||||
description "";
|
||||
source @classify_incident_and_alert_operator;
|
||||
target @normal_reporting;
|
||||
}
|
||||
|
||||
exchange_allocation report_carried_by_connection "Report carried by connection" id "d6a2c8d2-2a4f-4d90-a490-c2c661db4423" {
|
||||
description "";
|
||||
source @distress_reporting_connection;
|
||||
target @validated_distress_report;
|
||||
}
|
||||
|
||||
mission_capability mission_capability "Mission capability" id "e88a9747-0d1c-4813-8273-e6710f622e9b" {
|
||||
description "";
|
||||
source @support_timely_rescue_decisions;
|
||||
target @coordinate_rescue_response;
|
||||
}
|
||||
|
||||
boundary_exchange distress_reporting_connection "Distress reporting connection" id "eb7ae249-165c-4717-bebc-da476eebb05e" {
|
||||
description "";
|
||||
items [@digital_distress_report];
|
||||
source @vessel_reporting_output;
|
||||
target @system_reporting_input;
|
||||
}
|
||||
|
||||
interface_provision reporting_service_provided "Reporting service provided" id "ec3f9e3f-5134-49af-9969-29f818dfcb46" {
|
||||
description "";
|
||||
source @vessel_communication_service;
|
||||
target @distress_reporting_interface;
|
||||
}
|
||||
|
||||
allocation reports_distress "Reports distress" id "f844c555-b407-402c-81c9-4928245249ca" {
|
||||
description "";
|
||||
source @vessel_in_distress;
|
||||
target @report_distress;
|
||||
}
|
||||
|
||||
diagram sa_system_boundary_and_functions "SA system boundary and functions" id "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a" {
|
||||
description "A focused projection of the shared engineering model.";
|
||||
layer Sa;
|
||||
projection Allocation;
|
||||
}
|
||||
|
||||
node view_vessel_communication_service id "00edf6bc-b857-439a-8747-f1f66c1675e2" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @vessel_communication_service;
|
||||
position {
|
||||
x 60.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 166.0;
|
||||
width 272.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_transmit_distress_report id "035dab75-ef9e-4ca3-a107-87262e3c680b" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @transmit_distress_report;
|
||||
position {
|
||||
x 84.0;
|
||||
y 108.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_support_timely_rescue_decisions id "16b5055a-c98b-4276-b564-85b7fe1f020f" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @support_timely_rescue_decisions;
|
||||
position {
|
||||
x 392.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_digital_distress_handling id "4aa2fb09-b367-402c-a438-d4f918f04e08" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @digital_distress_handling;
|
||||
position {
|
||||
x 676.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_rescue_coordination_system id "5a6d6a1c-9915-4e33-8299-b512c50b18f5" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @rescue_coordination_system;
|
||||
position {
|
||||
x 960.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 166.0;
|
||||
width 272.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_manage_distress_response id "621e0e5e-d1be-4693-b95a-7c48d36e07b4" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @manage_distress_response;
|
||||
position {
|
||||
x 60.0;
|
||||
y 286.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_coordinate_rescue_response id "9f41eaff-3456-494b-b136-9f6f01afd90f" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @coordinate_rescue_response;
|
||||
position {
|
||||
x 344.0;
|
||||
y 286.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_classify_incident_and_alert_operator id "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68" {
|
||||
collapsed false;
|
||||
diagram @sa_system_boundary_and_functions;
|
||||
element @classify_incident_and_alert_operator;
|
||||
position {
|
||||
x 984.0;
|
||||
y 108.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
diagram sa_functional_decomposition "SA functional decomposition" id "a67786d6-5b08-4b06-a0c8-584a04f179ef" {
|
||||
description "A focused projection of the shared engineering model.";
|
||||
layer Sa;
|
||||
projection Decomposition;
|
||||
}
|
||||
|
||||
node view_classify_incident_and_alert_operator_5ae489f2 id "5ae489f2-4be1-4842-b464-37900983e333" {
|
||||
collapsed false;
|
||||
diagram @sa_functional_decomposition;
|
||||
element @classify_incident_and_alert_operator;
|
||||
position {
|
||||
x 84.0;
|
||||
y 108.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_manage_distress_response_80ab61e0 id "80ab61e0-d1f2-4008-9858-0759bdaa02e3" {
|
||||
collapsed false;
|
||||
diagram @sa_functional_decomposition;
|
||||
element @manage_distress_response;
|
||||
position {
|
||||
x 60.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 276.0;
|
||||
width 272.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_transmit_distress_report_e02431fd id "e02431fd-c27e-4b48-9c94-37b8e3d55375" {
|
||||
collapsed false;
|
||||
diagram @sa_functional_decomposition;
|
||||
element @transmit_distress_report;
|
||||
position {
|
||||
x 84.0;
|
||||
y 218.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
diagram oa_participants_and_activities "OA participants and activities" id "d7f59b88-4a9b-4906-b859-1c6d044bbb52" {
|
||||
description "A focused projection of the shared engineering model.";
|
||||
layer Oa;
|
||||
projection Allocation;
|
||||
}
|
||||
|
||||
node view_rescue_coordination id "2f5fed31-e2dc-419d-9ebc-a5c9e9682277" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @rescue_coordination;
|
||||
position {
|
||||
x 60.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 166.0;
|
||||
width 272.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_protect_life_at_sea id "91fe2d63-0083-4df2-9c29-563766e1b706" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @protect_life_at_sea;
|
||||
position {
|
||||
x 392.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_distress_to_response id "a045fd40-84ea-48f3-a6d4-95641492d960" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @distress_to_response;
|
||||
position {
|
||||
x 676.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_report_distress id "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @report_distress;
|
||||
position {
|
||||
x 984.0;
|
||||
y 108.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_assess_incident id "acaa5a3d-51c4-45da-b5aa-4825bccd747c" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @assess_incident;
|
||||
position {
|
||||
x 84.0;
|
||||
y 108.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_vessel_in_distress id "af3a61b3-db02-400f-bd18-b4422e2db7a7" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @vessel_in_distress;
|
||||
position {
|
||||
x 960.0;
|
||||
y 60.0;
|
||||
};
|
||||
size {
|
||||
height 166.0;
|
||||
width 272.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_respond_to_distress id "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @respond_to_distress;
|
||||
position {
|
||||
x 60.0;
|
||||
y 286.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
node view_incident_coordinator id "fcbda72a-5478-4d77-8330-16f89cdefc37" {
|
||||
collapsed false;
|
||||
diagram @oa_participants_and_activities;
|
||||
element @incident_coordinator;
|
||||
position {
|
||||
x 344.0;
|
||||
y 286.0;
|
||||
};
|
||||
size {
|
||||
height 94.0;
|
||||
width 224.0;
|
||||
};
|
||||
}
|
||||
|
||||
artifact console_enclosure_cad "Console enclosure CAD" id "091b5a40-1631-49ef-8b53-1471c016a13f" {
|
||||
description "Illustrative external engineering reference; opening a model does not fetch it.";
|
||||
fragment none;
|
||||
owner @rescue_coordination_system;
|
||||
relation References;
|
||||
revision "B.3";
|
||||
uri "cad://rescue-console/enclosure.step";
|
||||
}
|
||||
|
||||
artifact response_implementation "Response implementation" id "0b09f95e-0545-44c3-b687-84489ce1422b" {
|
||||
description "Illustrative external engineering reference; opening a model does not fetch it.";
|
||||
fragment none;
|
||||
owner @classify_incident_and_alert_operator;
|
||||
relation Implements;
|
||||
revision "design-v2";
|
||||
uri "git+https://example.org/rescue/control?path=src/response.rs";
|
||||
}
|
||||
|
||||
trace trace_respond_to_distress "Respond to distress ↔ Manage distress response" id "1d0a908f-9e22-42b7-9aac-26bbe3f5df78" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Respond to distress";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Manage distress response";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Refinement;
|
||||
oa @respond_to_distress;
|
||||
sa @manage_distress_response;
|
||||
}
|
||||
|
||||
trace trace_rescue_coordination "Rescue coordination ↔ Rescue coordination system" id "49f2b55a-6dcc-44ba-a68f-a305942db7c9" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Rescue coordination";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Rescue coordination system";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Participant;
|
||||
oa @rescue_coordination;
|
||||
sa @rescue_coordination_system;
|
||||
}
|
||||
|
||||
trace trace_distress_to_response "Distress to response ↔ Digital distress handling" id "4ac98979-6c84-4a66-b043-373b70bb5203" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Distress to response";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Digital distress handling";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Sequence;
|
||||
oa @distress_to_response;
|
||||
sa @digital_distress_handling;
|
||||
}
|
||||
|
||||
trace trace_protect_life_at_sea "Protect life at sea ↔ Coordinate rescue response" id "4b6acf23-dab3-462d-b45f-066aea916932" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Protect life at sea";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Coordinate rescue response";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Capability;
|
||||
oa @protect_life_at_sea;
|
||||
sa @coordinate_rescue_response;
|
||||
}
|
||||
|
||||
trace trace_distress_signal "Distress signal ↔ Validated distress report" id "587cca51-69e8-47e7-9674-32ff49926a22" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Distress signal";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Validated distress report";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Exchange;
|
||||
oa @distress_signal;
|
||||
sa @validated_distress_report;
|
||||
}
|
||||
|
||||
trace trace_distress_report "Distress report ↔ Digital distress report" id "7790684b-220f-443d-8754-52c29414367f" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Distress report";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Digital distress report";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Item;
|
||||
oa @distress_report;
|
||||
sa @digital_distress_report;
|
||||
}
|
||||
|
||||
trace trace_vessel_in_distress "Vessel in distress ↔ Vessel communication service" id "9a8101e4-ebce-4836-a0e3-63440c2e1e87" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Vessel in distress";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Vessel communication service";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Participant;
|
||||
oa @vessel_in_distress;
|
||||
sa @vessel_communication_service;
|
||||
}
|
||||
|
||||
trace trace_assess_incident "Assess incident ↔ Classify incident and alert operator" id "b8fee65b-21da-4a27-9b42-575d417bd942" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Assess incident";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Classify incident and alert operator";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Refinement;
|
||||
oa @assess_incident;
|
||||
sa @classify_incident_and_alert_operator;
|
||||
}
|
||||
|
||||
trace trace_distress_received "Distress received ↔ Report handled" id "be4365d6-2a43-433c-935f-c039b266242f" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Distress received";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Report handled";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Scenario;
|
||||
oa @distress_received;
|
||||
sa @report_handled;
|
||||
}
|
||||
|
||||
trace trace_report_distress "Report distress ↔ Transmit distress report" id "d3f224a5-0b2d-4eba-93c9-232b9e84e742" {
|
||||
baseline {
|
||||
oa {
|
||||
description "";
|
||||
name "Report distress";
|
||||
};
|
||||
sa {
|
||||
description "";
|
||||
name "Transmit distress report";
|
||||
};
|
||||
};
|
||||
description "Reviewed OA/SA correspondence in the coastal rescue example.";
|
||||
kind Refinement;
|
||||
oa @report_distress;
|
||||
sa @transmit_distress_report;
|
||||
}
|
||||
|
||||
}
|
||||
+1414
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use oa_core::{
|
||||
validation::{Severity, validate},
|
||||
*,
|
||||
};
|
||||
#[test]
|
||||
fn sequence_gaps_name_occurrences_exchanges_and_endpoints() {
|
||||
let mut p = Project::demo();
|
||||
let first = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let other = p.add(Kind::Activity, "Unrelated work", 0., 0.);
|
||||
let reply = p.relate(
|
||||
RelationKind::Interaction,
|
||||
"Unrelated message",
|
||||
other,
|
||||
first.source(),
|
||||
);
|
||||
let p = execute_batch(&p, &[Command::AddStep(process, reply)]).unwrap();
|
||||
let issue = validate(&p)
|
||||
.into_iter()
|
||||
.find(|i| i.code == "sequence_gap")
|
||||
.unwrap();
|
||||
assert_eq!(issue.severity, Severity::Warning);
|
||||
assert_eq!(issue.element, Some(process));
|
||||
assert!(issue.message.contains("steps 1 and 2"));
|
||||
assert!(issue.message.contains("Distress signal"));
|
||||
assert!(issue.message.contains("Unrelated message"));
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
use oa_core::{
|
||||
projection, references,
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
|
||||
fn kinds(layer: Layer) -> (Kind, Kind, Kind) {
|
||||
match layer {
|
||||
Layer::Oa => (Kind::Actor, Kind::OperationalMission, Kind::Capability),
|
||||
Layer::Sa => (Kind::SystemActor, Kind::Mission, Kind::SystemCapability),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn transition(p: &Project, request: &TransitionRequest) -> (Project, TransitionPlan) {
|
||||
let plan = plan_transition(p, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
(apply_transition(p, &plan).unwrap(), plan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_and_mission_links_enforce_direction_family_and_layer() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let (participant_kind, mission_kind, capability_kind) = kinds(layer);
|
||||
let participant = add(&mut p, participant_kind, layer, "Mission participant");
|
||||
let mission = add(&mut p, mission_kind, layer, "Protect lives");
|
||||
let capability = add(&mut p, capability_kind, layer, "Rescue");
|
||||
let other = add(&mut p, capability_kind, layer, "Locate distress");
|
||||
let opposite = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let cross_layer = add(&mut p, kinds(opposite).2, opposite, "Remote capability");
|
||||
for (kind, source, target) in [
|
||||
(RelationKind::MissionInvolvement, participant, mission),
|
||||
(RelationKind::MissionCapability, mission, capability),
|
||||
(RelationKind::CapabilityInclude, capability, other),
|
||||
(RelationKind::CapabilityExtend, capability, other),
|
||||
(RelationKind::CapabilityGeneralization, capability, other),
|
||||
] {
|
||||
assert!(validation::can_connect(&p, kind, source, target));
|
||||
let created =
|
||||
execute_batch(&p, &[Command::Connect(kind, "Link".into(), source, target)])
|
||||
.unwrap();
|
||||
let relation = created.relations.values().next().unwrap();
|
||||
assert_eq!(relation.body.endpoints(), (source, target));
|
||||
for invalid_target in [source, cross_layer] {
|
||||
assert!(!validation::can_connect(&p, kind, source, invalid_target));
|
||||
assert!(
|
||||
execute_batch(
|
||||
&p,
|
||||
&[Command::Connect(
|
||||
kind,
|
||||
"Invalid".into(),
|
||||
source,
|
||||
invalid_target
|
||||
)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
for (kind, source, target) in [
|
||||
(RelationKind::MissionInvolvement, mission, participant),
|
||||
(RelationKind::MissionInvolvement, capability, mission),
|
||||
(RelationKind::MissionCapability, capability, mission),
|
||||
(RelationKind::CapabilityInclude, mission, capability),
|
||||
(RelationKind::CapabilityExtend, participant, capability),
|
||||
(RelationKind::CapabilityGeneralization, mission, mission),
|
||||
] {
|
||||
assert!(!validation::can_connect(&p, kind, source, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inclusion_and_specialization_reject_transitive_cycles_atomically() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (kind, code) in [
|
||||
(RelationKind::CapabilityInclude, "capability_include_cycle"),
|
||||
(
|
||||
RelationKind::CapabilityGeneralization,
|
||||
"capability_generalization_cycle",
|
||||
),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let ids: Vec<_> = ["A", "B", "C"]
|
||||
.into_iter()
|
||||
.map(|name| add(&mut p, kinds(layer).2, layer, name))
|
||||
.collect();
|
||||
p.relate(kind, "A to B", ids[0], ids[1]);
|
||||
p.relate(kind, "B to C", ids[1], ids[2]);
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::Rename(ids[0], "Undoable draft".into()))
|
||||
.unwrap();
|
||||
session.undo();
|
||||
let revision = session.revision();
|
||||
assert!(!validation::can_connect(&p, kind, ids[2], ids[0]));
|
||||
let error = session
|
||||
.execute_many(&[
|
||||
Command::Rename(ids[0], "Must roll back".into()),
|
||||
Command::Connect(kind, "Would close cycle".into(), ids[2], ids[0]),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(error.0.contains(code), "{error}");
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(session.can_redo());
|
||||
// Even an imported or edited complete graph is checked, not just the connector UI.
|
||||
let closing = p.relate(kind, "Cycle", ids[2], ids[0]);
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|issue| issue.code == code && issue.element == Some(closing))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_extension_remains_editable_and_condition_is_validated() {
|
||||
let mut p = Project::empty();
|
||||
let base = add(&mut p, Kind::Capability, Layer::Oa, "Rescue");
|
||||
let extending = add(&mut p, Kind::Capability, Layer::Oa, "Night rescue");
|
||||
let mut session = Session::new(p);
|
||||
let link = session
|
||||
.execute(Command::Connect(
|
||||
RelationKind::CapabilityExtend,
|
||||
"Night assistance".into(),
|
||||
extending,
|
||||
base,
|
||||
))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
validation::validate(session.project())
|
||||
.iter()
|
||||
.any(|issue| issue.element == Some(link)
|
||||
&& issue.code == "missing_extension_condition"
|
||||
&& issue.severity == Severity::Warning)
|
||||
);
|
||||
let mut edited = session.project().relations[&link].clone();
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body {
|
||||
*condition = "daylight == false && visibility < 500".into();
|
||||
}
|
||||
session
|
||||
.execute(Command::EditRelation(link, edited.clone()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!validation::validate(session.project())
|
||||
.iter()
|
||||
.any(|issue| issue.code == "missing_extension_condition")
|
||||
);
|
||||
let accepted = session.project().clone();
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body {
|
||||
*condition = "invalid\u{0000}condition".into();
|
||||
}
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::EditRelation(link, edited))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &accepted);
|
||||
session.undo();
|
||||
assert!(
|
||||
validation::validate(session.project())
|
||||
.iter()
|
||||
.any(|issue| issue.code == "missing_extension_condition")
|
||||
);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_missions_have_typed_bidirectional_counterparts_and_legacy_choices() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let mut p = Project::empty();
|
||||
let source = add(
|
||||
&mut p,
|
||||
kinds(direction.source_layer()).1,
|
||||
direction.source_layer(),
|
||||
"Protect lives",
|
||||
);
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let (candidate, plan) = transition(&p, &request);
|
||||
let target = plan.changes[0].target;
|
||||
assert_eq!(
|
||||
candidate.elements[&target].kind(),
|
||||
kinds(direction.target_layer()).1
|
||||
);
|
||||
let trace = candidate.traces.values().next().unwrap();
|
||||
assert_eq!(trace.kind, TraceKind::Mission);
|
||||
assert_eq!(
|
||||
candidate.elements[&trace.oa].kind(),
|
||||
Kind::OperationalMission
|
||||
);
|
||||
assert_eq!(candidate.elements[&trace.sa].kind(), Kind::Mission);
|
||||
assert!(validation::trace_permits(&candidate, trace));
|
||||
assert!(
|
||||
plan_transition(&candidate, &request)
|
||||
.unwrap()
|
||||
.commands
|
||||
.is_empty()
|
||||
);
|
||||
let mut session = Session::new(p.clone());
|
||||
session.execute_many(&plan.commands).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &candidate);
|
||||
}
|
||||
assert_eq!(
|
||||
counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa),
|
||||
vec![Kind::OperationalMission, Kind::Capability]
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa),
|
||||
vec![Kind::SystemCapability, Kind::Mission]
|
||||
);
|
||||
// A previously intentional capability↔mission relation remains a manual trace and is reused.
|
||||
let mut p = Project::empty();
|
||||
let capability = add(&mut p, Kind::Capability, Layer::Oa, "User-level goal");
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![capability]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: capability,
|
||||
target: None,
|
||||
target_kind: Some(Kind::Mission),
|
||||
});
|
||||
let (candidate, plan) = transition(&p, &request);
|
||||
let mission = plan.changes[0].target;
|
||||
assert_eq!(
|
||||
candidate.traces.values().next().unwrap().kind,
|
||||
TraceKind::Manual
|
||||
);
|
||||
let reverse = plan_transition(
|
||||
&candidate,
|
||||
&TransitionRequest::new(TransitionDirection::SaToOa, vec![mission]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverse.conflicts.is_empty());
|
||||
assert_eq!(reverse.changes[0].target, capability);
|
||||
assert!(reverse.commands.is_empty());
|
||||
}
|
||||
|
||||
fn mission_map(layer: Layer) -> (Project, Vec<Id>) {
|
||||
let mut p = Project::empty();
|
||||
let (actor_kind, mission_kind, capability_kind) = kinds(layer);
|
||||
let actor = add(&mut p, actor_kind, layer, "Rescue coordinator");
|
||||
let mission = add(&mut p, mission_kind, layer, "Protect lives");
|
||||
let rescue = add(&mut p, capability_kind, layer, "Rescue");
|
||||
let locate = add(&mut p, capability_kind, layer, "Locate distress");
|
||||
let night = add(&mut p, capability_kind, layer, "Night rescue");
|
||||
let mut relations = vec![
|
||||
p.relate(
|
||||
RelationKind::MissionInvolvement,
|
||||
"Participates",
|
||||
actor,
|
||||
mission,
|
||||
),
|
||||
p.relate(
|
||||
RelationKind::MissionCapability,
|
||||
"Delivered by",
|
||||
mission,
|
||||
rescue,
|
||||
),
|
||||
p.relate(
|
||||
RelationKind::CapabilityInclude,
|
||||
"Always locate",
|
||||
rescue,
|
||||
locate,
|
||||
),
|
||||
p.relate(
|
||||
RelationKind::CapabilityGeneralization,
|
||||
"Specializes rescue",
|
||||
night,
|
||||
rescue,
|
||||
),
|
||||
];
|
||||
let extension = p.relate(
|
||||
RelationKind::CapabilityExtend,
|
||||
"Night assistance",
|
||||
night,
|
||||
rescue,
|
||||
);
|
||||
if let RelationBody::CapabilityExtend { condition, .. } =
|
||||
&mut p.relations.get_mut(&extension).unwrap().body
|
||||
{
|
||||
*condition = "light_level < 10".into();
|
||||
}
|
||||
relations.push(extension);
|
||||
(p, relations)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mission_map_transition_preserves_all_relationship_endpoints_and_conditions_both_ways() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, relations) = mission_map(direction.source_layer());
|
||||
let mut request = TransitionRequest::new(direction, relations.clone());
|
||||
request.scope = DependencyScope::Required;
|
||||
let actor = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind().is_performer())
|
||||
.unwrap()
|
||||
.id;
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: actor,
|
||||
target: None,
|
||||
target_kind: Some(kinds(direction.target_layer()).0),
|
||||
});
|
||||
let (candidate, plan) = transition(&p, &request);
|
||||
let mapped = |source| {
|
||||
plan.changes
|
||||
.iter()
|
||||
.find(|change| change.source == source)
|
||||
.unwrap()
|
||||
.target
|
||||
};
|
||||
for id in &relations {
|
||||
let original = &p.relations[id];
|
||||
let counterpart = &candidate.relations[&mapped(*id)];
|
||||
assert_eq!(counterpart.kind(), original.kind());
|
||||
assert_eq!(
|
||||
counterpart.body.endpoints(),
|
||||
(mapped(original.source()), mapped(original.target()))
|
||||
);
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &counterpart.body {
|
||||
assert_eq!(condition, "light_level < 10");
|
||||
}
|
||||
assert_eq!(
|
||||
references::relation_references(counterpart),
|
||||
vec![counterpart.source(), counterpart.target()]
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
candidate.elements.values().all(|e| e.parent.is_none()),
|
||||
"capability relations do not create containment"
|
||||
);
|
||||
assert!(
|
||||
candidate
|
||||
.traces
|
||||
.values()
|
||||
.any(|t| t.kind == TraceKind::Mission)
|
||||
);
|
||||
let mut reverse = TransitionRequest::new(
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
},
|
||||
relations.iter().map(|id| mapped(*id)).collect(),
|
||||
);
|
||||
reverse.scope = DependencyScope::Required;
|
||||
let repeated = plan_transition(&candidate, &reverse).unwrap();
|
||||
assert!(repeated.conflicts.is_empty(), "{:?}", repeated.conflicts);
|
||||
assert!(repeated.commands.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mission_reidentification_and_deletion_preserve_reference_integrity_and_undo() {
|
||||
let (mut p, relations) = mission_map(Layer::Oa);
|
||||
let mission = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::OperationalMission)
|
||||
.unwrap()
|
||||
.id;
|
||||
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Mission demonstration");
|
||||
if let Some(SharedBody::Scenario { context, .. }) =
|
||||
p.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
{
|
||||
*context = Some(mission);
|
||||
}
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(diagram, mission, Position { x: 10., y: 20. });
|
||||
let evidence = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
evidence,
|
||||
ArtifactLink {
|
||||
id: evidence,
|
||||
alias: "mission_evidence".into(),
|
||||
name: "Mission evidence".into(),
|
||||
description: String::new(),
|
||||
owner: mission,
|
||||
relation: ArtifactRelation::Verifies,
|
||||
uri: "git:reviews/mission.md".into(),
|
||||
revision: Some("review-1".into()),
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let (p, plan) = transition(
|
||||
&p,
|
||||
&TransitionRequest::new(TransitionDirection::OaToSa, vec![mission]),
|
||||
);
|
||||
let sa = plan.changes[0].target;
|
||||
let mut session = Session::new(p.clone());
|
||||
let renamed_id = Id::new_v4();
|
||||
session
|
||||
.execute(Command::Reidentify(mission, renamed_id))
|
||||
.unwrap();
|
||||
let updated = session.project();
|
||||
assert_eq!(updated.relations[&relations[0]].target(), renamed_id);
|
||||
assert_eq!(updated.relations[&relations[1]].source(), renamed_id);
|
||||
assert!(references::element_references(&updated.elements[&scenario]).contains(&renamed_id));
|
||||
assert_eq!(updated.diagrams[&diagram].nodes[&node].element, renamed_id);
|
||||
assert_eq!(updated.artifact_links[&evidence].owner, renamed_id);
|
||||
assert_eq!(updated.traces.values().next().unwrap().oa, renamed_id);
|
||||
let reidentified = updated.clone();
|
||||
session.execute(Command::Delete(renamed_id)).unwrap();
|
||||
let deleted = session.project();
|
||||
assert!(
|
||||
deleted.elements.contains_key(&sa),
|
||||
"deletion never cascades to the opposite-layer counterpart"
|
||||
);
|
||||
assert!(deleted.elements.contains_key(&scenario));
|
||||
assert!(references::element_references(&deleted.elements[&scenario]).is_empty());
|
||||
assert!(!deleted.relations.contains_key(&relations[0]));
|
||||
assert!(!deleted.relations.contains_key(&relations[1]));
|
||||
assert!(deleted.relations.contains_key(&relations[2]));
|
||||
assert!(deleted.traces.is_empty());
|
||||
assert!(!deleted.artifact_links.contains_key(&evidence));
|
||||
assert!(!deleted.diagrams[&diagram].nodes.contains_key(&node));
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &reidentified);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
// The same identity visitor covers incoming/outgoing include, extend,
|
||||
// generalization, and mission-capability endpoints.
|
||||
let capability = p.relations[&relations[1]].target();
|
||||
let updated_capability = Id::new_v4();
|
||||
session
|
||||
.execute(Command::Reidentify(capability, updated_capability))
|
||||
.unwrap();
|
||||
for original in p.relations.values() {
|
||||
let (source, target) = original.body.endpoints();
|
||||
let map = |id| {
|
||||
if id == capability {
|
||||
updated_capability
|
||||
} else {
|
||||
id
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
session.project().relations[&original.id].body.endpoints(),
|
||||
(map(source), map(target))
|
||||
);
|
||||
}
|
||||
let updated = session.project().clone();
|
||||
session
|
||||
.execute(Command::Delete(updated_capability))
|
||||
.unwrap();
|
||||
assert!(session.project().relations.values().all(|relation| {
|
||||
!references::relation_references(relation).contains(&updated_capability)
|
||||
}));
|
||||
assert!(session.project().elements.contains_key(&mission));
|
||||
assert!(session.project().relations.contains_key(&relations[0]));
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &updated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_semantics_remain_edges_independent_of_visual_containment() {
|
||||
let mut p = Project::empty();
|
||||
let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group");
|
||||
let child = p
|
||||
.add_element(
|
||||
Kind::Capability,
|
||||
Layer::Oa,
|
||||
"Specific capability",
|
||||
Some(parent),
|
||||
)
|
||||
.unwrap();
|
||||
let include = p.relate(
|
||||
RelationKind::CapabilityInclude,
|
||||
"Includes group behavior",
|
||||
child,
|
||||
parent,
|
||||
);
|
||||
let generalization = p.relate(
|
||||
RelationKind::CapabilityGeneralization,
|
||||
"Separate specialization",
|
||||
parent,
|
||||
child,
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let parent_node = p.place(diagram, parent, Position { x: 0., y: 0. });
|
||||
let child_node = p.place(diagram, child, Position { x: 25., y: 75. });
|
||||
let view = projection::project_view(&p, diagram);
|
||||
assert_eq!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == child_node)
|
||||
.unwrap()
|
||||
.visual_parent,
|
||||
Some(parent_node)
|
||||
);
|
||||
for (id, source, target) in [
|
||||
(include, child_node, parent_node),
|
||||
(generalization, parent_node, child_node),
|
||||
] {
|
||||
let edge = view.edges.iter().find(|edge| edge.id == id).unwrap();
|
||||
assert_eq!((edge.source, edge.target), (source, target));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use oa_core::{projection::*, *};
|
||||
|
||||
#[test]
|
||||
fn architecture_is_nested_exchange_context_and_ports_attach_without_placements() {
|
||||
let p = Project::demo();
|
||||
let d = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap();
|
||||
let view = project_view(&p, d.id);
|
||||
assert!(!view.nodes.is_empty());
|
||||
assert!(view.nodes.iter().all(|n| {
|
||||
let kind = p.elements[&n.element].kind();
|
||||
kind.is_performer() || kind.is_work()
|
||||
}));
|
||||
for n in view
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| p.elements[&n.element].kind().is_work())
|
||||
{
|
||||
let parent = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|parent| Some(parent.id) == n.visual_parent)
|
||||
.unwrap();
|
||||
assert!(p.elements[&parent.element].kind().is_performer());
|
||||
assert!(n.position.x > parent.position.x && n.position.y > parent.position.y);
|
||||
}
|
||||
assert_eq!(view.ports.len(), 4);
|
||||
assert!(
|
||||
d.nodes
|
||||
.values()
|
||||
.all(|n| p.elements[&n.element].kind() != Kind::Port)
|
||||
);
|
||||
assert!(
|
||||
view.edges
|
||||
.iter()
|
||||
.any(|e| p.relations[&e.id].kind() == RelationKind::BoundaryExchange)
|
||||
);
|
||||
for edge in &view.edges {
|
||||
assert!(matches!(
|
||||
p.relations[&edge.id].kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||
));
|
||||
for (id, owner) in [
|
||||
(edge.source_port, edge.source),
|
||||
(edge.target_port, edge.target),
|
||||
] {
|
||||
let port = view.ports.iter().find(|p| Some(p.element) == id).unwrap();
|
||||
assert_eq!(port.owner, owner);
|
||||
let node = view.nodes.iter().find(|n| n.id == owner).unwrap();
|
||||
assert!(
|
||||
port.position.x == node.position.x
|
||||
|| port.position.x == node.position.x + node.size.width
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_identity_collapse_and_group_movement_do_not_invent_semantic_edges() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = project_view(&p, diagram);
|
||||
let system = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| p.elements[&n.element].kind() == Kind::System)
|
||||
.unwrap();
|
||||
let node = system.id;
|
||||
let before = p.clone();
|
||||
let moved = execute_batch(
|
||||
&p,
|
||||
&[Command::MoveNode {
|
||||
diagram,
|
||||
node,
|
||||
position: Position {
|
||||
x: p.diagrams[&diagram].nodes[&node].position.x + 140.,
|
||||
y: p.diagrams[&diagram].nodes[&node].position.y + 80.,
|
||||
},
|
||||
with_descendants: true,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let after = project_view(&moved, diagram);
|
||||
for port in view.ports.iter().filter(|port| {
|
||||
port.owner == node
|
||||
|| view
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == port.owner && n.visual_parent == Some(node))
|
||||
}) {
|
||||
let shifted = after
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.element == port.element)
|
||||
.unwrap();
|
||||
assert_eq!(shifted.position.x, port.position.x + 140.);
|
||||
assert_eq!(shifted.position.y, port.position.y + 80.);
|
||||
}
|
||||
assert_eq!(moved.elements, before.elements);
|
||||
assert_eq!(moved.relations, before.relations);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let collapsed = project_view(&p, diagram);
|
||||
assert!(collapsed.ports.iter().all(|port| {
|
||||
p.elements[&port.element].parent
|
||||
!= Some(
|
||||
view.nodes
|
||||
.iter()
|
||||
.find(|n| n.visual_parent == Some(node))
|
||||
.unwrap()
|
||||
.element,
|
||||
)
|
||||
}));
|
||||
let exchange = collapsed
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| p.relations[&e.id].kind() == RelationKind::FunctionalExchange)
|
||||
.unwrap();
|
||||
assert_eq!(exchange.target, node);
|
||||
assert_eq!(
|
||||
exchange.target_port, None,
|
||||
"A collapsed link must not invent a system port"
|
||||
);
|
||||
assert_eq!(p.relations, before.relations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_filtering_keeps_placements_and_source_and_separates_capability_content() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Oa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let cap = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Capability)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.place(diagram, cap, Position { x: 600., y: 0. });
|
||||
let original = p.clone();
|
||||
assert!(
|
||||
!project_view(&p, diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.element == cap)
|
||||
);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Capabilities;
|
||||
let view = project_view(&p, diagram);
|
||||
assert!(view.nodes.iter().any(|n| n.element == cap));
|
||||
assert!(
|
||||
!view
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| p.elements[&n.element].kind().is_work())
|
||||
);
|
||||
assert_eq!(
|
||||
p.diagrams[&diagram].nodes,
|
||||
original.diagrams[&diagram].nodes
|
||||
);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use oa_core::{
|
||||
language::{self, ParseLimits},
|
||||
persistence,
|
||||
validation::Severity,
|
||||
*,
|
||||
};
|
||||
|
||||
fn limits(graph: usize, records: usize, steps: usize) -> ParseLimits {
|
||||
ParseLimits {
|
||||
max_source_bytes: 8 * 1024 * 1024,
|
||||
max_graph_objects: graph,
|
||||
max_records: records,
|
||||
max_step_references: steps,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_reports_located_engineering_warnings_without_rejecting_valid_drafts() {
|
||||
let source = "arcadia 2; project example \"航海\" {\n sa_function assess \"Assess\" {}\n}";
|
||||
assert!(language::parse(source).is_ok());
|
||||
let diagnostics = language::lint(source);
|
||||
let missing = diagnostics
|
||||
.iter()
|
||||
.find(|d| d.code == "missing_system")
|
||||
.unwrap();
|
||||
assert_eq!(missing.severity, Severity::Warning);
|
||||
let work = diagnostics
|
||||
.iter()
|
||||
.find(|d| d.code == "unallocated_leaf")
|
||||
.unwrap();
|
||||
assert_eq!(work.severity, Severity::Warning);
|
||||
assert_eq!((work.line, work.column), (2, 5));
|
||||
assert_eq!(
|
||||
&source[work.span.start..work.span.start + 11],
|
||||
"sa_function"
|
||||
);
|
||||
assert!(work.object_id.is_some());
|
||||
assert_eq!(work.field.as_deref(), Some("allocation"));
|
||||
let invalid = "arcadia 2; project example \"航海\" { oa_activity a \"A\" { parent @a; } }";
|
||||
let rejected = language::parse(invalid).unwrap_err();
|
||||
assert!(rejected.iter().all(|d| d.severity == Severity::Error));
|
||||
let parent = rejected
|
||||
.iter()
|
||||
.find(|d| d.code == "invalid_parent")
|
||||
.unwrap();
|
||||
assert_eq!(parent.span.start, invalid.find("@a").unwrap());
|
||||
assert_eq!(
|
||||
parent.column,
|
||||
invalid[..parent.span.start].chars().count() + 1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_limits_precede_reference_resolution_and_structural_validation() {
|
||||
let mut source = String::from("arcadia 2; project example \"Oversized\" {\n");
|
||||
for i in 0..12_000 {
|
||||
source.push_str(&format!("oa_activity a{i} \"A\" {{ parent @missing; }}\n"));
|
||||
}
|
||||
source.push('}');
|
||||
let rejected = language::parse_limited(&source, limits(5_000, 20_000, 50_000)).unwrap_err();
|
||||
assert_eq!(rejected[0].code, "model_limit");
|
||||
assert_eq!(rejected[0].line, 5_002);
|
||||
assert!(rejected[0].span.start < source.len() / 2);
|
||||
assert!(matches!(
|
||||
persistence::decode_limited(source.as_bytes(), limits(5_000, 20_000, 50_000)),
|
||||
Err(persistence::FileError::Limit(_))
|
||||
));
|
||||
let rejected = language::parse_limited(
|
||||
&source,
|
||||
ParseLimits {
|
||||
max_source_bytes: 20,
|
||||
..ParseLimits::UNLIMITED
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(rejected[0].code, "source_limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_record_and_step_limits_precede_model_errors_and_preserve_the_candidate() {
|
||||
let mut project = Project::empty();
|
||||
// Invalid parents would fail validation; admission must reject the count first.
|
||||
for i in 1..=6u128 {
|
||||
let id = Id::from_u128(i);
|
||||
project.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("a{i}"),
|
||||
name: "A".into(),
|
||||
description: String::new(),
|
||||
parent: Some(Id::nil()),
|
||||
body: ElementBody::Oa(OaBody::Activity),
|
||||
},
|
||||
);
|
||||
}
|
||||
let bytes = serde_json::to_vec(&project).unwrap();
|
||||
assert!(matches!(
|
||||
persistence::decode_limited(&bytes, limits(5, 100, 100)),
|
||||
Err(persistence::FileError::Limit(_))
|
||||
));
|
||||
let demo = Project::demo();
|
||||
let count = demo.identities().len();
|
||||
assert!(limits(1_000, count, 50_000).check_model(&demo).is_ok());
|
||||
assert!(limits(1_000, count - 1, 50_000).check_model(&demo).is_err());
|
||||
assert!(
|
||||
language::parse_limited(&language::format(&demo), limits(1_000, count - 1, 50_000))
|
||||
.is_err()
|
||||
);
|
||||
let source = "arcadia 2; project example \"Steps\" { oa_scenario story \"Story\" { steps [@story, @story]; } }";
|
||||
assert_eq!(
|
||||
language::parse_limited(source, limits(100, 100, 1)).unwrap_err()[0].code,
|
||||
"model_limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_budgets_include_generated_occurrences_and_placements_before_migration() {
|
||||
let source = r#"arcadia 1; project "Legacy" { activity a "A" {} activity b "B" {} interaction report "Report" from a to b; process story "Story" { steps [report, report]; } }"#;
|
||||
// Four original graph records become six after occurrence creation.
|
||||
assert_eq!(
|
||||
language::parse_limited(source, limits(5, 100, 100)).unwrap_err()[0].code,
|
||||
"model_limit"
|
||||
);
|
||||
let accepted = language::parse_limited(source, limits(6, 11, 2)).unwrap();
|
||||
assert_eq!(accepted.identities().len(), 11);
|
||||
assert!(language::parse_limited(source, limits(6, 10, 2)).is_err());
|
||||
let old = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
assert!(matches!(
|
||||
persistence::decode_limited(old, limits(1, 2, 1)),
|
||||
Err(persistence::FileError::Limit(_))
|
||||
));
|
||||
let original = persistence::decode(old).unwrap();
|
||||
let native = serde_json::to_vec(&original).unwrap();
|
||||
assert_eq!(
|
||||
persistence::decode_limited(&native, limits(5000, 20000, 50000)).unwrap(),
|
||||
original
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,756 @@
|
||||
use oa_core::{
|
||||
references,
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, &kind.to_string(), parent)
|
||||
.unwrap()
|
||||
}
|
||||
fn vertex(p: &mut Project, region: Id, kind: Kind) -> Id {
|
||||
add(p, kind, p.elements[®ion].layer(), Some(region))
|
||||
}
|
||||
fn region(p: &mut Project, owner: Id) -> Id {
|
||||
add(p, Kind::Region, p.elements[&owner].layer(), Some(owner))
|
||||
}
|
||||
fn edge(p: &mut Project, source: Id, target: Id) -> Id {
|
||||
p.relate(RelationKind::StateTransition, "Transition", source, target)
|
||||
}
|
||||
fn set_kind(p: &mut Project, id: Id, value: TransitionKind) {
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut p.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*transition_kind = value;
|
||||
}
|
||||
}
|
||||
fn set_guard(p: &mut Project, id: Id, value: &str) {
|
||||
if let RelationBody::StateTransition { guard, .. } = &mut p.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*guard = value.into();
|
||||
}
|
||||
}
|
||||
fn event(p: &mut Project, layer: Layer, body: EventBody) -> Id {
|
||||
let id = add(p, Kind::Event, layer, None);
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event { body };
|
||||
id
|
||||
}
|
||||
fn diagnostics(p: &Project, code: &str) -> Vec<validation::Issue> {
|
||||
validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.code == code)
|
||||
.collect()
|
||||
}
|
||||
fn valid(p: &Project) {
|
||||
let errors: Vec<_> = validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn rejects(p: &Project, code: &str) {
|
||||
assert!(
|
||||
diagnostics(p, code)
|
||||
.iter()
|
||||
.any(|i| i.severity == Severity::Error),
|
||||
"Expected {code}: {:?}",
|
||||
validation::validate(p)
|
||||
);
|
||||
}
|
||||
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let owner = add(
|
||||
&mut p,
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
let machine = add(&mut p, Kind::StateMachine, layer, Some(owner));
|
||||
let main = region(&mut p, machine);
|
||||
let initial = vertex(&mut p, main, Kind::Initial);
|
||||
let ready = vertex(&mut p, main, Kind::State);
|
||||
edge(&mut p, initial, ready);
|
||||
(p, owner, machine, main, ready)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_vertex_is_an_editable_draft_but_only_stable_states_own_behavior_or_regions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for kind in Kind::ALL.into_iter().filter(|k| k.is_state()) {
|
||||
let (mut p, _, _, main, _) = fixture(layer);
|
||||
if kind == Kind::Initial {
|
||||
continue;
|
||||
}
|
||||
let id = vertex(&mut p, main, kind);
|
||||
valid(&p);
|
||||
assert!(!validation::can_parent(&p, kind, layer, None));
|
||||
assert!(validation::can_parent(&p, kind, layer, Some(main)));
|
||||
assert_eq!(
|
||||
validation::can_parent(&p, Kind::Region, layer, Some(id)),
|
||||
kind.is_stable_state()
|
||||
);
|
||||
let work = add(
|
||||
&mut p,
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
RelationKind::StateAction.permits(kind, p.elements[&work].kind()),
|
||||
kind.is_stable_state()
|
||||
);
|
||||
assert_eq!(
|
||||
RelationKind::Availability.permits(p.elements[&work].kind(), kind),
|
||||
kind.is_stable_state()
|
||||
);
|
||||
if !kind.is_stable_state() {
|
||||
if let Some(SharedBody::State { entry, .. }) =
|
||||
p.elements.get_mut(&id).unwrap().shared_mut()
|
||||
{
|
||||
*entry = "Retained legacy annotation".into();
|
||||
}
|
||||
assert_eq!(diagnostics(&p, "pseudostate_behavior").len(), 1);
|
||||
valid(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_events_trigger_alternatives_and_effects_validate_without_owner_restrictions() {
|
||||
let (mut p, _, _, main, ready) = fixture(Layer::Sa);
|
||||
let end = vertex(&mut p, main, Kind::Final);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, None);
|
||||
let signal = event(&mut p, Layer::Sa, EventBody::Signal { items: vec![item] });
|
||||
let time = event(
|
||||
&mut p,
|
||||
Layer::Sa,
|
||||
EventBody::Time {
|
||||
kind: TimeEventKind::After,
|
||||
expression: "30 s".into(),
|
||||
},
|
||||
);
|
||||
let change = event(
|
||||
&mut p,
|
||||
Layer::Sa,
|
||||
EventBody::Change {
|
||||
expression: "pressure > limit".into(),
|
||||
},
|
||||
);
|
||||
let work = add(&mut p, Kind::Function, Layer::Sa, None);
|
||||
let external = add(&mut p, Kind::SystemActor, Layer::Sa, None);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"External function",
|
||||
external,
|
||||
work,
|
||||
);
|
||||
let transition = edge(&mut p, ready, end);
|
||||
if let RelationBody::StateTransition {
|
||||
triggers,
|
||||
effects,
|
||||
trigger,
|
||||
effect,
|
||||
..
|
||||
} = &mut p.relations.get_mut(&transition).unwrap().body
|
||||
{
|
||||
*triggers = vec![signal, time, change, work];
|
||||
*effects = vec![work];
|
||||
*trigger = "Any of these events".into();
|
||||
*effect = "Record the reason".into();
|
||||
}
|
||||
valid(&p);
|
||||
assert!(diagnostics(&p, "unresolved_trigger").is_empty());
|
||||
let baseline = p.clone();
|
||||
for replacement in [vec![signal, signal], vec![item], vec![Id::new_v4()]] {
|
||||
let mut invalid = baseline.clone();
|
||||
if let RelationBody::StateTransition { triggers, .. } =
|
||||
&mut invalid.relations.get_mut(&transition).unwrap().body
|
||||
{
|
||||
*triggers = replacement;
|
||||
}
|
||||
assert!(Session::try_new(invalid).is_err());
|
||||
}
|
||||
if let RelationBody::StateTransition { effects, .. } =
|
||||
&mut p.relations.get_mut(&transition).unwrap().body
|
||||
{
|
||||
*effects = vec![signal];
|
||||
}
|
||||
rejects(&p, "invalid_reference_kind");
|
||||
p = baseline.clone();
|
||||
if let Some(SharedBody::Event { body }) = p.elements.get_mut(&signal).unwrap().shared_mut() {
|
||||
*body = EventBody::Signal { items: vec![work] };
|
||||
}
|
||||
rejects(&p, "invalid_reference_kind");
|
||||
let mut p = baseline;
|
||||
let foreign = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![] });
|
||||
if let RelationBody::StateTransition { triggers, .. } =
|
||||
&mut p.relations.get_mut(&transition).unwrap().body
|
||||
{
|
||||
*triggers = vec![foreign];
|
||||
}
|
||||
rejects(&p, "invalid_reference_kind");
|
||||
for body in [
|
||||
EventBody::Time {
|
||||
kind: TimeEventKind::At,
|
||||
expression: String::new(),
|
||||
},
|
||||
EventBody::Change {
|
||||
expression: " ".into(),
|
||||
},
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
event(&mut p, Layer::Oa, body);
|
||||
valid(&p);
|
||||
assert_eq!(diagnostics(&p, "missing_event_expression").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_reference_identity_cleanup_and_undo_preserve_triggering_intent() {
|
||||
let (mut p, _, _, main, ready) = fixture(Layer::Oa);
|
||||
let end = vertex(&mut p, main, Kind::Final);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Oa, None);
|
||||
let signal = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![item] });
|
||||
let work = add(&mut p, Kind::Activity, Layer::Oa, None);
|
||||
let transition = edge(&mut p, ready, end);
|
||||
if let RelationBody::StateTransition {
|
||||
triggers,
|
||||
effects,
|
||||
guard,
|
||||
effect,
|
||||
..
|
||||
} = &mut p.relations.get_mut(&transition).unwrap().body
|
||||
{
|
||||
*triggers = vec![signal];
|
||||
*effects = vec![work];
|
||||
*guard = "accepted".into();
|
||||
*effect = "Effect annotation survives".into();
|
||||
}
|
||||
valid(&p);
|
||||
for id in [item, signal, work, transition, ready, main] {
|
||||
let mut session = Session::new(p.clone());
|
||||
let replacement = Id::new_v4();
|
||||
session
|
||||
.execute(Command::Reidentify(id, replacement))
|
||||
.unwrap();
|
||||
assert!(
|
||||
session
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.all(|e| !references::element_references(e).contains(&id))
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.all(|r| !references::relation_references(r).contains(&id))
|
||||
);
|
||||
let renamed = session.project().clone();
|
||||
session.execute(Command::Delete(replacement)).unwrap();
|
||||
valid(session.project());
|
||||
if id == signal {
|
||||
let RelationBody::StateTransition {
|
||||
trigger,
|
||||
triggers,
|
||||
effects,
|
||||
guard,
|
||||
effect,
|
||||
..
|
||||
} = &session.project().relations[&transition].body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(triggers.is_empty());
|
||||
assert!(trigger.contains(&p.elements[&signal].name));
|
||||
assert!(trigger.contains("replacement trigger"));
|
||||
assert_eq!(effects, &[work]);
|
||||
assert_eq!(guard, "accepted");
|
||||
assert_eq!(effect, "Effect annotation survives");
|
||||
assert_eq!(
|
||||
diagnostics(session.project(), "unresolved_trigger").len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
if id == transition {
|
||||
assert!(session.project().elements.contains_key(&signal));
|
||||
}
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &renamed);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &renamed);
|
||||
}
|
||||
let mut session = Session::new(p);
|
||||
let mut changed = session.project().relations[&transition].clone();
|
||||
if let RelationBody::StateTransition { trigger, .. } = &mut changed.body {
|
||||
*trigger = "Exact authored legacy trigger".into();
|
||||
}
|
||||
session
|
||||
.execute(Command::EditRelation(transition, changed))
|
||||
.unwrap();
|
||||
session.execute(Command::Delete(signal)).unwrap();
|
||||
assert!(
|
||||
matches!(&session.project().relations[&transition].body, RelationBody::StateTransition { trigger, .. } if trigger == "Exact authored legacy trigger")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_kinds_distinguish_internal_external_self_and_local_composite_routing() {
|
||||
let (mut p, _, _, main, composite) = fixture(Layer::Sa);
|
||||
let nested = region(&mut p, composite);
|
||||
let child = vertex(&mut p, nested, Kind::Mode);
|
||||
let other = vertex(&mut p, main, Kind::State);
|
||||
let local = edge(&mut p, composite, child);
|
||||
set_kind(&mut p, local, TransitionKind::Local);
|
||||
let internal = edge(&mut p, composite, composite);
|
||||
set_kind(&mut p, internal, TransitionKind::Internal);
|
||||
edge(&mut p, child, child);
|
||||
edge(&mut p, child, other);
|
||||
valid(&p);
|
||||
for (source, target, kind) in [
|
||||
(child, other, TransitionKind::Internal),
|
||||
(composite, composite, TransitionKind::Local),
|
||||
(composite, other, TransitionKind::Local),
|
||||
(child, other, TransitionKind::Local),
|
||||
] {
|
||||
let mut invalid = p.clone();
|
||||
let id = edge(&mut invalid, source, target);
|
||||
set_kind(&mut invalid, id, kind);
|
||||
rejects(&invalid, "transition_kind");
|
||||
}
|
||||
let machine = add(&mut p, Kind::StateMachine, Layer::Sa, None);
|
||||
let foreign = region(&mut p, machine);
|
||||
let foreign = vertex(&mut p, foreign, Kind::State);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
child,
|
||||
foreign
|
||||
));
|
||||
edge(&mut p, child, foreign);
|
||||
rejects(&p, "transition_machine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_final_and_terminate_reject_forbidden_routes_and_cardinality_atomically() {
|
||||
let (p, _, _, main, ready) = fixture(Layer::Oa);
|
||||
let initial = p
|
||||
.children(main)
|
||||
.find(|e| e.kind() == Kind::Initial)
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
initial,
|
||||
ready
|
||||
));
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, initial, ready);
|
||||
rejects(&invalid, "initial_transition_count");
|
||||
for kind in [Kind::Final, Kind::Terminate] {
|
||||
let mut p = p.clone();
|
||||
let terminal = vertex(&mut p, main, kind);
|
||||
edge(&mut p, ready, terminal);
|
||||
valid(&p);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
terminal,
|
||||
ready
|
||||
));
|
||||
edge(&mut p, terminal, ready);
|
||||
rejects(&p, "pseudostate_direction");
|
||||
}
|
||||
for kind in Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state() && !k.is_stable_state())
|
||||
{
|
||||
let mut p = p.clone();
|
||||
let v = if kind == Kind::Initial {
|
||||
initial
|
||||
} else {
|
||||
vertex(&mut p, main, kind)
|
||||
};
|
||||
let transition = edge(&mut p, v, v);
|
||||
rejects(&p, "pseudostate_self");
|
||||
let mut before = p.clone();
|
||||
before.relations.remove(&transition);
|
||||
let mut session = Session::new(before.clone());
|
||||
assert!(session.execute(Command::Replace(p)).is_err());
|
||||
assert_eq!(session.project(), &before);
|
||||
}
|
||||
for (prose, guard) in [("timer", ""), ("", "condition")] {
|
||||
let mut p = p.clone();
|
||||
let id = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.source() == initial)
|
||||
.unwrap()
|
||||
.id;
|
||||
if let RelationBody::StateTransition {
|
||||
trigger,
|
||||
guard: actual,
|
||||
..
|
||||
} = &mut p.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*trigger = prose.into();
|
||||
*actual = guard.into();
|
||||
}
|
||||
rejects(&p, "initial_transition");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choices_and_junctions_report_unfinished_guards_but_reject_conflicting_defaults_or_triggers() {
|
||||
let (mut p, _, _, main, ready) = fixture(Layer::Sa);
|
||||
let choice = vertex(&mut p, main, Kind::Choice);
|
||||
let junction = vertex(&mut p, main, Kind::Junction);
|
||||
let fallback = vertex(&mut p, main, Kind::State);
|
||||
edge(&mut p, ready, choice);
|
||||
let yes = edge(&mut p, choice, junction);
|
||||
set_guard(&mut p, yes, "pressure <= limit");
|
||||
let no = edge(&mut p, choice, fallback);
|
||||
set_guard(&mut p, no, "else");
|
||||
edge(&mut p, fallback, junction);
|
||||
edge(&mut p, junction, ready);
|
||||
valid(&p);
|
||||
assert!(diagnostics(&p, "missing_choice_guard").is_empty());
|
||||
let mut invalid = p.clone();
|
||||
set_guard(&mut invalid, yes, "else");
|
||||
rejects(&invalid, "choice_default");
|
||||
let mut draft = p.clone();
|
||||
set_guard(&mut draft, yes, "");
|
||||
set_guard(&mut draft, no, "unknown");
|
||||
valid(&draft);
|
||||
assert_eq!(diagnostics(&draft, "missing_choice_guard").len(), 1);
|
||||
assert_eq!(diagnostics(&draft, "missing_choice_default").len(), 1);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
fallback,
|
||||
choice
|
||||
));
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, fallback, choice);
|
||||
rejects(&invalid, "pseudostate_cardinality");
|
||||
for source in [choice, junction] {
|
||||
let mut invalid = p.clone();
|
||||
let id = invalid
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.source() == source)
|
||||
.unwrap()
|
||||
.id;
|
||||
if let RelationBody::StateTransition { trigger, .. } =
|
||||
&mut invalid.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*trigger = "after 30s".into();
|
||||
}
|
||||
rejects(&invalid, "pseudostate_trigger");
|
||||
}
|
||||
edge(&mut p, junction, fallback);
|
||||
valid(&p);
|
||||
assert_eq!(diagnostics(&p, "junction_guard").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histories_remain_per_region_with_shallow_direct_and_deep_nested_fallbacks() {
|
||||
let (mut p, _, _, main, ready) = fixture(Layer::Sa);
|
||||
let nested = region(&mut p, ready);
|
||||
let nested_state = vertex(&mut p, nested, Kind::State);
|
||||
let shallow = vertex(&mut p, main, Kind::ShallowHistory);
|
||||
let deep = vertex(&mut p, main, Kind::DeepHistory);
|
||||
assert_eq!(diagnostics(&p, "incomplete_state_vertex").len(), 2);
|
||||
let fallback = edge(&mut p, shallow, ready);
|
||||
edge(&mut p, deep, nested_state);
|
||||
valid(&p);
|
||||
let mut invalid = p.clone();
|
||||
invalid
|
||||
.relations
|
||||
.get_mut(&fallback)
|
||||
.unwrap()
|
||||
.body
|
||||
.set_endpoints(shallow, nested_state);
|
||||
rejects(&invalid, "history_scope");
|
||||
let mut invalid = p.clone();
|
||||
set_guard(&mut invalid, fallback, "resume");
|
||||
rejects(&invalid, "pseudostate_segment");
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, deep, ready);
|
||||
rejects(&invalid, "pseudostate_cardinality");
|
||||
for (kind, code) in [
|
||||
(Kind::ShallowHistory, "multiple_shallow_history"),
|
||||
(Kind::DeepHistory, "multiple_deep_history"),
|
||||
] {
|
||||
let mut invalid = p.clone();
|
||||
vertex(&mut invalid, main, kind);
|
||||
rejects(&invalid, code);
|
||||
let mut valid_other = p.clone();
|
||||
vertex(&mut valid_other, nested, kind);
|
||||
valid(&valid_other);
|
||||
}
|
||||
let outside = add(&mut p, Kind::StateMachine, Layer::Sa, None);
|
||||
let outside_region = region(&mut p, outside);
|
||||
let foreign = vertex(&mut p, outside_region, Kind::State);
|
||||
edge(&mut p, deep, foreign);
|
||||
rejects(&p, "history_scope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_join_detect_concurrency_at_the_common_composite_not_deep_immediate_regions() {
|
||||
let (mut p, _, machine, main, ready) = fixture(Layer::Sa);
|
||||
let fork = vertex(&mut p, main, Kind::Fork);
|
||||
let join = vertex(&mut p, main, Kind::Join);
|
||||
let left = vertex(&mut p, main, Kind::State);
|
||||
let left_region = region(&mut p, left);
|
||||
let left_leaf = vertex(&mut p, left_region, Kind::State);
|
||||
let right_region = region(&mut p, machine);
|
||||
let right = vertex(&mut p, right_region, Kind::State);
|
||||
let right_region = region(&mut p, right);
|
||||
let right_leaf = vertex(&mut p, right_region, Kind::State);
|
||||
edge(&mut p, ready, fork);
|
||||
let arm = edge(&mut p, fork, left_leaf);
|
||||
edge(&mut p, fork, right_leaf);
|
||||
edge(&mut p, left_leaf, join);
|
||||
edge(&mut p, right_leaf, join);
|
||||
edge(&mut p, join, ready);
|
||||
valid(&p);
|
||||
let sibling_region = region(&mut p, left);
|
||||
let sibling_leaf = vertex(&mut p, sibling_region, Kind::State);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
fork,
|
||||
sibling_leaf
|
||||
));
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, fork, sibling_leaf);
|
||||
rejects(&invalid, "orthogonal_regions");
|
||||
let mut invalid = p.clone();
|
||||
set_guard(&mut invalid, arm, "ready");
|
||||
rejects(&invalid, "pseudostate_segment");
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, fork, join);
|
||||
rejects(&invalid, "orthogonal_endpoint");
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, ready, fork);
|
||||
rejects(&invalid, "pseudostate_cardinality");
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, join, left);
|
||||
rejects(&invalid, "pseudostate_cardinality");
|
||||
let mut invalid = p.clone();
|
||||
let incoming = invalid
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.target() == join)
|
||||
.unwrap()
|
||||
.id;
|
||||
if let RelationBody::StateTransition { trigger, .. } =
|
||||
&mut invalid.relations.get_mut(&incoming).unwrap().body
|
||||
{
|
||||
*trigger = "synchronize".into();
|
||||
}
|
||||
rejects(&invalid, "pseudostate_segment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_points_enforce_inside_outside_local_routing_and_one_route_per_region() {
|
||||
let (mut p, _, machine, main, composite) = fixture(Layer::Sa);
|
||||
let outside = vertex(&mut p, main, Kind::State);
|
||||
let nested = region(&mut p, composite);
|
||||
let child = vertex(&mut p, nested, Kind::State);
|
||||
let entry = vertex(&mut p, nested, Kind::EntryPoint);
|
||||
let exit = vertex(&mut p, nested, Kind::ExitPoint);
|
||||
edge(&mut p, outside, entry);
|
||||
assert!(validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
entry,
|
||||
child
|
||||
));
|
||||
let route = edge(&mut p, entry, child);
|
||||
assert!(matches!(
|
||||
p.relations[&route].body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Local,
|
||||
..
|
||||
}
|
||||
));
|
||||
edge(&mut p, child, exit);
|
||||
edge(&mut p, exit, outside);
|
||||
valid(&p);
|
||||
for (source, target) in [
|
||||
(child, entry),
|
||||
(entry, outside),
|
||||
(outside, exit),
|
||||
(exit, child),
|
||||
] {
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
source,
|
||||
target
|
||||
));
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, source, target);
|
||||
rejects(&invalid, "boundary_transition");
|
||||
}
|
||||
let other = vertex(&mut p, nested, Kind::Mode);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::StateTransition,
|
||||
entry,
|
||||
other
|
||||
));
|
||||
let mut invalid = p.clone();
|
||||
edge(&mut invalid, entry, other);
|
||||
rejects(&invalid, "entry_point_cardinality");
|
||||
let mut invalid = p.clone();
|
||||
set_kind(&mut invalid, route, TransitionKind::External);
|
||||
rejects(&invalid, "transition_kind");
|
||||
let concurrent = region(&mut p, composite);
|
||||
let concurrent_state = vertex(&mut p, concurrent, Kind::State);
|
||||
assert_eq!(diagnostics(&p, "entry_region_default").len(), 1);
|
||||
edge(&mut p, entry, concurrent_state);
|
||||
valid(&p);
|
||||
assert!(diagnostics(&p, "entry_region_default").is_empty());
|
||||
let top_region = region(&mut p, machine);
|
||||
let top_exit = vertex(&mut p, top_region, Kind::ExitPoint);
|
||||
edge(&mut p, outside, top_exit);
|
||||
valid(&p);
|
||||
assert!(
|
||||
!diagnostics(&p, "incomplete_state_vertex")
|
||||
.iter()
|
||||
.any(|i| i.element == Some(top_exit))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachability_follows_nested_default_entry_and_reports_disconnected_vertices() {
|
||||
let (mut p, _, _, main, composite) = fixture(Layer::Oa);
|
||||
let nested = region(&mut p, composite);
|
||||
let initial = vertex(&mut p, nested, Kind::Initial);
|
||||
let child = vertex(&mut p, nested, Kind::State);
|
||||
edge(&mut p, initial, child);
|
||||
let isolated = vertex(&mut p, main, Kind::Final);
|
||||
let unreachable = diagnostics(&p, "unreachable_state");
|
||||
assert_eq!(unreachable.len(), 1);
|
||||
assert_eq!(unreachable[0].element, Some(isolated));
|
||||
edge(&mut p, child, isolated);
|
||||
valid(&p);
|
||||
assert!(diagnostics(&p, "unreachable_state").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_subtrees_and_selected_edges_transition_events_work_and_payloads_in_both_directions() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let layer = direction.source_layer();
|
||||
let (mut p, owner, machine, main, ready) = fixture(layer);
|
||||
let end = vertex(&mut p, main, Kind::Terminate);
|
||||
let vertices: Vec<_> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| {
|
||||
kind.is_state() && !matches!(kind, Kind::Initial | Kind::State | Kind::Terminate)
|
||||
})
|
||||
.map(|kind| vertex(&mut p, main, kind))
|
||||
.collect();
|
||||
let item = add(&mut p, Kind::ExchangeItem, layer, None);
|
||||
let signal = event(&mut p, layer, EventBody::Signal { items: vec![item] });
|
||||
let work = add(
|
||||
&mut p,
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
p.relate(RelationKind::Allocation, "Performs", owner, work);
|
||||
let transition = edge(&mut p, ready, end);
|
||||
if let RelationBody::StateTransition {
|
||||
triggers,
|
||||
effects,
|
||||
guard,
|
||||
..
|
||||
} = &mut p.relations.get_mut(&transition).unwrap().body
|
||||
{
|
||||
*triggers = vec![signal, work];
|
||||
*effects = vec![work];
|
||||
*guard = "accepted".into();
|
||||
}
|
||||
let action = p.relate(RelationKind::StateAction, "On entry", ready, work);
|
||||
let availability = p.relate(RelationKind::Availability, "Available", work, ready);
|
||||
valid(&p);
|
||||
for (source, scope) in [
|
||||
(machine, DependencyScope::Subtree),
|
||||
(transition, DependencyScope::Required),
|
||||
] {
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
request.scope = scope;
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: owner,
|
||||
target: None,
|
||||
target_kind: Some(if layer == Layer::Oa {
|
||||
Kind::System
|
||||
} else {
|
||||
Kind::Entity
|
||||
}),
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let candidate = execute_batch(&p, &plan.commands).unwrap();
|
||||
valid(&candidate);
|
||||
let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target;
|
||||
assert!(
|
||||
matches!(&candidate.relations[&mapped(transition)].body, RelationBody::StateTransition { triggers, effects, guard, .. } if *triggers == vec![mapped(signal), mapped(work)] && *effects == vec![mapped(work)] && guard == "accepted")
|
||||
);
|
||||
assert!(
|
||||
matches!(&candidate.elements[&mapped(signal)].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if *items == vec![mapped(item)])
|
||||
);
|
||||
if scope == DependencyScope::Subtree {
|
||||
assert!(candidate.relations.contains_key(&mapped(action)));
|
||||
for id in &vertices {
|
||||
assert_eq!(
|
||||
candidate.elements[&mapped(*id)].kind(),
|
||||
p.elements[id].kind()
|
||||
);
|
||||
assert!(
|
||||
candidate
|
||||
.traces
|
||||
.values()
|
||||
.any(|t| t.kind == TraceKind::State && (t.oa == *id || t.sa == *id))
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!plan.changes.iter().any(|c| c.source == availability));
|
||||
assert!(
|
||||
candidate
|
||||
.traces
|
||||
.values()
|
||||
.any(|t| t.kind == TraceKind::State && (t.oa == signal || t.sa == signal))
|
||||
);
|
||||
let mut session = Session::new(p.clone());
|
||||
session.execute_many(&plan.commands).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
use oa_core::{
|
||||
references,
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
|
||||
fn kinds(layer: Layer) -> (Kind, Kind) {
|
||||
match layer {
|
||||
Layer::Oa => (Kind::Process, Kind::Activity),
|
||||
Layer::Sa => (Kind::FunctionalChain, Kind::Function),
|
||||
}
|
||||
}
|
||||
fn occurrence(p: &Project, owner: Id, name: &str, body: OccurrenceBody) -> Element {
|
||||
let mut element = p
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
p.elements[&owner].layer(),
|
||||
name,
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
element
|
||||
}
|
||||
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let element = occurrence(p, owner, name, body);
|
||||
let id = element.id;
|
||||
p.elements.insert(id, element);
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn action(p: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let layer = p.elements[&owner].layer();
|
||||
let work = p.add_element(kinds(layer).1, layer, name, None).unwrap();
|
||||
let id = add_occurrence(
|
||||
p,
|
||||
owner,
|
||||
&format!("Perform {name}"),
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
p.elements.get_mut(&id).unwrap().description =
|
||||
format!("Contribution within {}", p.elements[&owner].name);
|
||||
id
|
||||
}
|
||||
fn link(p: &mut Project, source: Id, target: Id, guard: &str) -> Id {
|
||||
let id = p.relate(
|
||||
RelationKind::SequenceLink,
|
||||
"Next contribution",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
if let RelationBody::SequenceLink { guard: value, .. } =
|
||||
&mut p.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*value = guard.into();
|
||||
}
|
||||
id
|
||||
}
|
||||
fn branch_fixture(layer: Layer) -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let main = p
|
||||
.add_element(kinds(layer).0, layer, "Respond to distress", None)
|
||||
.unwrap();
|
||||
let reusable = p
|
||||
.add_element(kinds(layer).0, layer, "Prepare resources", None)
|
||||
.unwrap();
|
||||
action(&mut p, reusable, "Mobilize responder");
|
||||
let start = action(&mut p, main, "Assess distress");
|
||||
let fork = add_occurrence(
|
||||
&mut p,
|
||||
main,
|
||||
"Prepare in parallel",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Fork,
|
||||
},
|
||||
);
|
||||
let locate = action(&mut p, main, "Locate vessel");
|
||||
let reuse = add_occurrence(
|
||||
&mut p,
|
||||
main,
|
||||
"Use preparations",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(reusable),
|
||||
},
|
||||
);
|
||||
let join = add_occurrence(
|
||||
&mut p,
|
||||
main,
|
||||
"Both preparations ready",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Join,
|
||||
},
|
||||
);
|
||||
let choice = add_occurrence(
|
||||
&mut p,
|
||||
main,
|
||||
"Select response",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let direct = action(&mut p, main, "Dispatch rescue");
|
||||
let fallback = action(&mut p, main, "Request assistance");
|
||||
let merge = add_occurrence(
|
||||
&mut p,
|
||||
main,
|
||||
"Response selected",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Merge,
|
||||
},
|
||||
);
|
||||
let end = action(&mut p, main, "Confirm response");
|
||||
for (source, target, guard) in [
|
||||
(start, fork, ""),
|
||||
(fork, locate, ""),
|
||||
(fork, reuse, ""),
|
||||
(locate, join, ""),
|
||||
(reuse, join, ""),
|
||||
(join, choice, ""),
|
||||
(choice, direct, "available_resources > 0"),
|
||||
(choice, fallback, "else"),
|
||||
(direct, merge, ""),
|
||||
(fallback, merge, ""),
|
||||
(merge, end, ""),
|
||||
] {
|
||||
link(&mut p, source, target, guard);
|
||||
}
|
||||
(p, main, reusable)
|
||||
}
|
||||
fn errors(p: &Project) -> Vec<validation::Issue> {
|
||||
validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Error)
|
||||
.collect()
|
||||
}
|
||||
fn coherent_path(p: &Project) {
|
||||
// These contextual-path fixtures deliberately leave the global system boundary
|
||||
// and performer allocation open.
|
||||
let unexpected: Vec<_> = validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|issue| {
|
||||
issue.severity != Severity::Warning
|
||||
|| !matches!(issue.code.as_str(), "unallocated_leaf" | "missing_system")
|
||||
})
|
||||
.collect();
|
||||
assert!(unexpected.is_empty(), "{unexpected:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branching_reusable_processes_transition_as_contextual_topology_in_both_directions() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, main, reusable) = branch_fixture(direction.source_layer());
|
||||
coherent_path(&p);
|
||||
let mut request = TransitionRequest::new(direction, vec![main]);
|
||||
request.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let mapped = |id| {
|
||||
plan.changes
|
||||
.iter()
|
||||
.find(|change| change.source == id)
|
||||
.unwrap()
|
||||
.target
|
||||
};
|
||||
let candidate = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(
|
||||
candidate.elements[&mapped(main)].kind(),
|
||||
kinds(direction.target_layer()).0
|
||||
);
|
||||
assert_eq!(
|
||||
candidate.elements[&mapped(reusable)].kind(),
|
||||
kinds(direction.target_layer()).0
|
||||
);
|
||||
for element in p.elements.values() {
|
||||
let counterpart = &candidate.elements[&mapped(element.id)];
|
||||
assert_eq!(counterpart.description, element.description);
|
||||
let expected: Vec<_> = references::element_references(element)
|
||||
.into_iter()
|
||||
.map(mapped)
|
||||
.collect();
|
||||
assert_eq!(references::element_references(counterpart), expected);
|
||||
if element.kind() == Kind::Occurrence {
|
||||
let trace = candidate
|
||||
.traces
|
||||
.values()
|
||||
.find(|trace| trace.oa == element.id || trace.sa == element.id)
|
||||
.unwrap();
|
||||
assert_eq!(trace.kind, TraceKind::Sequence);
|
||||
}
|
||||
}
|
||||
for relation in p.relations.values() {
|
||||
let counterpart = &candidate.relations[&mapped(relation.id)];
|
||||
let mut expected = relation.body.clone();
|
||||
expected.set_endpoints(mapped(relation.source()), mapped(relation.target()));
|
||||
assert_eq!(counterpart.body, expected);
|
||||
}
|
||||
assert!(
|
||||
candidate
|
||||
.relations
|
||||
.values()
|
||||
.all(|r| r.kind() == RelationKind::SequenceLink),
|
||||
"contextual precedence never fabricates global exchanges"
|
||||
);
|
||||
let mut session = Session::new(p.clone());
|
||||
session.execute_many(&plan.commands).unwrap();
|
||||
assert_eq!(session.project(), &candidate);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &candidate);
|
||||
let mut reverse = TransitionRequest::new(
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
},
|
||||
vec![mapped(main)],
|
||||
);
|
||||
reverse.scope = DependencyScope::Required;
|
||||
let repeat = plan_transition(&candidate, &reverse).unwrap();
|
||||
assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts);
|
||||
assert!(repeat.commands.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(kinds(layer).0, layer, "Context", None)
|
||||
.unwrap();
|
||||
let a = action(&mut p, owner, "A");
|
||||
let b = action(&mut p, owner, "B");
|
||||
let wrong = action(&mut p, owner, "Wrong");
|
||||
let work = |p: &Project, id| match p.elements[&id].shared().unwrap() {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work), ..
|
||||
},
|
||||
..
|
||||
} => *work,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let global = p.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"A sends to B",
|
||||
work(&p, a),
|
||||
work(&p, b),
|
||||
);
|
||||
let exchange = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Contextual message",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(global),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
let other = p
|
||||
.add_element(kinds(layer).0, layer, "Other context", None)
|
||||
.unwrap();
|
||||
let foreign = action(&mut p, other, "Foreign");
|
||||
for (source, target) in [(a, exchange), (exchange, b)] {
|
||||
assert!(validation::can_connect(
|
||||
&p,
|
||||
RelationKind::SequenceLink,
|
||||
source,
|
||||
target
|
||||
));
|
||||
}
|
||||
for (source, target) in [(wrong, exchange), (exchange, wrong), (a, foreign), (a, a)] {
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::SequenceLink,
|
||||
source,
|
||||
target
|
||||
));
|
||||
assert!(
|
||||
execute_batch(
|
||||
&p,
|
||||
&[Command::Connect(
|
||||
RelationKind::SequenceLink,
|
||||
"Invalid".into(),
|
||||
source,
|
||||
target
|
||||
)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let original_exchange = p.relations[&global].clone();
|
||||
let accepted = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::Connect(RelationKind::SequenceLink, "Before".into(), a, exchange),
|
||||
Command::Connect(RelationKind::SequenceLink, "After".into(), exchange, b),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accepted.relations[&global], original_exchange);
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, layer, "Timeline", None)
|
||||
.unwrap();
|
||||
let invalid = occurrence(
|
||||
&p,
|
||||
scenario,
|
||||
"Wrong control context",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Fork,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
execute_batch(&p, &[Command::CreateElement(invalid)])
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("sequence_contribution_owner")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reuse_rejects_recursive_or_cross_layer_targets_and_deleted_targets_become_drafts() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Process, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Process, Layer::Oa, "B", None).unwrap();
|
||||
let c = p.add_element(Kind::Process, Layer::Oa, "C", None).unwrap();
|
||||
let use_b = add_occurrence(
|
||||
&mut p,
|
||||
a,
|
||||
"Reuse B",
|
||||
OccurrenceBody::SequenceReference { sequence: Some(b) },
|
||||
);
|
||||
add_occurrence(
|
||||
&mut p,
|
||||
b,
|
||||
"Reuse C",
|
||||
OccurrenceBody::SequenceReference { sequence: Some(c) },
|
||||
);
|
||||
let foreign = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "SA sequence", None)
|
||||
.unwrap();
|
||||
let mut session = Session::new(p.clone());
|
||||
for target in [a, c, foreign] {
|
||||
let invalid = occurrence(
|
||||
session.project(),
|
||||
c,
|
||||
"Invalid reuse",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(target),
|
||||
},
|
||||
);
|
||||
assert!(session.execute(Command::CreateElement(invalid)).is_err());
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
let new_b = Id::new_v4();
|
||||
session.execute(Command::Reidentify(b, new_b)).unwrap();
|
||||
assert!(references::element_references(&session.project().elements[&use_b]).contains(&new_b));
|
||||
let reidentified = session.project().clone();
|
||||
session.execute(Command::Delete(new_b)).unwrap();
|
||||
assert!(session.project().elements.contains_key(&use_b));
|
||||
assert!(matches!(
|
||||
session.project().elements[&use_b].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: None },
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(session.project().elements.contains_key(&c));
|
||||
assert!(errors(session.project()).is_empty());
|
||||
assert!(
|
||||
validation::validate(session.project())
|
||||
.iter()
|
||||
.any(|issue| issue.element == Some(use_b) && issue.code == "incomplete_reference")
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &reidentified);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_are_allowed_while_missing_guards_and_disconnected_paths_are_diagnosed() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Retry until ready", None)
|
||||
.unwrap();
|
||||
let start = action(&mut p, owner, "Start");
|
||||
let gate = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Ready?",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Loop,
|
||||
},
|
||||
);
|
||||
let repeat = action(&mut p, owner, "Retry");
|
||||
let end = action(&mut p, owner, "Finish");
|
||||
link(&mut p, start, gate, "");
|
||||
link(&mut p, gate, repeat, "!ready");
|
||||
link(&mut p, repeat, gate, "");
|
||||
let exit = link(&mut p, gate, end, "ready");
|
||||
coherent_path(&p);
|
||||
if let RelationBody::SequenceLink { guard, .. } = &mut p.relations.get_mut(&exit).unwrap().body
|
||||
{
|
||||
guard.clear();
|
||||
}
|
||||
assert!(errors(&p).is_empty());
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.element == Some(exit) && i.code == "missing_sequence_guard")
|
||||
);
|
||||
let island = action(&mut p, owner, "Disconnected work");
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.element == Some(island) && i.code == "isolated_contribution")
|
||||
);
|
||||
p.relations.remove(&exit);
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.element == Some(gate) && i.code == "control_cardinality")
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.element == Some(gate) && i.code == "nonterminating_contribution")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_identity_edits_and_deletions_preserve_remaining_paths_and_undo() {
|
||||
let (p, main, _) = branch_fixture(Layer::Sa);
|
||||
for id in p.elements.keys().chain(p.relations.keys()).copied() {
|
||||
let mut session = Session::new(p.clone());
|
||||
let replacement = Id::new_v4();
|
||||
session
|
||||
.execute(Command::Reidentify(id, replacement))
|
||||
.unwrap();
|
||||
let reidentified = session.project().clone();
|
||||
assert!(
|
||||
!reidentified
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| references::element_references(e).contains(&id))
|
||||
);
|
||||
assert!(
|
||||
!reidentified
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| references::relation_references(r).contains(&id))
|
||||
);
|
||||
session.execute(Command::Delete(replacement)).unwrap();
|
||||
assert!(errors(session.project()).is_empty());
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &reidentified);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
// List order is presentation order: changing it leaves explicit links untouched.
|
||||
let mut session = Session::new(p.clone());
|
||||
session.execute(Command::MoveStep(main, 0, 4)).unwrap();
|
||||
assert_eq!(session.project().relations, p.relations);
|
||||
coherent_path(session.project());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_linear_exchange_only_sequences_keep_their_existing_meaning() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
|
||||
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
|
||||
let owner = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Legacy order", None)
|
||||
.unwrap();
|
||||
let ab = p.relate(RelationKind::Interaction, "AB", a, b);
|
||||
let ca = p.relate(RelationKind::Interaction, "CA", c, a);
|
||||
let mut session = Session::new(p);
|
||||
session.execute(Command::AddStep(owner, ab)).unwrap();
|
||||
session.execute(Command::AddStep(owner, ca)).unwrap();
|
||||
let issues = validation::validate(session.project());
|
||||
assert!(issues.iter().any(|i| i.code == "sequence_gap"));
|
||||
assert!(
|
||||
!issues
|
||||
.iter()
|
||||
.any(|i| i.code == "isolated_contribution" || i.code == "sequence_starts")
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.all(|r| r.kind() != RelationKind::SequenceLink)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
use oa_core::{
|
||||
persistence::{self, FileError},
|
||||
transitions::*,
|
||||
*,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn unowned_state_machine_is_valid_incomplete_work_in_both_layers() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let p = Project::empty();
|
||||
assert!(validation::can_parent(&p, Kind::StateMachine, layer, None));
|
||||
let machine = p
|
||||
.make_element(Kind::StateMachine, layer, "Draft machine", None)
|
||||
.unwrap();
|
||||
let id = machine.id;
|
||||
let created = execute_batch(&p, &[Command::CreateElement(machine)]).unwrap();
|
||||
let issues = validation::validate(&created);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.all(|i| i.severity != validation::Severity::Error)
|
||||
);
|
||||
assert!(issues.iter().any(|i| i.element == Some(id)
|
||||
&& i.code == "machine_owner"
|
||||
&& i.severity == validation::Severity::Warning));
|
||||
assert!(!validation::can_parent(&created, Kind::Region, layer, None));
|
||||
}
|
||||
}
|
||||
|
||||
fn placement_fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let element = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Placed work", None)
|
||||
.unwrap();
|
||||
let source = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let target = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(source, element, Position { x: 123., y: -42. });
|
||||
let placement = p
|
||||
.diagrams
|
||||
.get_mut(&source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap();
|
||||
placement.collapsed = true;
|
||||
placement.size = Size {
|
||||
width: 350.,
|
||||
height: 135.,
|
||||
};
|
||||
let artifact = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
artifact,
|
||||
ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "placement_evidence".into(),
|
||||
name: "Placement evidence".into(),
|
||||
description: "Retained with the same placement".into(),
|
||||
owner: node,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "git:designs/layout#view".into(),
|
||||
revision: Some("layout-v2".into()),
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
(p, source, target, node)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocating_a_placement_preserves_identity_artifacts_and_history() {
|
||||
let (p, source, target, node) = placement_fixture();
|
||||
let original = p.diagrams[&source].nodes[&node].clone();
|
||||
let command = Command::RelocateNode {
|
||||
node,
|
||||
diagram: target,
|
||||
};
|
||||
let json = serde_json::to_string(&command).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Command>(&json).unwrap(), command);
|
||||
let mut session = Session::new(p.clone());
|
||||
assert_eq!(session.execute(command.clone()).unwrap(), Some(node));
|
||||
assert!(
|
||||
!session.project().diagrams[&source]
|
||||
.nodes
|
||||
.contains_key(&node)
|
||||
);
|
||||
assert_eq!(session.project().diagrams[&target].nodes[&node], original);
|
||||
assert_eq!(session.project().artifact_links, p.artifact_links);
|
||||
assert_eq!(session.project().elements, p.elements);
|
||||
let revision = session.revision();
|
||||
let relocated = session.project().clone();
|
||||
session.execute(command).unwrap();
|
||||
assert_eq!(session.revision(), revision);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &relocated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_placement_relocations_preserve_model_artifacts_and_redo() {
|
||||
let (mut p, source, target, node) = placement_fixture();
|
||||
let element = p.diagrams[&source].nodes[&node].element;
|
||||
p.place(target, element, Position { x: 0., y: 0. });
|
||||
let incompatible = p.ensure_diagram(Layer::Sa);
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::Rename(element, "Pending redo".into()))
|
||||
.unwrap();
|
||||
session.undo();
|
||||
let revision = session.revision();
|
||||
for diagram in [target, incompatible, Id::new_v4()] {
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::RelocateNode { node, diagram })
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(session.can_redo());
|
||||
}
|
||||
assert!(
|
||||
execute_batch(
|
||||
&p,
|
||||
&[Command::RelocateNode {
|
||||
node: Id::new_v4(),
|
||||
diagram: source
|
||||
}]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut traceability = p.diagrams[&incompatible].clone();
|
||||
traceability.projection = ProjectionKind::Traceability;
|
||||
let accepted = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::EditDiagram(incompatible, traceability),
|
||||
Command::RelocateNode {
|
||||
node,
|
||||
diagram: incompatible,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
accepted.diagrams[&incompatible].nodes[&node],
|
||||
p.diagrams[&source].nodes[&node]
|
||||
);
|
||||
assert_eq!(accepted.artifact_links, p.artifact_links);
|
||||
}
|
||||
|
||||
fn reviewed_divergence(direction: TransitionDirection) -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational assessment", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "System classification", None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&oa).unwrap().description = "Operational intent".into();
|
||||
p.elements.get_mut(&sa).unwrap().description = "Technical responsibility".into();
|
||||
let (source, target) = match direction {
|
||||
TransitionDirection::OaToSa => (oa, sa),
|
||||
TransitionDirection::SaToOa => (sa, oa),
|
||||
};
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
request.resolutions.push(FieldResolution {
|
||||
source,
|
||||
field,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
});
|
||||
}
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let reviewed = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(reviewed.properties(source), p.properties(source));
|
||||
assert_eq!(reviewed.properties(target), p.properties(target));
|
||||
(reviewed, source, target)
|
||||
}
|
||||
|
||||
fn reverse(direction: TransitionDirection) -> TransitionDirection {
|
||||
match direction {
|
||||
TransitionDirection::OaToSa => TransitionDirection::SaToOa,
|
||||
TransitionDirection::SaToOa => TransitionDirection::OaToSa,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_divergent_values_is_idempotent_in_both_directions() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, source, target) = reviewed_divergence(direction);
|
||||
for (direction, source) in [(direction, source), (reverse(direction), target)] {
|
||||
let plan =
|
||||
plan_transition(&p, &TransitionRequest::new(direction, vec![source])).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
|
||||
assert!(
|
||||
plan.changes
|
||||
.iter()
|
||||
.all(|c| c.action == TransitionAction::Reuse && c.fields.is_empty())
|
||||
);
|
||||
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_source_changes_propagate_without_overwriting_other_reviewed_fields() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, source, target) = reviewed_divergence(direction);
|
||||
let target_description = p.elements[&target].description.clone();
|
||||
let edited =
|
||||
execute_batch(&p, &[Command::Rename(source, "New source decision".into())]).unwrap();
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(&edited, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes[0].fields.len(), 1);
|
||||
assert_eq!(plan.changes[0].fields[0].field, TransitionField::Name);
|
||||
let applied = apply_transition(&edited, &plan).unwrap();
|
||||
assert_eq!(applied.elements[&target].name, "New source decision");
|
||||
assert_eq!(applied.elements[&target].description, target_description);
|
||||
let repeat = plan_transition(&applied, &request).unwrap();
|
||||
assert!(repeat.conflicts.is_empty());
|
||||
assert!(repeat.commands.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_only_changes_remain_available_for_reverse_propagation() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, source, target) = reviewed_divergence(direction);
|
||||
let edited = execute_batch(
|
||||
&p,
|
||||
&[Command::Rename(target, "Destination local change".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let forward =
|
||||
plan_transition(&edited, &TransitionRequest::new(direction, vec![source])).unwrap();
|
||||
assert!(forward.conflicts.is_empty(), "{:?}", forward.conflicts);
|
||||
assert!(
|
||||
forward.commands.is_empty(),
|
||||
"A skipped field must retain its previous baseline"
|
||||
);
|
||||
assert_eq!(apply_transition(&edited, &forward).unwrap(), edited);
|
||||
let back = plan_transition(
|
||||
&edited,
|
||||
&TransitionRequest::new(reverse(direction), vec![target]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(back.conflicts.is_empty(), "{:?}", back.conflicts);
|
||||
let applied = apply_transition(&edited, &back).unwrap();
|
||||
assert_eq!(applied.elements[&source].name, "Destination local change");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_changes_on_both_sides_still_require_review() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, source, target) = reviewed_divergence(direction);
|
||||
let changed = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::Rename(source, "Changed source".into()),
|
||||
Command::Rename(target, "Changed target".into()),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(&changed, &request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.any(|c| c.field == Some(TransitionField::Name))
|
||||
);
|
||||
assert!(apply_transition(&changed, &plan).is_err());
|
||||
assert_eq!(changed.elements[&target].name, "Changed target");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_merged_destination_stays_reviewed_on_repeated_preview() {
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let (p, source, target) = reviewed_divergence(direction);
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
request.resolutions.push(FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::Value("Reviewed merged name".into()),
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty());
|
||||
let applied = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(applied.elements[&target].name, "Reviewed merged name");
|
||||
let repeated =
|
||||
plan_transition(&applied, &TransitionRequest::new(direction, vec![source])).unwrap();
|
||||
assert!(repeated.conflicts.is_empty());
|
||||
assert!(repeated.commands.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_parent_reparent_preserves_sequence_order_revision_and_redo() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
|
||||
let exchange = p.relate(RelationKind::Interaction, "Message", a, b);
|
||||
let sequence = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Procedure", None)
|
||||
.unwrap();
|
||||
let mut session = Session::new(p);
|
||||
for _ in 0..3 {
|
||||
session
|
||||
.execute(Command::AddStep(sequence, exchange))
|
||||
.unwrap();
|
||||
}
|
||||
let first = session.project().elements[&sequence].steps().unwrap()[0];
|
||||
session
|
||||
.execute(Command::Rename(sequence, "Temporary name".into()))
|
||||
.unwrap();
|
||||
session.undo();
|
||||
let before = session.project().clone();
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::Reparent {
|
||||
element: first,
|
||||
parent: Some(sequence),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(session.can_redo());
|
||||
}
|
||||
|
||||
fn fragment_session(layer: Layer) -> (Session, Id) {
|
||||
let mut p = Project::empty();
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, layer, "Scenario", None)
|
||||
.unwrap();
|
||||
let mut fragment = p
|
||||
.make_element(Kind::Occurrence, layer, "Alternative", Some(scenario))
|
||||
.unwrap();
|
||||
*fragment.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let id = fragment.id;
|
||||
let mut session = Session::new(p);
|
||||
session.execute(Command::CreateElement(fragment)).unwrap();
|
||||
(session, id)
|
||||
}
|
||||
|
||||
fn branches(p: &Project, fragment: Id) -> &Vec<Id> {
|
||||
match p.elements[&fragment].shared().unwrap() {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
} => branches,
|
||||
_ => panic!("Expected fragment"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_parent_reparent_preserves_fragment_branch_order_and_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut session, fragment) = fragment_session(layer);
|
||||
for name in ["First branch", "Second branch", "Third branch"] {
|
||||
session
|
||||
.execute(Command::AddElement {
|
||||
kind: Kind::Occurrence,
|
||||
layer,
|
||||
name: name.into(),
|
||||
parent: Some(fragment),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let first = branches(session.project(), fragment)[0];
|
||||
session
|
||||
.execute(Command::Rename(fragment, "Temporary name".into()))
|
||||
.unwrap();
|
||||
session.undo();
|
||||
let before = session.project().clone();
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::Reparent {
|
||||
element: first,
|
||||
parent: Some(fragment),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(session.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_and_add_commands_share_fragment_branch_defaults() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut session, fragment) = fragment_session(layer);
|
||||
let child = session
|
||||
.project()
|
||||
.make_element(Kind::Occurrence, layer, "Factory branch", Some(fragment))
|
||||
.unwrap();
|
||||
let factory_id = child.id;
|
||||
assert!(matches!(
|
||||
child.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
session.execute(Command::CreateElement(child)).unwrap();
|
||||
let added_id = session
|
||||
.execute(Command::AddElement {
|
||||
kind: Kind::Occurrence,
|
||||
layer,
|
||||
name: "Command branch".into(),
|
||||
parent: Some(fragment),
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project().elements[&factory_id].body,
|
||||
session.project().elements[&added_id].body
|
||||
);
|
||||
assert_eq!(
|
||||
branches(session.project(), fragment),
|
||||
&vec![factory_id, added_id]
|
||||
);
|
||||
let step = session
|
||||
.project()
|
||||
.make_element(Kind::Occurrence, layer, "Branch message", Some(factory_id))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
step.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
session.execute(Command::CreateElement(step)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_save_returns_exact_bytes_and_accepts_unchanged_external_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["project.arcadia", "project.oa.json"] {
|
||||
let path = dir.path().join(name);
|
||||
let mut p = Project::empty();
|
||||
let previous = persistence::save_checked(&path, &p, None).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), previous);
|
||||
p.description = "A checked update with Unicode 海岸 and a newline\n".into();
|
||||
let written = persistence::save_checked(&path, &p, Some(&previous)).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), written);
|
||||
assert_eq!(persistence::decode(&written).unwrap(), p);
|
||||
let same = persistence::save_checked(&path, &p, Some(&written)).unwrap();
|
||||
assert_eq!(same, written);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), written);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_save_rejects_external_changes_and_preserves_their_exact_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["project.arcadia", "project.oa.json"] {
|
||||
let path = dir.path().join(name);
|
||||
let mut p = Project::empty();
|
||||
let previous = persistence::save_checked(&path, &p, None).unwrap();
|
||||
let external = b"External editor content, including an unfinished draft\r\n";
|
||||
std::fs::write(&path, external).unwrap();
|
||||
p.name = "Local unsaved changes".into();
|
||||
assert!(matches!(
|
||||
persistence::save_checked(&path, &p, Some(&previous)),
|
||||
Err(FileError::Conflict)
|
||||
));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), external);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_save_rejects_external_deletion_without_recreating_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["project.arcadia", "project.oa.json"] {
|
||||
let path = dir.path().join(name);
|
||||
let p = Project::empty();
|
||||
let previous = persistence::save_checked(&path, &p, None).unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(matches!(
|
||||
persistence::save_checked(&path, &p, Some(&previous)),
|
||||
Err(FileError::Conflict)
|
||||
));
|
||||
assert!(!path.exists());
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_mission_transition_remains_an_explicit_choice_in_both_directions() {
|
||||
assert_eq!(
|
||||
counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa),
|
||||
vec![Kind::SystemCapability, Kind::Mission]
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa),
|
||||
vec![Kind::OperationalMission, Kind::Capability]
|
||||
);
|
||||
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
|
||||
let mut p = Project::empty();
|
||||
let (kind, layer) = match direction {
|
||||
TransitionDirection::OaToSa => (Kind::Capability, Layer::Oa),
|
||||
TransitionDirection::SaToOa => (Kind::Mission, Layer::Sa),
|
||||
};
|
||||
let source = p.add_element(kind, layer, "Protect life", None).unwrap();
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
let default_plan = plan_transition(&p, &request).unwrap();
|
||||
let default = apply_transition(&p, &default_plan).unwrap();
|
||||
assert_eq!(
|
||||
default.elements[&default_plan.changes[0].target].kind(),
|
||||
Kind::SystemCapability
|
||||
);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: None,
|
||||
target_kind: Some(Kind::Mission),
|
||||
});
|
||||
} else {
|
||||
let default_plan = plan_transition(&p, &request).unwrap();
|
||||
let default = apply_transition(&p, &default_plan).unwrap();
|
||||
assert_eq!(
|
||||
default.elements[&default_plan.changes[0].target].kind(),
|
||||
Kind::OperationalMission
|
||||
);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: None,
|
||||
target_kind: Some(Kind::Capability),
|
||||
});
|
||||
}
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let target = plan.changes[0].target;
|
||||
let applied = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(
|
||||
applied.elements[&target].kind(),
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
Kind::Mission
|
||||
} else {
|
||||
Kind::Capability
|
||||
}
|
||||
);
|
||||
assert_eq!(applied.traces.len(), 1);
|
||||
assert_eq!(
|
||||
applied.traces.values().next().unwrap().kind,
|
||||
TraceKind::Manual
|
||||
);
|
||||
for (direction, source) in [(direction, source), (reverse(direction), target)] {
|
||||
let repeat =
|
||||
plan_transition(&applied, &TransitionRequest::new(direction, vec![source]))
|
||||
.unwrap();
|
||||
assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts);
|
||||
assert!(repeat.commands.is_empty());
|
||||
assert_eq!(apply_transition(&applied, &repeat).unwrap(), applied);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
use oa_core::{
|
||||
projection,
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
fn accepted(p: &Project) {
|
||||
let errors: Vec<_> = validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn rejected(p: &Project, code: &str) {
|
||||
assert!(
|
||||
validation::validate(p)
|
||||
.iter()
|
||||
.any(|i| i.severity == Severity::Error && i.code == code),
|
||||
"Expected {code}: {:?}",
|
||||
validation::validate(p)
|
||||
);
|
||||
}
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, name, parent).unwrap()
|
||||
}
|
||||
fn work_pair() -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None);
|
||||
let sa = add(&mut p, Kind::Function, Layer::Sa, "Classify", None);
|
||||
(p, oa, sa)
|
||||
}
|
||||
fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let t = Trace {
|
||||
id,
|
||||
alias: p.fresh_alias("work_trace", "trace", id),
|
||||
name: "Refinement".into(),
|
||||
description: "Rationale".into(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
}),
|
||||
};
|
||||
p.traces.insert(id, t);
|
||||
id
|
||||
}
|
||||
fn artifact(p: &mut Project, owner: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias("requirement", "artifact", id),
|
||||
name: "REQ-42".into(),
|
||||
description: "Engineering requirement".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::Satisfies,
|
||||
uri: "requirements+custom://project/REQ-42?view=full".into(),
|
||||
revision: Some("baseline-2026/September".into()),
|
||||
fragment: Some("verification".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_is_valid_and_preserves_uuid_and_alias_through_json() {
|
||||
let p = Project::demo();
|
||||
accepted(&p);
|
||||
let bytes = serde_json::to_vec(&p).unwrap();
|
||||
assert_eq!(serde_json::from_slice::<Project>(&bytes).unwrap(), p);
|
||||
assert!(
|
||||
p.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.steps()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|id| p.elements[id].kind() == Kind::Occurrence)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn failed_batch_preserves_model_history_revision_and_redo() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Reporter", None);
|
||||
let mut s = Session::new(p);
|
||||
s.execute(Command::Rename(oa, "Updated".into())).unwrap();
|
||||
s.undo();
|
||||
let before = s.project().clone();
|
||||
let revision = s.revision();
|
||||
let result = s.execute_many(&[
|
||||
Command::Rename(oa, "Would be lost".into()),
|
||||
Command::Connect(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Invalid".into(),
|
||||
actor,
|
||||
oa,
|
||||
),
|
||||
]);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.project(), &before);
|
||||
assert_eq!(s.revision(), revision);
|
||||
assert!(s.can_redo());
|
||||
}
|
||||
#[test]
|
||||
fn batch_is_one_undo_and_noop_does_not_change_revision() {
|
||||
let (p, oa, _) = work_pair();
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute_many(&[
|
||||
Command::Rename(oa, "Renamed".into()),
|
||||
Command::Describe(oa, "Detailed".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(s.revision(), 1);
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
assert!(!s.dirty());
|
||||
let revision = s.revision();
|
||||
s.execute_many(&[]).unwrap();
|
||||
assert_eq!(s.revision(), revision);
|
||||
assert!(s.can_redo());
|
||||
}
|
||||
#[test]
|
||||
fn identity_aliases_and_map_keys_share_global_namespace() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(d, oa, Position { x: 0., y: 0. });
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.alias = p.elements[&oa].alias.clone();
|
||||
rejected(&p, "duplicate_alias");
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.alias = "placed_assess".into();
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.id = oa;
|
||||
rejected(&p, "duplicate_identity");
|
||||
rejected(&p, "map_identity");
|
||||
}
|
||||
#[test]
|
||||
fn rename_and_collision_insertion_preserve_existing_aliases() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let alias = p.elements[&oa].alias.clone();
|
||||
p = execute_batch(
|
||||
&p,
|
||||
&[Command::Rename(oa, "Completely new display name".into())],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.elements[&oa].alias, alias);
|
||||
let first = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None);
|
||||
let first_alias = p.elements[&first].alias.clone();
|
||||
let second = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None);
|
||||
assert_eq!(p.elements[&first].alias, first_alias);
|
||||
assert_ne!(p.elements[&second].alias, first_alias);
|
||||
accepted(&p);
|
||||
}
|
||||
#[test]
|
||||
fn nested_work_is_independent_of_leaf_allocation() {
|
||||
let mut p = Project::empty();
|
||||
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None);
|
||||
let parent = add(&mut p, Kind::Activity, Layer::Oa, "Parent", None);
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(parent));
|
||||
p.relate(RelationKind::Allocation, "Performs", actor, child);
|
||||
accepted(&p);
|
||||
assert_eq!(validation::allocation_summary(&p, parent), Some(actor));
|
||||
let before = p.clone();
|
||||
assert!(
|
||||
execute_batch(
|
||||
&p,
|
||||
&[Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Invalid parent allocation".into(),
|
||||
actor,
|
||||
parent
|
||||
)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
let other = add(
|
||||
&mut p,
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Unallocated child",
|
||||
Some(parent),
|
||||
);
|
||||
assert!(p.elements.contains_key(&other));
|
||||
assert_eq!(validation::allocation_summary(&p, parent), None);
|
||||
}
|
||||
#[test]
|
||||
fn parent_cycles_cross_layer_ownership_and_multiple_performers_reject() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
p.elements.get_mut(&oa).unwrap().parent = Some(sa);
|
||||
rejected(&p, "invalid_parent");
|
||||
p.elements.get_mut(&oa).unwrap().parent = None;
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
|
||||
p.elements.get_mut(&oa).unwrap().parent = Some(child);
|
||||
rejected(&p, "containment_cycle");
|
||||
p.elements.get_mut(&oa).unwrap().parent = None;
|
||||
let a = add(&mut p, Kind::Actor, Layer::Oa, "A", None);
|
||||
let b = add(&mut p, Kind::Actor, Layer::Oa, "B", None);
|
||||
p.relate(RelationKind::Allocation, "A performs", a, child);
|
||||
p.relate(RelationKind::Allocation, "B performs", b, child);
|
||||
rejected(&p, "multiple_performers");
|
||||
}
|
||||
#[test]
|
||||
fn parent_and_endpoint_queries_match_creation_rules() {
|
||||
let mut p = Project::empty();
|
||||
let system = add(&mut p, Kind::System, Layer::Sa, "System", None);
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
assert!(validation::can_parent(
|
||||
&p,
|
||||
Kind::Port,
|
||||
Layer::Sa,
|
||||
Some(system)
|
||||
));
|
||||
assert!(!validation::can_parent(&p, Kind::Port, Layer::Sa, None));
|
||||
assert!(!validation::can_parent(
|
||||
&p,
|
||||
Kind::Port,
|
||||
Layer::Oa,
|
||||
Some(system)
|
||||
));
|
||||
assert!(validation::can_connect(
|
||||
&p,
|
||||
RelationKind::Allocation,
|
||||
system,
|
||||
function
|
||||
));
|
||||
p.relate(RelationKind::Allocation, "Performs", system, function);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::Allocation,
|
||||
system,
|
||||
function
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn create_occurrence_attaches_once_and_repeats_exchange_by_distinct_ids() {
|
||||
let p = Project::demo();
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let exchange = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.id;
|
||||
let p = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::AddStep(process, exchange),
|
||||
Command::AddStep(process, exchange),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let steps = p.elements[&process].steps().unwrap();
|
||||
assert_eq!(steps.len(), 3);
|
||||
assert_ne!(steps[0], steps[1]);
|
||||
assert_ne!(steps[1], steps[2]);
|
||||
accepted(&p);
|
||||
let mut bad = p.clone();
|
||||
bad.elements
|
||||
.get_mut(&process)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(steps[0]);
|
||||
rejected(&bad, "duplicate_reference");
|
||||
}
|
||||
#[test]
|
||||
fn scenario_lifelines_and_occurrences_require_same_context() {
|
||||
let mut p = Project::empty();
|
||||
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None);
|
||||
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Scenario", None);
|
||||
let other = add(&mut p, Kind::Scenario, Layer::Oa, "Other scenario", None);
|
||||
let line = add(&mut p, Kind::Lifeline, Layer::Oa, "Actor line", Some(other));
|
||||
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(actor),
|
||||
};
|
||||
let action = add(&mut p, Kind::Activity, Layer::Oa, "Action", None);
|
||||
let mut occurrence = p
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Action occurrence",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(action),
|
||||
lifeline: Some(line),
|
||||
},
|
||||
precondition: "Before".into(),
|
||||
postcondition: "After".into(),
|
||||
};
|
||||
p.elements.insert(occurrence.id, occurrence);
|
||||
rejected(&p, "lifeline_context");
|
||||
}
|
||||
#[test]
|
||||
fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
let mut p = Project::empty();
|
||||
let system = add(&mut p, Kind::System, Layer::Sa, "System", None);
|
||||
let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None);
|
||||
let f1 = add(&mut p, Kind::Function, Layer::Sa, "Send", None);
|
||||
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(parent));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
direction,
|
||||
interface: Some(interface),
|
||||
};
|
||||
id
|
||||
};
|
||||
let out = port(&mut p, "System output", system, PortDirection::Out);
|
||||
let input = port(&mut p, "Actor input", actor, PortDirection::In);
|
||||
let fp1 = port(&mut p, "Function output", f1, PortDirection::Out);
|
||||
let fp2 = port(&mut p, "Function input", f2, PortDirection::In);
|
||||
let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary link", out, input);
|
||||
if let RelationBody::BoundaryExchange { items, .. } =
|
||||
&mut p.relations.get_mut(&boundary).unwrap().body
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
let exchange = p.relate(RelationKind::FunctionalExchange, "Message exchange", f1, f2);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: f1,
|
||||
target: f2,
|
||||
source_port: Some(fp1),
|
||||
target_port: Some(fp2),
|
||||
items: vec![item],
|
||||
};
|
||||
p.relate(
|
||||
RelationKind::ExchangeAllocation,
|
||||
"Carried on",
|
||||
boundary,
|
||||
exchange,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides",
|
||||
system,
|
||||
interface,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires",
|
||||
actor,
|
||||
interface,
|
||||
);
|
||||
accepted(&p);
|
||||
if let RelationBody::FunctionalExchange { source_port, .. } =
|
||||
&mut p.relations.get_mut(&exchange).unwrap().body
|
||||
{
|
||||
*source_port = Some(input);
|
||||
}
|
||||
rejected(&p, "functional_port");
|
||||
}
|
||||
#[test]
|
||||
fn state_regions_initial_final_and_typed_actions_validate() {
|
||||
let mut p = Project::empty();
|
||||
let sys = add(&mut p, Kind::System, Layer::Sa, "System", None);
|
||||
let machine = add(
|
||||
&mut p,
|
||||
Kind::StateMachine,
|
||||
Layer::Sa,
|
||||
"Lifecycle",
|
||||
Some(sys),
|
||||
);
|
||||
let region = add(
|
||||
&mut p,
|
||||
Kind::Region,
|
||||
Layer::Sa,
|
||||
"Main region",
|
||||
Some(machine),
|
||||
);
|
||||
let initial = add(
|
||||
&mut p,
|
||||
Kind::Initial,
|
||||
Layer::Sa,
|
||||
"Initial point",
|
||||
Some(region),
|
||||
);
|
||||
let state = add(&mut p, Kind::State, Layer::Sa, "Ready", Some(region));
|
||||
let final_state = add(&mut p, Kind::Final, Layer::Sa, "Finished", Some(region));
|
||||
let work = add(&mut p, Kind::Function, Layer::Sa, "Initialize", None);
|
||||
p.relate(RelationKind::StateTransition, "Begin", initial, state);
|
||||
p.relate(RelationKind::StateTransition, "Finish", state, final_state);
|
||||
p.relate(RelationKind::StateAction, "Entry action", state, work);
|
||||
p.relate(RelationKind::Availability, "Available", work, state);
|
||||
accepted(&p);
|
||||
p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Invalid restart",
|
||||
final_state,
|
||||
state,
|
||||
);
|
||||
rejected(&p, "pseudostate_direction");
|
||||
}
|
||||
#[test]
|
||||
fn artifacts_preserve_arbitrary_uri_and_revision_without_fetch() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let id = artifact(&mut p, oa);
|
||||
accepted(&p);
|
||||
let encoded = serde_json::to_vec(&p).unwrap();
|
||||
let decoded: Project = serde_json::from_slice(&encoded).unwrap();
|
||||
assert_eq!(decoded.artifact_links[&id], p.artifact_links[&id]);
|
||||
p.artifact_links.get_mut(&id).unwrap().uri = "file:///C:/Engineering/REQ%2042.md".into();
|
||||
accepted(&p);
|
||||
p.artifact_links.get_mut(&id).unwrap().uri = "broken%XZ".into();
|
||||
rejected(&p, "invalid_uri");
|
||||
}
|
||||
#[test]
|
||||
fn reidentify_updates_containment_sequences_traces_artifacts_and_views_atomically() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
|
||||
let trace = make_trace(&mut p, oa, sa);
|
||||
let link = artifact(&mut p, oa);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(diagram, oa, Position { x: 1.25, y: -0. });
|
||||
let new = Id::new_v4();
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Reidentify(oa, new)).unwrap();
|
||||
assert_eq!(s.project().elements[&child].parent, Some(new));
|
||||
assert_eq!(s.project().traces[&trace].oa, new);
|
||||
assert_eq!(s.project().artifact_links[&link].owner, new);
|
||||
assert_eq!(s.project().diagrams[&diagram].nodes[&node].element, new);
|
||||
assert_eq!(s.project().elements[&new].alias, p.elements[&oa].alias);
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn deleting_source_cascades_owned_records_but_keeps_other_layer_counterpart() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
|
||||
let trace = make_trace(&mut p, oa, sa);
|
||||
let link = artifact(&mut p, child);
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Delete(oa)).unwrap();
|
||||
assert!(!s.project().elements.contains_key(&child));
|
||||
assert!(!s.project().traces.contains_key(&trace));
|
||||
assert!(!s.project().artifact_links.contains_key(&link));
|
||||
assert!(s.project().elements.contains_key(&sa));
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn relation_deletion_prunes_owned_occurrences_and_undo_restores_order() {
|
||||
let p = Project::demo();
|
||||
let exchange = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.id;
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Delete(exchange)).unwrap();
|
||||
assert!(s.project().elements[&process].steps().unwrap().is_empty());
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn nested_projection_encloses_children_and_moves_only_selected_diagram() {
|
||||
let mut p = Project::empty();
|
||||
let root = add(&mut p, Kind::Entity, Layer::Oa, "Organization", None);
|
||||
let child = add(&mut p, Kind::Entity, Layer::Oa, "Team", Some(root));
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let parent_node = p.place(d, root, Position { x: 100., y: 100. });
|
||||
let child_node = p.place(d, child, Position { x: 500., y: 400. });
|
||||
let view = projection::project_view(&p, d);
|
||||
let parent = view.nodes.iter().find(|n| n.id == parent_node).unwrap();
|
||||
let nested = view.nodes.iter().find(|n| n.id == child_node).unwrap();
|
||||
assert_eq!(nested.visual_parent, Some(parent_node));
|
||||
assert!(parent.position.x + parent.size.width >= nested.position.x + nested.size.width + 24.);
|
||||
let moved = execute_batch(
|
||||
&p,
|
||||
&[Command::MoveNode {
|
||||
diagram: d,
|
||||
node: parent_node,
|
||||
position: Position { x: 125., y: 150. },
|
||||
with_descendants: true,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved.diagrams[&d].nodes[&child_node].position,
|
||||
Position { x: 525., y: 450. }
|
||||
);
|
||||
assert_eq!(moved.elements, p.elements);
|
||||
let arranged = execute_batch(&p, &[Command::Layout(d)]).unwrap();
|
||||
let pn = &arranged.diagrams[&d].nodes[&parent_node];
|
||||
let cn = &arranged.diagrams[&d].nodes[&child_node];
|
||||
assert!(cn.position.x > pn.position.x);
|
||||
assert!(cn.position.y > pn.position.y);
|
||||
assert!(cn.position.y + cn.size.height < pn.position.y + pn.size.height);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_geometry_and_new_payload_fields_are_rejected() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(d, oa, Position { x: f32::NAN, y: 0. });
|
||||
rejected(&p, "invalid_bounds");
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = 0.;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.size
|
||||
.width = -1.;
|
||||
rejected(&p, "invalid_bounds");
|
||||
assert!(
|
||||
serde_json::from_str::<SharedBody>(
|
||||
r#"{"Port":{"direction":"In","interface":null,"typo":true}}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_str::<Command>(r#"{"Reparent":{"element":"00000000-0000-0000-0000-000000000001","parent":null,"typo":true}}"#).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn bidirectional_transition_creates_reuses_and_updates_one_history_transaction() {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None);
|
||||
p.elements.get_mut(&oa).unwrap().description = "Operational intent".into();
|
||||
let request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let target = plan.changes[0].target;
|
||||
let migrated = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(migrated.elements[&target].kind(), Kind::Function);
|
||||
assert_eq!(migrated.traces.len(), 1);
|
||||
let again = plan_transition(&migrated, &request).unwrap();
|
||||
assert!(again.conflicts.is_empty());
|
||||
assert!(again.commands.is_empty());
|
||||
let edited = execute_batch(
|
||||
&migrated,
|
||||
&[Command::Describe(target, "Updated system intent".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let reverse = plan_transition(
|
||||
&edited,
|
||||
&TransitionRequest::new(TransitionDirection::SaToOa, vec![target]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverse.conflicts.is_empty(), "{:?}", reverse.conflicts);
|
||||
let restored = apply_transition(&edited, &reverse).unwrap();
|
||||
assert_eq!(restored.elements[&oa].description, "Updated system intent");
|
||||
assert_eq!(restored.traces.len(), 1);
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Replace(migrated)).unwrap();
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn both_side_transition_conflicts_require_explicit_field_resolution() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
p.elements.get_mut(&sa).unwrap().name = p.elements[&oa].name.clone();
|
||||
let trace = make_trace(&mut p, oa, sa);
|
||||
p.elements.get_mut(&oa).unwrap().name = "New OA name".into();
|
||||
p.elements.get_mut(&sa).unwrap().name = "New SA name".into();
|
||||
assert_eq!(trace_drift(&p, &p.traces[&trace]), Drift::BothChanged);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.any(|c| c.field == Some(TransitionField::Name))
|
||||
);
|
||||
assert!(apply_transition(&p, &plan).is_err());
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: oa,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
});
|
||||
let accepted_plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
accepted_plan.conflicts.is_empty(),
|
||||
"{:?}",
|
||||
accepted_plan.conflicts
|
||||
);
|
||||
let updated = apply_transition(&p, &accepted_plan).unwrap();
|
||||
assert_eq!(updated.elements[&sa].name, "New SA name");
|
||||
assert_eq!(
|
||||
trace_drift(&updated, &updated.traces[&trace]),
|
||||
Drift::Unchanged
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn participant_transition_requires_boundary_decision_and_stale_plans_reject() {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Entity, Layer::Oa, "Operations", None);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: oa,
|
||||
target: None,
|
||||
target_kind: Some(Kind::System),
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
p.name = "Changed project".into();
|
||||
assert!(apply_transition(&p, &plan).unwrap_err().0.contains("stale"));
|
||||
}
|
||||
#[test]
|
||||
fn explicit_dependencies_map_chain_order_and_repeated_occurrences() {
|
||||
let mut p = Project::demo();
|
||||
p.traces.clear();
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let exchange = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.id;
|
||||
let p = execute_batch(&p, &[Command::AddStep(process, exchange)]).unwrap();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![process]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = apply_transition(&p, &plan).unwrap();
|
||||
let chain = plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == process)
|
||||
.unwrap()
|
||||
.target;
|
||||
let steps = result.elements[&chain].steps().unwrap();
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert_ne!(steps[0], steps[1]);
|
||||
let relation = |id: Id| match result.elements[&id].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(r), ..
|
||||
},
|
||||
..
|
||||
}) => *r,
|
||||
_ => panic!("exchange occurrence"),
|
||||
};
|
||||
assert_eq!(relation(steps[0]), relation(steps[1]));
|
||||
}
|
||||
#[test]
|
||||
fn many_traces_need_explicit_counterpart_and_layout_does_not_cause_drift() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
let first = make_trace(&mut p, oa, sa);
|
||||
let second = add(&mut p, Kind::Function, Layer::Sa, "Another function", None);
|
||||
make_trace(&mut p, oa, second);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: oa,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty());
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.place(d, oa, Position { x: 400., y: 500. });
|
||||
assert_eq!(trace_drift(&p, &p.traces[&first]), Drift::Unchanged);
|
||||
}
|
||||
#[test]
|
||||
fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
let mut p = Project::empty();
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Item", None);
|
||||
let dtype = add(&mut p, Kind::DataType, Layer::Sa, "Count type", None);
|
||||
let field = add(&mut p, Kind::DataField, Layer::Sa, "Count", Some(item));
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(dtype),
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: "items".into(),
|
||||
};
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||
let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function));
|
||||
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port, items, ..
|
||||
} = &mut p.relations.get_mut(&rel).unwrap().body
|
||||
{
|
||||
*source_port = Some(port);
|
||||
items.push(item);
|
||||
}
|
||||
let dtype_new = Id::new_v4();
|
||||
let port_new = Id::new_v4();
|
||||
let result = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::Reidentify(dtype, dtype_new),
|
||||
Command::Reidentify(port, port_new),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
|
||||
);
|
||||
assert!(
|
||||
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references() {
|
||||
let p = Project::demo();
|
||||
for (id, _) in p.identities() {
|
||||
let replacement = Id::new_v4();
|
||||
let remapped = execute_batch(&p, &[Command::Reidentify(id, replacement)])
|
||||
.unwrap_or_else(|err| panic!("Reidentify {id}: {err}"));
|
||||
let restored = execute_batch(&remapped, &[Command::Reidentify(replacement, id)]).unwrap();
|
||||
assert_eq!(
|
||||
restored, p,
|
||||
"Reidentification must preserve all values and order"
|
||||
);
|
||||
if id != p.id {
|
||||
let deleted = execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
|
||||
accepted(&deleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn shared_roles_remain_valid_without_an_invented_unique_performer() {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, Kind::Entity, Layer::Oa, "Team A", None);
|
||||
let b = add(&mut p, Kind::Entity, Layer::Oa, "Team B", None);
|
||||
let role = add(&mut p, Kind::Role, Layer::Oa, "Responder", None);
|
||||
let work = add(&mut p, Kind::Activity, Layer::Oa, "Respond", None);
|
||||
p.relate(RelationKind::RoleRealization, "A realizes", a, role);
|
||||
p.relate(
|
||||
RelationKind::RoleAssignment,
|
||||
"Role responsibility",
|
||||
role,
|
||||
work,
|
||||
);
|
||||
assert_eq!(validation::effective_performer(&p, work), Some(a));
|
||||
assert!(
|
||||
!validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "unallocated_leaf")
|
||||
);
|
||||
p.relate(RelationKind::RoleRealization, "B realizes", b, role);
|
||||
accepted(&p);
|
||||
assert_eq!(validation::effective_performer(&p, work), None);
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "ambiguous_role_performer" && i.severity == Severity::Warning)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn automatic_transition_does_not_accept_arbitrary_target_kinds() {
|
||||
let (p, oa, sa) = work_pair();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: oa,
|
||||
target: None,
|
||||
target_kind: Some(Kind::System),
|
||||
});
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
let mut p = p;
|
||||
let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None);
|
||||
request.mappings[0].target = Some(actor);
|
||||
request.mappings[0].target_kind = None;
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings[0].target = Some(sa);
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: oa,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::TakeSource,
|
||||
});
|
||||
assert!(plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn demo_has_both_layers_complete_contracts_and_no_checks() {
|
||||
let p = Project::demo();
|
||||
assert_eq!(p.elements.len(), 41);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for projection in [
|
||||
ProjectionKind::Allocation,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
assert!(
|
||||
p.diagrams
|
||||
.values()
|
||||
.any(|d| d.layer == layer && d.projection == projection)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(p.traces.len(), 10);
|
||||
assert_eq!(p.artifact_links.len(), 2);
|
||||
assert!(
|
||||
validation::validate(&p).is_empty(),
|
||||
"{:?}",
|
||||
validation::validate(&p)
|
||||
);
|
||||
assert!(p.elements.values().any(|e| e.kind() == Kind::System));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material
|
||||
})
|
||||
)));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy
|
||||
})
|
||||
)));
|
||||
}
|
||||
Reference in New Issue
Block a user