Compose saved Scenario authoring with current Text and input authority
This commit is contained in:
@@ -81,6 +81,7 @@ fn diagram(
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod scenario_layout;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
mod source_defaults;
|
||||
@@ -20,6 +21,9 @@ pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use scenario_layout::{
|
||||
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
|
||||
@@ -1436,6 +1436,7 @@ pub enum ProjectionKind {
|
||||
FunctionalDataflow,
|
||||
Capabilities,
|
||||
StateMachine { machine: Id },
|
||||
Scenario { scenario: Id },
|
||||
}
|
||||
impl ProjectionKind {
|
||||
pub fn state_machine(self) -> Option<Id> {
|
||||
@@ -1445,8 +1446,30 @@ impl ProjectionKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The semantic owner of a saved Scenario representation.
|
||||
pub fn scenario(self) -> Option<Id> {
|
||||
match self {
|
||||
Self::Scenario { scenario } => Some(scenario),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly classify architecture views; absence of a state machine is insufficient.
|
||||
pub fn is_architecture(self) -> bool {
|
||||
match self {
|
||||
Self::Overview
|
||||
| Self::Decomposition
|
||||
| Self::Allocation
|
||||
| Self::Traceability
|
||||
| Self::FunctionalDataflow
|
||||
| Self::Capabilities => true,
|
||||
Self::StateMachine { .. } | Self::Scenario { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(self, kind: Kind) -> bool {
|
||||
match self {
|
||||
Self::Scenario { .. } => false,
|
||||
Self::StateMachine { .. } => {
|
||||
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
|
||||
}
|
||||
@@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::StateMachine { .. } => "State machine",
|
||||
Self::Scenario { .. } => "Scenario",
|
||||
Self::Allocation => "Architecture · allocated work",
|
||||
Self::FunctionalDataflow => "Functional dataflow",
|
||||
Self::Capabilities => "Missions & capabilities",
|
||||
@@ -1495,6 +1519,8 @@ pub struct Diagram {
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<crate::scenario_layout::DiagramLayout>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
@@ -1921,6 +1947,7 @@ impl Project {
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -278,6 +278,11 @@ fn project_diagram_impl(
|
||||
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
||||
index: Option<&crate::view_options::ViewIndex>,
|
||||
) -> ProjectedView {
|
||||
// Saved Scenario layout is not an architecture/state projection. Its dedicated
|
||||
// renderer is not wired yet; never interpret its payload through this fallback.
|
||||
if d.projection.scenario().is_some() {
|
||||
return ProjectedView::default();
|
||||
}
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
@@ -394,6 +399,7 @@ fn project_diagram_impl(
|
||||
continue;
|
||||
}
|
||||
let included = match d.projection {
|
||||
ProjectionKind::Scenario { .. } => false,
|
||||
ProjectionKind::StateMachine { machine } => matches!(&r.body,
|
||||
RelationBody::StateTransition { transition_kind, .. }
|
||||
if *transition_kind != TransitionKind::Internal
|
||||
|
||||
@@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
/// Every semantic/presentation reference in a diagram, excluding its own identities.
|
||||
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
|
||||
let mut copy = d.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_diagram_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
|
||||
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
|
||||
d.view_options.visit_references(&mut visit);
|
||||
match &mut d.projection {
|
||||
ProjectionKind::StateMachine { machine } => visit(machine),
|
||||
ProjectionKind::Scenario { scenario } => visit(scenario),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(layout) = &mut d.layout {
|
||||
layout.visit_references(&mut visit);
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
visit(&mut route.edge);
|
||||
}
|
||||
for node in d.nodes.values_mut() {
|
||||
for anchor in &mut node.port_anchors {
|
||||
visit(&mut anchor.port);
|
||||
}
|
||||
visit(&mut node.element);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight reference-key collisions before the caller changes object identities.
|
||||
/// Fresh targets are allowed. This is not whole-project admission: unrelated
|
||||
/// invalid records remain repairable within a transaction. Future typed reference
|
||||
/// domains must compose their own preflight here before any remap mutation.
|
||||
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
let remap = |id: Id| if id == old { new } else { id };
|
||||
for diagram in p.diagrams.values() {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
continue;
|
||||
};
|
||||
let mut lanes = std::collections::BTreeMap::new();
|
||||
for lane in &layout.lanes {
|
||||
let affected = lane.lifeline == old;
|
||||
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario lane position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut messages = std::collections::BTreeMap::new();
|
||||
for message in &layout.messages {
|
||||
let mut address = message.address.clone();
|
||||
let affected = address.occurrence == old || address.references.contains(&old);
|
||||
address.occurrence = remap(address.occurrence);
|
||||
for reference in &mut address.references {
|
||||
*reference = remap(*reference);
|
||||
}
|
||||
if let Some(previous_affected) = messages.insert(address, affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario message position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
check_remap(p, old, new)?;
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.visit_references(|id| {
|
||||
visit_diagram_refs(d, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
if let ProjectionKind::StateMachine { machine } = &mut d.projection
|
||||
&& *machine == old
|
||||
{
|
||||
*machine = new;
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
@@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
for d in p.diagrams.values() {
|
||||
if d.projection
|
||||
.state_machine()
|
||||
.is_some_and(|machine| removed.contains(&machine))
|
||||
.or_else(|| d.projection.scenario())
|
||||
.is_some_and(|owner| removed.contains(&owner))
|
||||
{
|
||||
removed.insert(d.id);
|
||||
}
|
||||
@@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
crate::scenario_layout::prune_deleted(d, &removed);
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "references/scenario_layout_tests.rs"]
|
||||
mod scenario_layout_tests;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&view).unwrap().layout
|
||||
else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn admitted(project: &Project) {
|
||||
let errors: Vec<_> = crate::validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == crate::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
|
||||
.unwrap();
|
||||
let lanes = ["Sender", "Receiver"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
let messages = ["First message", "Second message"].map(|name| {
|
||||
let id = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
id
|
||||
});
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.to_vec();
|
||||
let views = ["delivery_view", "review_view"].map(|alias| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 120.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 420.,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[0]),
|
||||
send_y: 220.,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[1]),
|
||||
send_y: 360.,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
|
||||
let mut f = fixture();
|
||||
let before = f.project.clone();
|
||||
let temporary = Id::new_v4();
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
admitted(&f.project);
|
||||
assert_eq!(
|
||||
f.project.elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.messages[1], f.messages[0]]
|
||||
);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(
|
||||
rows.lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[1],
|
||||
x: 120.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[0],
|
||||
x: 420.
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 220.
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[0]),
|
||||
send_y: 360.
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
// Reverse the swaps: no values or identities other than those requested drift.
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
|
||||
for message in [false, true] {
|
||||
let mut f = fixture();
|
||||
let [old, new] = if message { f.messages } else { f.lanes };
|
||||
let before = f.project.clone();
|
||||
assert!(check_remap(&f.project, old, new).is_err());
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
|
||||
let mut f = fixture();
|
||||
let fresh = Id::new_v4();
|
||||
// A repair candidate can already have a dangling coordinate key. It must not
|
||||
// silently merge with the live lane, even though the destination ID is vacant.
|
||||
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
|
||||
let before = f.project.clone();
|
||||
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
|
||||
let mut f = fixture();
|
||||
let [old, new] = [Id::new_v4(), Id::new_v4()];
|
||||
let rows = layout_mut(&mut f.project, f.views[0]);
|
||||
// Future/invalid expanded candidates still participate in reference safety.
|
||||
rows.messages[0].address.references = vec![old];
|
||||
rows.messages[1].address = OccurrenceEndpoint {
|
||||
references: vec![new],
|
||||
occurrence: f.messages[0],
|
||||
};
|
||||
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
|
||||
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
|
||||
let before = f.project.clone();
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
let fresh = Id::new_v4();
|
||||
remap_references(&mut f.project, old, fresh).unwrap();
|
||||
let rows = layout(&f.project, f.views[0]);
|
||||
assert_eq!(rows.messages[0].address.references, vec![fresh]);
|
||||
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
|
||||
assert_eq!(rows.messages[1].address.references, vec![new]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
|
||||
let mut f = fixture();
|
||||
let other_owner = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
|
||||
.unwrap();
|
||||
let mut other_view = f.project.diagrams[&f.views[0]].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "independent_view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
other_view.layout = None;
|
||||
f.project.diagrams.insert(other_view.id, other_view.clone());
|
||||
admitted(&f.project);
|
||||
let fresh_owner = Id::new_v4();
|
||||
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
|
||||
.unwrap()
|
||||
.project;
|
||||
for view in f.views {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&view].projection.scenario(),
|
||||
Some(fresh_owner)
|
||||
);
|
||||
}
|
||||
let impact = deletion_impact(&f.project, fresh_owner);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.chain(&f.lanes)
|
||||
.chain(&f.messages)
|
||||
.all(|id| impact.contains(id))
|
||||
);
|
||||
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
|
||||
delete_cascade(&mut f.project, fresh_owner);
|
||||
assert_eq!(f.project.diagrams[&other_view.id], other_view);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.all(|id| !f.project.diagrams.contains_key(id))
|
||||
);
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
|
||||
let mut f = fixture();
|
||||
let lane = f.project.elements[&f.lanes[0]].clone();
|
||||
let message = f.project.elements[&f.messages[0]].clone();
|
||||
delete_cascade(&mut f.project, lane.id);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
|
||||
// Missing endpoint assignment does not erase a still-live message's spacing.
|
||||
assert_eq!(rows.messages.len(), 2);
|
||||
}
|
||||
let mut pending = BTreeSet::new();
|
||||
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
|
||||
// Pruning happens during deletion, before later batch commands can recreate IDs.
|
||||
assert!(
|
||||
layout(&f.project, f.views[0])
|
||||
.messages
|
||||
.iter()
|
||||
.all(|row| row.address.occurrence != message.id)
|
||||
);
|
||||
f.project.elements.insert(lane.id, lane);
|
||||
f.project.elements.insert(message.id, message);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.insert(0, f.messages[0]);
|
||||
finish_deletions(&mut f.project, &pending);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
}
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
|
||||
let mut f = fixture();
|
||||
let unrelated = Id::new_v4();
|
||||
let rows = layout_mut(&mut f.project, f.views[1]);
|
||||
let invalid = ScenarioLanePosition {
|
||||
lifeline: unrelated,
|
||||
x: MAX_COORDINATE * 2.,
|
||||
};
|
||||
rows.lanes.extend([invalid.clone(), invalid]);
|
||||
let fresh = Id::new_v4();
|
||||
let untouched = f.project.diagrams[&f.views[1]].clone();
|
||||
let before = f.project.clone();
|
||||
remap_references(&mut f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(f.project, before);
|
||||
// The duplicate lane records do not involve the remapped message identity.
|
||||
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).lanes,
|
||||
layout_from(&untouched).lanes
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).messages[0]
|
||||
.address
|
||||
.occurrence,
|
||||
fresh
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
|
||||
}
|
||||
|
||||
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("missing layout")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
|
||||
let mut f = fixture();
|
||||
// Expanded addresses are not admitted in this slice, but deleting their
|
||||
// explicit references must still make an intermediate repair candidate safe.
|
||||
layout_mut(&mut f.project, f.views[0]).messages[0]
|
||||
.address
|
||||
.references = vec![f.lanes[0]];
|
||||
delete_cascade(&mut f.project, f.lanes[0]);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[0]).messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
|
||||
assert!(f.project.elements.contains_key(&f.messages[0]));
|
||||
admitted(&f.project);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Authored Scenario presentation. Structural admission never changes event order.
|
||||
use crate::{
|
||||
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
|
||||
Project, SharedBody, validation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DiagramLayout {
|
||||
Scenario(ScenarioLayout),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLayout {
|
||||
#[serde(default)]
|
||||
pub lanes: Vec<ScenarioLanePosition>,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<ScenarioMessagePosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLanePosition {
|
||||
pub lifeline: Id,
|
||||
pub x: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioMessagePosition {
|
||||
pub address: OccurrenceEndpoint,
|
||||
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
|
||||
pub send_y: f32,
|
||||
}
|
||||
|
||||
impl DiagramLayout {
|
||||
/// Visit every explicit reference, including unsupported paths in a candidate.
|
||||
/// The caller owns remap preflight and final atomic validation.
|
||||
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
|
||||
match self {
|
||||
Self::Scenario(layout) => {
|
||||
for lane in &mut layout.lanes {
|
||||
visit(&mut lane.lifeline);
|
||||
}
|
||||
for message in &mut layout.messages {
|
||||
for reference in &mut message.address.references {
|
||||
visit(reference);
|
||||
}
|
||||
visit(&mut message.address.occurrence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
|
||||
/// One payload plus one record per lane/message. No graph objects are introduced.
|
||||
pub fn record_count(diagram: &Diagram) -> usize {
|
||||
match &diagram.layout {
|
||||
None => 0,
|
||||
Some(DiagramLayout::Scenario(layout)) => 1usize
|
||||
.saturating_add(layout.lanes.len())
|
||||
.saturating_add(layout.messages.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn coordinate(value: f32) -> bool {
|
||||
value.is_finite() && value.abs() <= MAX_COORDINATE
|
||||
}
|
||||
|
||||
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
|
||||
let scenario = diagram
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
|
||||
if !p
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
|
||||
));
|
||||
}
|
||||
Ok(scenario)
|
||||
}
|
||||
|
||||
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
|
||||
p.elements.get(&lifeline).is_some_and(|element| {
|
||||
element.kind() == Kind::Lifeline
|
||||
&& element.parent == Some(owner)
|
||||
&& element.layer() == layer
|
||||
})
|
||||
}
|
||||
|
||||
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
|
||||
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
|
||||
/// cycles terminate, and malformed parent/step links do not manufacture membership.
|
||||
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
|
||||
let mut messages = BTreeSet::new();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut pending = vec![owner];
|
||||
while let Some(scope) = pending.pop() {
|
||||
if !visited.insert(scope) {
|
||||
continue;
|
||||
}
|
||||
let Some(element) = p.elements.get(&scope) else {
|
||||
continue;
|
||||
};
|
||||
for id in element.steps().into_iter().flatten() {
|
||||
let Some(item) = p.elements.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if item.parent != Some(scope) || item.layer() != layer {
|
||||
continue;
|
||||
}
|
||||
match item.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
}) => {
|
||||
messages.insert(*id);
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
for branch in branches {
|
||||
if p.elements.get(branch).is_some_and(|operand| {
|
||||
operand.parent == Some(*id)
|
||||
&& operand.layer() == layer
|
||||
&& matches!(
|
||||
operand.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
pending.push(*branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
|
||||
if diagram.projection.scenario().is_none() {
|
||||
if diagram.layout.is_some() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_projection",
|
||||
"layout",
|
||||
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let scenario = match owner(p, diagram) {
|
||||
Ok(owner) => owner,
|
||||
Err(error) => {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_context",
|
||||
"projection",
|
||||
error.0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_content",
|
||||
"layout",
|
||||
"Scenario views do not accept architecture node placements or edge routes.",
|
||||
);
|
||||
}
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
return;
|
||||
};
|
||||
let mut lanes = BTreeSet::new();
|
||||
for lane in &layout.lanes {
|
||||
if !lanes.insert(lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_lane",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per lifeline.",
|
||||
);
|
||||
}
|
||||
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_lifeline",
|
||||
"layout",
|
||||
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
|
||||
);
|
||||
}
|
||||
if !coordinate(lane.x) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let members = direct_messages(p, scenario, diagram.layer);
|
||||
let mut addresses = BTreeSet::new();
|
||||
for message in &layout.messages {
|
||||
// Reject expanded paths without cloning or indexing their potentially large contents.
|
||||
if !message.address.references.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_address",
|
||||
"layout",
|
||||
"Expanded-reference message positions are not supported; references must be empty.",
|
||||
);
|
||||
} else if !addresses.insert(message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_message",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per message address.",
|
||||
);
|
||||
}
|
||||
if !members.contains(&message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_message",
|
||||
"layout",
|
||||
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
|
||||
);
|
||||
}
|
||||
if !coordinate(message.send_y) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
|
||||
let scenario = owner(p, diagram)?;
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Scenario views do not accept architecture node placements or edge routes.".into(),
|
||||
));
|
||||
}
|
||||
Ok((diagram, scenario))
|
||||
}
|
||||
|
||||
fn check_message(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
owner: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<(), ModelError> {
|
||||
if !address.references.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Expanded-reference message positions are not supported; references must be empty."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
|
||||
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
|
||||
let DiagramLayout::Scenario(layout) = diagram
|
||||
.layout
|
||||
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
layout
|
||||
}
|
||||
|
||||
/// Target-scoped preparation, not whole-project or whole-layout validation.
|
||||
/// Unrelated intermediate errors remain repairable by later commands in a batch.
|
||||
pub fn set_lane(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
|
||||
return Err(ModelError(
|
||||
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.lanes
|
||||
.iter()
|
||||
.filter(|row| row.lifeline == position.lifeline)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate lane position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.lanes
|
||||
.iter_mut()
|
||||
.find(|row| row.lifeline == position.lifeline)
|
||||
{
|
||||
if existing.x != position.x {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.lanes.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, lifeline) {
|
||||
return Err(ModelError(
|
||||
"Removing a lane position requires an owned Lifeline.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.lanes.retain(|row| row.lifeline != lifeline);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, &position.address)?;
|
||||
if !coordinate(position.send_y) {
|
||||
return Err(ModelError(
|
||||
"Message position requires a finite bounded send_y coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|row| row.address == position.address)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate message position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|row| row.address == position.address)
|
||||
{
|
||||
if existing.send_y != position.send_y {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.messages.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, address)?;
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.messages.retain(|row| &row.address != address);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prune only identities in the caller's actual deletion closure, never visibility
|
||||
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
|
||||
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
|
||||
layout
|
||||
.lanes
|
||||
.retain(|lane| !deleted.contains(&lane.lifeline));
|
||||
layout.messages.retain(|message| {
|
||||
!deleted.contains(&message.address.occurrence)
|
||||
&& !message
|
||||
.address
|
||||
.references
|
||||
.iter()
|
||||
.any(|id| deleted.contains(id))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,800 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
|
||||
ScenarioViewpoint,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
performer: Id,
|
||||
lane: Id,
|
||||
repeated_lane: Id,
|
||||
first: Id,
|
||||
second: Id,
|
||||
nested: Id,
|
||||
fragment: Id,
|
||||
branch: Id,
|
||||
view: Id,
|
||||
other_view: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn set_body(element: &mut Element, body: OccurrenceBody) {
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
fn diagram(owner: Id, alias: &str) -> Diagram {
|
||||
Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn has_code(project: &Project, code: &str) -> bool {
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == code)
|
||||
}
|
||||
fn layout(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("expected saved payload")
|
||||
};
|
||||
layout
|
||||
}
|
||||
fn save(project: &mut Project, replacement: Diagram) {
|
||||
project.diagrams.insert(replacement.id, replacement);
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
project.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let receiver = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
|
||||
.unwrap();
|
||||
let lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
|
||||
.unwrap();
|
||||
let repeated_lane = project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Controller review lane",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let receiver_lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
|
||||
.unwrap();
|
||||
for (id, represented) in [
|
||||
(lane, performer),
|
||||
(repeated_lane, performer),
|
||||
(receiver_lane, receiver),
|
||||
] {
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let exchange = Id::new_v4();
|
||||
project.relations.insert(
|
||||
exchange,
|
||||
Relation {
|
||||
id: exchange,
|
||||
alias: "delivery_channel".into(),
|
||||
name: "Delivery".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
|
||||
},
|
||||
);
|
||||
let first = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
|
||||
.unwrap();
|
||||
for (id, sender) in [(first, lane), (second, repeated_lane)] {
|
||||
set_body(
|
||||
project.elements.get_mut(&id).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver_lane),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let fragment = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Optional delivery",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
|
||||
.unwrap();
|
||||
let nested = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unfinished nested delivery",
|
||||
Some(branch),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&nested).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&branch).unwrap(),
|
||||
OccurrenceBody::Branch {
|
||||
guard: "requested".into(),
|
||||
occurrences: vec![nested],
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![branch],
|
||||
},
|
||||
);
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![first, second, fragment];
|
||||
let view_record = diagram(owner, "nominal_view");
|
||||
let other_record = diagram(owner, "review_view");
|
||||
let view = view_record.id;
|
||||
let other_view = other_record.id;
|
||||
save(&mut project, view_record);
|
||||
save(&mut project, other_record);
|
||||
assert_admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
performer,
|
||||
lane,
|
||||
repeated_lane,
|
||||
first,
|
||||
second,
|
||||
nested,
|
||||
fragment,
|
||||
branch,
|
||||
view,
|
||||
other_view,
|
||||
exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
|
||||
let mut f = fixture();
|
||||
let semantics = (f.project.elements.clone(), f.project.relations.clone());
|
||||
for (view, line, x) in [
|
||||
(f.view, f.lane, 420.),
|
||||
(f.view, f.repeated_lane, 680.),
|
||||
(f.other_view, f.lane, 900.),
|
||||
] {
|
||||
let result =
|
||||
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
for (view, id, y) in [
|
||||
(f.view, f.first, 200.),
|
||||
(f.view, f.second, 350.),
|
||||
(f.other_view, f.first, 260.),
|
||||
] {
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: y,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view]).lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.repeated_lane,
|
||||
x: 680.
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view])
|
||||
.messages
|
||||
.iter()
|
||||
.map(|r| (r.address.occurrence, r.send_y))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(f.first, 200.), (f.second, 350.)]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
|
||||
260.
|
||||
);
|
||||
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
|
||||
assert_eq!(
|
||||
(f.project.elements.clone(), f.project.relations.clone()),
|
||||
semantics
|
||||
);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.nested),
|
||||
send_y: -80.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
let before = f.project.clone();
|
||||
let expanded = OccurrenceEndpoint {
|
||||
references: vec![f.fragment],
|
||||
occurrence: f.nested,
|
||||
};
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: expanded.clone(),
|
||||
send_y: 400.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(remove_message(&f.project, f.view, &expanded).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
|
||||
assert!(has_code(&f.project, "scenario_layout_address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
|
||||
let mut f = fixture();
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.performer,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
let foreign_lane = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
|
||||
.unwrap();
|
||||
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.first);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positions_reject_even_when_values_match() {
|
||||
let mut f = fixture();
|
||||
let lane = ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.,
|
||||
};
|
||||
let message = ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
};
|
||||
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
entries.lanes = vec![lane.clone(), lane.clone()];
|
||||
entries.messages = vec![message.clone(), message.clone()];
|
||||
assert!(has_code(&f.project, "duplicate_scenario_lane"));
|
||||
assert!(has_code(&f.project, "duplicate_scenario_message"));
|
||||
assert!(set_lane(&f.project, f.view, lane).is_err());
|
||||
assert!(set_message(&f.project, f.view, message).is_err());
|
||||
// Explicit removal can repair all duplicates of the targeted identity.
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
|
||||
let mut f = fixture();
|
||||
let absent = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
|
||||
assert_eq!(
|
||||
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
|
||||
absent
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_value(&absent)
|
||||
.unwrap()
|
||||
.get("layout")
|
||||
.is_none()
|
||||
);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layout(&result).messages[0].send_y.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&result).unwrap(),
|
||||
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
|
||||
);
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
assert_eq!(
|
||||
result.layout,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
|
||||
);
|
||||
assert_ne!(result, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
|
||||
let mut f = fixture();
|
||||
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
let before = serde_json::to_string(&f.project).unwrap();
|
||||
for value in [
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
MAX_COORDINATE + 1.,
|
||||
] {
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
|
||||
let mut f = fixture();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.push(ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: f32::INFINITY,
|
||||
});
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 300.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
|
||||
let mut f = fixture();
|
||||
let scenario = f.project.diagrams[&f.view].projection;
|
||||
assert_eq!(scenario.scenario(), Some(f.owner));
|
||||
assert!(!scenario.is_architecture());
|
||||
assert!(!scenario.includes(Kind::Lifeline));
|
||||
assert!(ProjectionKind::Overview.is_architecture());
|
||||
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
|
||||
assert!(has_code(&f.project, "scenario_diagram_context"));
|
||||
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(crate::EdgeRoute {
|
||||
edge: f.exchange,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
assert!(has_code(&f.project, "scenario_diagram_content"));
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(has_code(&f.project, "scenario_layout_projection"));
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
|
||||
f.project.diagrams[&f.view]
|
||||
);
|
||||
assert_eq!(
|
||||
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
|
||||
serde_json::json!(f.first)
|
||||
);
|
||||
let mut unknown = encoded.clone();
|
||||
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
|
||||
let mut missing = encoded;
|
||||
missing["layout"]["Scenario"]["messages"][0]["address"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("references");
|
||||
assert!(serde_json::from_value::<Diagram>(missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
|
||||
let mut f = fixture();
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let mut copied = f.project.diagrams[&f.view].clone();
|
||||
copied.id = f.other_view;
|
||||
copied.alias = "review_view".into();
|
||||
copied.name = "Review view".into();
|
||||
save(&mut f.project, copied.clone());
|
||||
assert_admitted(&f.project);
|
||||
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
|
||||
let mut candidate = f.project.diagrams[&f.view].clone();
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
|
||||
assert!(layout(&candidate).lanes.is_empty());
|
||||
assert_eq!(layout(&candidate).messages.len(), 1);
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
|
||||
assert!(layout(&candidate).messages.is_empty());
|
||||
assert!(candidate.layout.is_some());
|
||||
assert_eq!(f.project.diagrams[&f.other_view], copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_visitor_and_record_count_cover_explicit_rows() {
|
||||
let f = fixture();
|
||||
let mut d = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(record_count(&d), 0);
|
||||
let rows = payload(&mut d);
|
||||
rows.lanes.push(ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 50.,
|
||||
});
|
||||
rows.messages.push(ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
});
|
||||
assert_eq!(record_count(&d), 3);
|
||||
let replacement = Id::new_v4();
|
||||
d.layout.as_mut().unwrap().visit_references(|id| {
|
||||
if *id == f.first {
|
||||
*id = replacement;
|
||||
}
|
||||
});
|
||||
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
|
||||
assert_eq!(layout(&d).messages[0].send_y, 100.);
|
||||
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
|
||||
let mut f = fixture();
|
||||
let called = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
f.project.elements.get_mut(&called).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Called Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let called_message = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&called_message).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&called)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![called_message];
|
||||
let usage = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&usage).unwrap(),
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(called),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(usage);
|
||||
assert_admitted(&f.project);
|
||||
for target in [
|
||||
address(called_message),
|
||||
address(usage),
|
||||
OccurrenceEndpoint {
|
||||
references: vec![usage],
|
||||
occurrence: called_message,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: target,
|
||||
send_y: 200.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod source_lifecycle;
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Source/command lifecycle checks for the candidate payload. Public version
|
||||
//! assignment remains the coordinated codec integration gate.
|
||||
use super::*;
|
||||
use crate::{Command, Session, language, prepare_command, source_project::*};
|
||||
|
||||
fn authored(f: &Fixture) -> CompiledSourceProject {
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &initial.index.declarations[&f.view];
|
||||
let source = bundle.files.get_mut(&location.path).unwrap();
|
||||
let text = format!(
|
||||
r#"diagram nominal_view "Nominal review" id "{}" {{
|
||||
projection {{ Scenario {{ scenario @{}; }}; }};
|
||||
layout {{ Scenario {{
|
||||
lanes [
|
||||
// Controller spacing rationale.
|
||||
{{ lifeline @ // first lane identity
|
||||
{}; x 4.20e2; }},
|
||||
// Repeated performer has its own lane.
|
||||
{{ lifeline @{}; x 680; }}
|
||||
];
|
||||
messages [
|
||||
// First delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
|
||||
// Second delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
|
||||
];
|
||||
}}; }};
|
||||
}}
|
||||
"#,
|
||||
f.view,
|
||||
f.project.elements[&f.owner].alias,
|
||||
f.project.elements[&f.lane].alias,
|
||||
f.project.elements[&f.repeated_lane].alias,
|
||||
f.project.elements[&f.first].alias,
|
||||
f.project.elements[&f.second].alias
|
||||
);
|
||||
source.replace_range(location.span.start..location.span.end, &text);
|
||||
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut changed = original.decoded.project.clone();
|
||||
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
assert_eq!(&updated.index.declarations[&f.view].path, path);
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(text.contains("send_y 390"), "{text}");
|
||||
for (other, bytes) in &original.bundle.files {
|
||||
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
|
||||
assert_eq!(&updated.bundle.files[other], bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let temporary = Id::new_v4();
|
||||
let command = Command::Batch(vec![
|
||||
Command::Reidentify(f.first, temporary),
|
||||
Command::Reidentify(f.second, f.first),
|
||||
Command::Reidentify(temporary, f.second),
|
||||
]);
|
||||
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
|
||||
let mut changed = prepared.project;
|
||||
payload(changed.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.swap(0, 1);
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&changed,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
let first_comment = text.find("// First delivery").unwrap();
|
||||
assert!(text[first_comment..].contains(&format!(
|
||||
"occurrence @{}",
|
||||
changed.elements[&f.second].alias
|
||||
)));
|
||||
assert!(text[first_comment..].contains("send_y 2.00e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut session = Session::new(original.decoded.project.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::SetScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 510.,
|
||||
},
|
||||
},
|
||||
Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
},
|
||||
]))
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
assert_eq!(session.revision(), 1);
|
||||
assert_eq!(changed.elements, original.decoded.project.elements);
|
||||
assert_eq!(changed.relations, original.decoded.project.relations);
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original.decoded.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), 3);
|
||||
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(noop.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
|
||||
let f = fixture();
|
||||
let before = language::format(&f.project);
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::RemoveScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
lifeline: f.lane,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(language::format(session.project()), before);
|
||||
let mut present = f.project.clone();
|
||||
present.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let text = language::format(&present);
|
||||
assert_ne!(text, before);
|
||||
assert_eq!(language::parse(&text).unwrap(), present);
|
||||
assert_eq!(language::parse(&before).unwrap(), f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let before = original.bundle.clone();
|
||||
let path = original.index.declarations[&f.view].path.clone();
|
||||
for replacement in ["x 1e30", "x not_a_number"] {
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace("x 4.20e2", replacement);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace(
|
||||
&format!("occurrence @{}", f.project.elements[&f.first].alias),
|
||||
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
|
||||
);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
|
||||
let f = fixture();
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &compiled.index.declarations[&f.view];
|
||||
let path = location.path.clone();
|
||||
bundle.files.get_mut(&path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n layout // explicitly automatic\n none;\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut described = original.decoded.project.clone();
|
||||
described.diagrams.get_mut(&f.view).unwrap().description =
|
||||
"Review spacing independently".into();
|
||||
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
|
||||
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
|
||||
assert_eq!(updated.decoded.project, described);
|
||||
let mut empty = described.clone();
|
||||
empty.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(present.decoded.project, empty);
|
||||
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
|
||||
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, described);
|
||||
assert!(!cleared.bundle.files[&path].contains("layout"));
|
||||
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() {
|
||||
let f = fixture();
|
||||
let initial = authored(&f);
|
||||
let mut bundle = initial.bundle.clone();
|
||||
let location = &initial.index.declarations[&f.performer];
|
||||
bundle.files.get_mut(&location.path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n description // explicitly default prose\n \"\";\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut expected = original.decoded.project.clone();
|
||||
let performer = expected.elements.get_mut(&f.performer).unwrap();
|
||||
performer.name = "Reviewed controller".into();
|
||||
performer.alias = "reviewed_controller".into();
|
||||
expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into();
|
||||
let rows = payload(expected.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, expected);
|
||||
let element = &updated.index.declarations[&f.performer];
|
||||
let text = &updated.bundle.files[&element.path][element.span.start..element.span.end];
|
||||
assert!(
|
||||
text.contains("description // explicitly default prose\n \"\";"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("categories"),
|
||||
"an omitted default must stay omitted: {text}"
|
||||
);
|
||||
let diagram = &updated.index.declarations[&f.view];
|
||||
let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap()
|
||||
);
|
||||
assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}");
|
||||
assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap());
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(!text.contains("view_options"), "{text}");
|
||||
expected.diagrams.get_mut(&f.view).unwrap().layout = None;
|
||||
let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, expected);
|
||||
let declaration = &cleared.index.declarations[&f.view];
|
||||
let text =
|
||||
&cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end];
|
||||
assert!(!text.contains("layout"), "{text}");
|
||||
assert!(text.contains("// Controller spacing rationale."), "{text}");
|
||||
assert_eq!(
|
||||
update_bundle(&cleared, &expected, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
cleared.bundle,
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,22 @@ pub enum Command {
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
EditDiagram(Id, Diagram),
|
||||
SetScenarioLanePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
},
|
||||
RemoveScenarioLanePosition {
|
||||
diagram: Id,
|
||||
lifeline: Id,
|
||||
},
|
||||
SetScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
},
|
||||
RemoveScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
address: OccurrenceEndpoint,
|
||||
},
|
||||
CreateArtifact(ArtifactLink),
|
||||
EditArtifact(Id, ArtifactLink),
|
||||
CreateTrace(Trace),
|
||||
@@ -488,6 +504,26 @@ fn apply_command(
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetScenarioLanePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
|
||||
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::SetScenarioMessagePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioMessagePosition { diagram, address } => {
|
||||
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.artifact_links.insert(value.id, value.clone());
|
||||
@@ -878,6 +914,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::references::check_remap(p, old, new)?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
@@ -904,12 +941,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new);
|
||||
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)?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ModelError(
|
||||
"Scenario layout requires the Scenario geometry operation.".into(),
|
||||
));
|
||||
}
|
||||
let parents = crate::projection::parents(p, d);
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for (id, parent) in &parents {
|
||||
|
||||
@@ -972,7 +972,18 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
|
||||
let context = if old.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&previous.fields),
|
||||
&next.fields,
|
||||
context,
|
||||
local,
|
||||
);
|
||||
}
|
||||
}
|
||||
if old.id != project.id || old.alias != project.alias || old.name != project.name {
|
||||
@@ -1224,6 +1235,29 @@ impl<'a> InsertionSyntax<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// Schema context is explicit: similarly named fields in arbitrary records must
|
||||
// never acquire Scenario row identity or move their authored trivia.
|
||||
#[derive(Clone, Copy)]
|
||||
enum EditContext {
|
||||
Other,
|
||||
Diagram,
|
||||
Layout,
|
||||
Scenario,
|
||||
Lanes,
|
||||
Messages,
|
||||
}
|
||||
impl EditContext {
|
||||
fn field(self, name: &str) -> Self {
|
||||
match (self, name) {
|
||||
(Self::Diagram, "layout") => Self::Layout,
|
||||
(Self::Layout, "Scenario") => Self::Scenario,
|
||||
(Self::Scenario, "lanes") => Self::Lanes,
|
||||
(Self::Scenario, "messages") => Self::Messages,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
@@ -1301,6 +1335,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
@@ -1316,7 +1351,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.record(actual.span, fields, previous, next, edits);
|
||||
self.record(actual.span, fields, previous, next, context, edits);
|
||||
}
|
||||
(EditKind::List(items), EditKind::List(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
@@ -1326,7 +1361,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.list(actual.span, items, previous, next, edits);
|
||||
self.list(actual.span, items, previous, next, context, edits);
|
||||
}
|
||||
(EditKind::Reference(_), EditKind::Reference(alias)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
@@ -1379,6 +1414,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &BTreeMap<String, language::EditField>,
|
||||
before: Option<&BTreeMap<String, language::EditField>>,
|
||||
after: &BTreeMap<String, language::EditField>,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
// A typed constructor tag change retains shared fields and their trivia.
|
||||
@@ -1402,7 +1438,13 @@ impl SyntaxUpdate<'_> {
|
||||
},
|
||||
new_key.clone(),
|
||||
));
|
||||
self.value(&field.value, Some(&old.value), &new.value, edits);
|
||||
self.value(
|
||||
&field.value,
|
||||
Some(&old.value),
|
||||
&new.value,
|
||||
context.field(new_key),
|
||||
edits,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1414,9 +1456,16 @@ impl SyntaxUpdate<'_> {
|
||||
.and_then(|fields| fields.get(key))
|
||||
.map(|field| &field.value),
|
||||
&next.value,
|
||||
context.field(key),
|
||||
edits,
|
||||
);
|
||||
} else {
|
||||
} else if !(matches!(context, EditContext::Diagram)
|
||||
&& key == "layout"
|
||||
&& before.is_some_and(|fields| !fields.contains_key(key)))
|
||||
{
|
||||
// Canonical None omits layout. Preserve an authored explicit
|
||||
// none while another field changes; a real Some->None clear
|
||||
// still removes the former payload and retains its trivia.
|
||||
erase_syntax(self.source, field.span, edits);
|
||||
}
|
||||
}
|
||||
@@ -1470,6 +1519,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &[language::EditValue],
|
||||
before: Option<&[language::EditValue]>,
|
||||
after: &[language::EditValue],
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
@@ -1482,6 +1532,7 @@ impl SyntaxUpdate<'_> {
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1494,6 +1545,7 @@ impl SyntaxUpdate<'_> {
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1522,7 +1574,7 @@ impl SyntaxUpdate<'_> {
|
||||
.all(|(i, source)| *source == Some(i))
|
||||
{
|
||||
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
|
||||
self.value(current, previous.get(i), next, edits);
|
||||
self.value(current, previous.get(i), next, EditContext::Other, edits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1555,7 +1607,13 @@ impl SyntaxUpdate<'_> {
|
||||
let chunk = chunks[index];
|
||||
text.push_str(&self.source[chunk.start..current.span.start]);
|
||||
let mut local = Vec::new();
|
||||
self.value(current, previous.get(index), next, &mut local);
|
||||
self.value(
|
||||
current,
|
||||
previous.get(index),
|
||||
next,
|
||||
EditContext::Other,
|
||||
&mut local,
|
||||
);
|
||||
text.push_str(&changed_text(self.source, current.span, local));
|
||||
suffix = &self.source[current.span.end..chunk.end];
|
||||
} else {
|
||||
@@ -1594,14 +1652,58 @@ impl SyntaxUpdate<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn scenario_item_key(
|
||||
value: &language::EditValue,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
let EditKind::Record(fields) = &value.kind else {
|
||||
return None;
|
||||
};
|
||||
let reference = |value: &language::EditValue| {
|
||||
if let EditKind::Reference(alias) = &value.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match context {
|
||||
EditContext::Lanes => {
|
||||
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
|
||||
}
|
||||
EditContext::Messages => {
|
||||
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
|
||||
return None;
|
||||
};
|
||||
let occurrence = reference(&address.get("occurrence")?.value)?;
|
||||
let path: Vec<_> = match address.get("references") {
|
||||
None => Vec::new(),
|
||||
Some(field) => {
|
||||
let EditKind::List(path) = &field.value.kind else {
|
||||
return None;
|
||||
};
|
||||
path.iter().map(reference).collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
};
|
||||
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(context, EditContext::Lanes | EditContext::Messages) {
|
||||
return scenario_item_key(value, aliases, context);
|
||||
}
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
|
||||
@@ -72,6 +72,11 @@ pub(super) fn upgrade(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
if project.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
},
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,6 +619,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
crate::scenario_layout::validate_diagram(p, d, &mut out);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user