Compose saved Scenario authoring with current Text and input authority

This commit is contained in:
2026-09-23 00:25:20 -07:00
parent 5960563b23
commit 6f4dfc67b3
78 changed files with 14097 additions and 318 deletions
@@ -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);
}