Plan whole saved-view model deletion from exact visible groups
This commit is contained in:
@@ -43,6 +43,8 @@ pub use port_labels::PortLabels;
|
|||||||
#[path = "diagram/focus.rs"]
|
#[path = "diagram/focus.rs"]
|
||||||
pub(crate) mod focus;
|
pub(crate) mod focus;
|
||||||
pub(crate) mod group_gesture;
|
pub(crate) mod group_gesture;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod group_removal;
|
||||||
mod multiselect;
|
mod multiselect;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod multiselect_tests;
|
mod multiselect_tests;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Pure, whole-selection Delete from model planning for saved architecture and
|
||||||
|
//! State views. UI lifetime, drafts and confirmation remain the caller's concern.
|
||||||
|
use crate::diagram_usage::Target;
|
||||||
|
use oa_core::{
|
||||||
|
Command, Id, Kind, Project, execute_batch, projection, references, validate_candidate,
|
||||||
|
};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Plan {
|
||||||
|
pub(crate) command: Command,
|
||||||
|
/// Semantic identities resolved from every admitted exact target.
|
||||||
|
pub(crate) selected: BTreeSet<Id>,
|
||||||
|
/// Actual identity and surviving-record differences after the complete batch.
|
||||||
|
pub(crate) removed: BTreeSet<Id>,
|
||||||
|
pub(crate) changed: BTreeSet<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||||
|
let saved = project
|
||||||
|
.diagrams
|
||||||
|
.get(&diagram)
|
||||||
|
.ok_or("The saved view no longer exists.")?;
|
||||||
|
if !saved.projection.is_architecture() && saved.projection.state_machine().is_none() {
|
||||||
|
return Err("Delete from model supports saved Architecture and State views here.".into());
|
||||||
|
}
|
||||||
|
if targets.is_empty() {
|
||||||
|
return Err("Select at least one visible model object to delete.".into());
|
||||||
|
}
|
||||||
|
validate_candidate(project).map_err(|error| error.to_string())?;
|
||||||
|
let view = projection::project_diagram(project, saved);
|
||||||
|
let mut selected = BTreeSet::new();
|
||||||
|
// Resolve every original typed identity BEFORE removing any cascade overlap.
|
||||||
|
// A stale/hidden/wrong-kind target cannot become admissible because another
|
||||||
|
// selection member would happen to delete its semantic identity.
|
||||||
|
for (index, target) in targets.iter().enumerate() {
|
||||||
|
if targets[..index].contains(target) {
|
||||||
|
return Err("Every selected exact target must be unique.".into());
|
||||||
|
}
|
||||||
|
let id = match target {
|
||||||
|
Target::Placement(id) => saved.nodes.get(id)
|
||||||
|
.filter(|node| view.nodes.iter().any(|visible| visible.id == *id && visible.element == node.element))
|
||||||
|
.map(|node| node.element)
|
||||||
|
.ok_or("A selected placement is no longer visible in this saved view.")?,
|
||||||
|
Target::Port { port, placement } => {
|
||||||
|
let owner = saved.nodes.get(placement)
|
||||||
|
.ok_or("A selected port's exact owner placement no longer exists in this view.")?;
|
||||||
|
if !view.ports.iter().any(|visible| visible.element == *port && visible.owner == *placement)
|
||||||
|
|| !project.elements.get(port).is_some_and(|element| element.kind() == Kind::Port && element.parent == Some(owner.element))
|
||||||
|
{
|
||||||
|
return Err("A selected port is no longer visible on its exact owner placement.".into());
|
||||||
|
}
|
||||||
|
*port
|
||||||
|
}
|
||||||
|
Target::Edge(id) => {
|
||||||
|
if !view.edges.iter().any(|edge| edge.id == *id)
|
||||||
|
|| !(project.relations.contains_key(id) || project.traces.contains_key(id))
|
||||||
|
{
|
||||||
|
return Err("Every selected edge must be a visible stored relation or trace; inferred edges are read-only.".into());
|
||||||
|
}
|
||||||
|
*id
|
||||||
|
}
|
||||||
|
Target::StateRow { placement, relation } => {
|
||||||
|
if !crate::behavior::state_canvas::visible_row_target(project, diagram, &view, *placement, *relation) {
|
||||||
|
return Err("A selected State row is no longer visible on its exact placement.".into());
|
||||||
|
}
|
||||||
|
*relation
|
||||||
|
}
|
||||||
|
_ => return Err("The complete selection must contain only visible placements, ports, stored edges or State rows; grouping and read-only targets cannot be deleted here.".into()),
|
||||||
|
};
|
||||||
|
selected.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer cascades covering the most remaining selected subjects. Selecting a
|
||||||
|
// parent together with its child/port/incident relation must not issue Delete
|
||||||
|
// for an identity already removed by that parent. Stable ID order breaks ties.
|
||||||
|
// Core defers conditional exchange-allocation cleanup until batch completion;
|
||||||
|
// admission and the final result are therefore never singleton-validated.
|
||||||
|
let impacts: BTreeMap<_, _> = selected
|
||||||
|
.iter()
|
||||||
|
.map(|&id| (id, references::deletion_impact(project, id)))
|
||||||
|
.collect();
|
||||||
|
let mut pending = selected.clone();
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
while !pending.is_empty() {
|
||||||
|
let id = *pending
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|&&id| {
|
||||||
|
(
|
||||||
|
impacts[&id].intersection(&pending).count(),
|
||||||
|
std::cmp::Reverse(id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
commands.push(Command::Delete(id));
|
||||||
|
pending.retain(|remaining| !impacts[&id].contains(remaining));
|
||||||
|
}
|
||||||
|
let command = Command::Batch(commands);
|
||||||
|
let candidate = execute_batch(project, std::slice::from_ref(&command))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||||
|
let retained: BTreeSet<_> = candidate
|
||||||
|
.identities()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.collect();
|
||||||
|
if !retained.is_subset(&original) {
|
||||||
|
return Err("Removal unexpectedly created model identities; no change was applied.".into());
|
||||||
|
}
|
||||||
|
let removed: BTreeSet<_> = original.difference(&retained).copied().collect();
|
||||||
|
if !selected.is_subset(&removed) {
|
||||||
|
return Err("The complete selected set could not be removed.".into());
|
||||||
|
}
|
||||||
|
let mut changed = BTreeSet::new();
|
||||||
|
changed_records(&project.elements, &candidate.elements, &mut changed);
|
||||||
|
changed_records(&project.relations, &candidate.relations, &mut changed);
|
||||||
|
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
|
||||||
|
changed_records(&project.traces, &candidate.traces, &mut changed);
|
||||||
|
changed_records(
|
||||||
|
&project.artifact_links,
|
||||||
|
&candidate.artifact_links,
|
||||||
|
&mut changed,
|
||||||
|
);
|
||||||
|
for (&id, diagram) in &project.diagrams {
|
||||||
|
if let Some(after) = candidate.diagrams.get(&id) {
|
||||||
|
changed_records(&diagram.nodes, &after.nodes, &mut changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Plan {
|
||||||
|
command,
|
||||||
|
selected,
|
||||||
|
removed,
|
||||||
|
changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed_records<T: PartialEq>(
|
||||||
|
before: &BTreeMap<Id, T>,
|
||||||
|
after: &BTreeMap<Id, T>,
|
||||||
|
changed: &mut BTreeSet<Id>,
|
||||||
|
) {
|
||||||
|
changed.extend(
|
||||||
|
before
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod conditional_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! Independent conditional-cascade checks for overlapping selected endpoint allocations.
|
||||||
|
use super::prepare;
|
||||||
|
use crate::diagram_usage::Target;
|
||||||
|
use oa_core::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
project: Project,
|
||||||
|
diagram: Id,
|
||||||
|
selected_allocations: [Id; 2],
|
||||||
|
exchange_allocation: Id,
|
||||||
|
functional_exchange: Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation(project: &mut Project, number: u128, alias: &str, body: RelationBody) -> Id {
|
||||||
|
let id = Id::from_u128(number);
|
||||||
|
assert!(
|
||||||
|
project
|
||||||
|
.relations
|
||||||
|
.insert(
|
||||||
|
id,
|
||||||
|
Relation {
|
||||||
|
id,
|
||||||
|
alias: alias.into(),
|
||||||
|
name: alias.into(),
|
||||||
|
description: String::new(),
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(reverse_ids: bool, invalid_dual_mapping: bool) -> Fixture {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let performer = project
|
||||||
|
.add_element(Kind::System, Layer::Sa, "Send performer", None)
|
||||||
|
.unwrap();
|
||||||
|
let receiver = if invalid_dual_mapping {
|
||||||
|
performer
|
||||||
|
} else {
|
||||||
|
project
|
||||||
|
.add_element(Kind::SystemActor, Layer::Sa, "Receive performer", None)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let source = project
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||||
|
.unwrap();
|
||||||
|
let target = project
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||||
|
.unwrap();
|
||||||
|
let retained = project.relate(RelationKind::Allocation, "Performs send", performer, source);
|
||||||
|
project.relate(
|
||||||
|
RelationKind::Allocation,
|
||||||
|
"Performs receive",
|
||||||
|
receiver,
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
let functional_exchange = project.relate(
|
||||||
|
RelationKind::FunctionalExchange,
|
||||||
|
"Information",
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
let (output, input) = match project.relations[&functional_exchange].body {
|
||||||
|
RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} => (source_port, target_port),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let mut component_ports = vec![];
|
||||||
|
for (owner, name) in [
|
||||||
|
(performer, "First component port"),
|
||||||
|
(receiver, "Second component port"),
|
||||||
|
] {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let port = make_port(
|
||||||
|
&project,
|
||||||
|
id,
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: ComponentPortOrientation::InOut,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
project.elements.insert(id, port);
|
||||||
|
component_ports.push(id);
|
||||||
|
}
|
||||||
|
let [a, b] = [component_ports[0], component_ports[1]];
|
||||||
|
let boundary = relation(
|
||||||
|
&mut project,
|
||||||
|
300,
|
||||||
|
"boundary",
|
||||||
|
RelationBody::BoundaryExchange {
|
||||||
|
source: a,
|
||||||
|
target: b,
|
||||||
|
oriented: false,
|
||||||
|
items: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Either endpoint allocation is required. Their overlapping conditional
|
||||||
|
// cascade must remove the exchange allocation once, after one whole batch.
|
||||||
|
let numbers = if reverse_ids { [200, 100] } else { [100, 200] };
|
||||||
|
let first = relation(
|
||||||
|
&mut project,
|
||||||
|
numbers[0],
|
||||||
|
"first_output",
|
||||||
|
RelationBody::PortAllocation {
|
||||||
|
source: a,
|
||||||
|
target: output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let second = relation(
|
||||||
|
&mut project,
|
||||||
|
numbers[1],
|
||||||
|
"second_input",
|
||||||
|
RelationBody::PortAllocation {
|
||||||
|
source: b,
|
||||||
|
target: input,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if invalid_dual_mapping {
|
||||||
|
// Two simultaneous direct/reverse mappings would need the same actual
|
||||||
|
// performer at both ends, violating the different-boundary-owners rule.
|
||||||
|
relation(
|
||||||
|
&mut project,
|
||||||
|
400,
|
||||||
|
"first_input",
|
||||||
|
RelationBody::PortAllocation {
|
||||||
|
source: a,
|
||||||
|
target: input,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
relation(
|
||||||
|
&mut project,
|
||||||
|
500,
|
||||||
|
"second_output",
|
||||||
|
RelationBody::PortAllocation {
|
||||||
|
source: b,
|
||||||
|
target: output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let exchange_allocation = relation(
|
||||||
|
&mut project,
|
||||||
|
150,
|
||||||
|
"realizes_exchange",
|
||||||
|
RelationBody::ExchangeAllocation {
|
||||||
|
source: boundary,
|
||||||
|
target: functional_exchange,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let diagram = project.ensure_diagram(Layer::Sa);
|
||||||
|
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||||
|
for (element, x) in [
|
||||||
|
(performer, 40.),
|
||||||
|
(receiver, 1360.),
|
||||||
|
(source, 480.),
|
||||||
|
(target, 920.),
|
||||||
|
] {
|
||||||
|
project.place(diagram, element, Position { x, y: 200. });
|
||||||
|
}
|
||||||
|
project.diagrams.get_mut(&diagram).unwrap().routes = [first, retained]
|
||||||
|
.into_iter()
|
||||||
|
.map(|edge| EdgeRoute {
|
||||||
|
edge,
|
||||||
|
source_attachment: None,
|
||||||
|
target_attachment: None,
|
||||||
|
waypoints: vec![Position { x: 360., y: 350. }],
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Fixture {
|
||||||
|
project,
|
||||||
|
diagram,
|
||||||
|
selected_allocations: [first, second],
|
||||||
|
exchange_allocation,
|
||||||
|
functional_exchange,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlapping_conditional_allocation_loss_is_reviewed_from_final_candidate() {
|
||||||
|
for reverse_ids in [false, true] {
|
||||||
|
let f = fixture(reverse_ids, false);
|
||||||
|
validate_candidate(&f.project)
|
||||||
|
.expect("Separate actual performers and required endpoint mappings are valid");
|
||||||
|
for selected in f.selected_allocations {
|
||||||
|
assert!(
|
||||||
|
references::deletion_impact(&f.project, selected).contains(&f.exchange_allocation),
|
||||||
|
"Both selected allocations overlap on the same conditional dependent"
|
||||||
|
);
|
||||||
|
let singleton = execute_batch(&f.project, &[Command::Delete(selected)]).unwrap();
|
||||||
|
assert!(!singleton.relations.contains_key(&f.exchange_allocation));
|
||||||
|
}
|
||||||
|
let targets = f.selected_allocations.map(Target::Edge);
|
||||||
|
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
|
||||||
|
let reversed = prepare(
|
||||||
|
&f.project,
|
||||||
|
f.diagram,
|
||||||
|
&[targets[1].clone(), targets[0].clone()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&plan.command).unwrap(),
|
||||||
|
serde_json::to_value(&reversed.command).unwrap(),
|
||||||
|
"Click order must not change normalization"
|
||||||
|
);
|
||||||
|
let removed = BTreeSet::from([
|
||||||
|
f.selected_allocations[0],
|
||||||
|
f.selected_allocations[1],
|
||||||
|
f.exchange_allocation,
|
||||||
|
]);
|
||||||
|
assert_eq!(plan.selected, BTreeSet::from(f.selected_allocations));
|
||||||
|
assert_eq!(plan.removed, removed);
|
||||||
|
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
|
||||||
|
// Literal record oracle, independent of deletion_impact / planner output.
|
||||||
|
let mut expected = f.project.clone();
|
||||||
|
for id in removed {
|
||||||
|
expected.relations.remove(&id);
|
||||||
|
}
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.diagram)
|
||||||
|
.unwrap()
|
||||||
|
.routes
|
||||||
|
.remove(0);
|
||||||
|
let actual = execute_batch(&f.project, &[plan.command]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
actual, expected,
|
||||||
|
"Only two port allocations, their conditional exchange allocation and the deleted edge's authored route change"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_exchange_cascade_and_conditional_support_loss_share_one_batch() {
|
||||||
|
for reverse_ids in [false, true] {
|
||||||
|
let f = fixture(reverse_ids, false);
|
||||||
|
validate_candidate(&f.project)
|
||||||
|
.expect("Separate actual performers and required endpoint mappings are valid");
|
||||||
|
let targets = [
|
||||||
|
Target::Edge(f.selected_allocations[0]),
|
||||||
|
Target::Edge(f.functional_exchange),
|
||||||
|
Target::Edge(f.selected_allocations[1]),
|
||||||
|
];
|
||||||
|
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
|
||||||
|
let removed = BTreeSet::from([
|
||||||
|
f.selected_allocations[0],
|
||||||
|
f.selected_allocations[1],
|
||||||
|
f.functional_exchange,
|
||||||
|
f.exchange_allocation,
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
plan.selected,
|
||||||
|
BTreeSet::from([
|
||||||
|
f.selected_allocations[0],
|
||||||
|
f.selected_allocations[1],
|
||||||
|
f.functional_exchange
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(plan.removed, removed);
|
||||||
|
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
|
||||||
|
let mut expected = f.project.clone();
|
||||||
|
for id in removed {
|
||||||
|
expected.relations.remove(&id);
|
||||||
|
}
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.diagram)
|
||||||
|
.unwrap()
|
||||||
|
.routes
|
||||||
|
.remove(0);
|
||||||
|
assert_eq!(
|
||||||
|
execute_batch(&f.project, &[plan.command]).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alternative_pairing_counterexample_is_not_a_valid_saved_model() {
|
||||||
|
let f = fixture(false, true);
|
||||||
|
assert!(validation::validate(&f.project).iter().any(
|
||||||
|
|issue| issue.code == "boundary_owner" && issue.severity == validation::Severity::Error
|
||||||
|
));
|
||||||
|
let before = f.project.clone();
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&f.project,
|
||||||
|
f.diagram,
|
||||||
|
&f.selected_allocations.map(Target::Edge)
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(f.project, before);
|
||||||
|
}
|
||||||
@@ -0,0 +1,896 @@
|
|||||||
|
use super::*;
|
||||||
|
use oa_core::*;
|
||||||
|
|
||||||
|
fn pos(x: f32, y: f32) -> Position {
|
||||||
|
Position { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagram(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
project.diagrams.insert(
|
||||||
|
id,
|
||||||
|
Diagram {
|
||||||
|
id,
|
||||||
|
alias: format!("view_{}", id.simple()),
|
||||||
|
name: "Saved view".into(),
|
||||||
|
description: String::new(),
|
||||||
|
layer,
|
||||||
|
projection,
|
||||||
|
view_options: Default::default(),
|
||||||
|
nodes: BTreeMap::new(),
|
||||||
|
routes: vec![],
|
||||||
|
layout: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact(project: &mut Project, owner: Id) -> Id {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
project.artifact_links.insert(
|
||||||
|
id,
|
||||||
|
ArtifactLink {
|
||||||
|
id,
|
||||||
|
alias: format!("evidence_{}", id.simple()),
|
||||||
|
name: "Evidence".into(),
|
||||||
|
description: String::new(),
|
||||||
|
owner,
|
||||||
|
relation: ArtifactRelation::References,
|
||||||
|
uri: "docs/evidence.md".into(),
|
||||||
|
revision: Some("r1".into()),
|
||||||
|
fragment: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(edge: Id) -> EdgeRoute {
|
||||||
|
EdgeRoute {
|
||||||
|
edge,
|
||||||
|
source_attachment: None,
|
||||||
|
target_attachment: None,
|
||||||
|
waypoints: vec![pos(450., 150.)],
|
||||||
|
label: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every success compares the whole resulting Project with a separately authored
|
||||||
|
/// expected value; the planner's own impact calculation is never the oracle.
|
||||||
|
fn check(
|
||||||
|
project: &Project,
|
||||||
|
view: Id,
|
||||||
|
targets: &[Target],
|
||||||
|
expected: &Project,
|
||||||
|
selected: &[Id],
|
||||||
|
removed: &[Id],
|
||||||
|
changed: &[Id],
|
||||||
|
) -> Plan {
|
||||||
|
validate_candidate(project).unwrap();
|
||||||
|
validate_candidate(expected).unwrap();
|
||||||
|
let original = project.clone();
|
||||||
|
let plan = prepare(project, view, targets).unwrap();
|
||||||
|
assert_eq!(project, &original, "planning must be pure");
|
||||||
|
assert_eq!(plan.selected, selected.iter().copied().collect());
|
||||||
|
assert_eq!(plan.removed, removed.iter().copied().collect());
|
||||||
|
assert_eq!(plan.changed, changed.iter().copied().collect());
|
||||||
|
assert_eq!(
|
||||||
|
execute_batch(project, std::slice::from_ref(&plan.command)).unwrap(),
|
||||||
|
*expected
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(&plan.command, Command::Batch(commands) if commands.iter().all(|command| matches!(command, Command::Delete(_))))
|
||||||
|
);
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn architecture_parent_child_and_incident_edge_collapse_to_one_delete_across_views() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let kind = if layer == Layer::Oa {
|
||||||
|
Kind::Activity
|
||||||
|
} else {
|
||||||
|
Kind::Function
|
||||||
|
};
|
||||||
|
let parent = project.add_element(kind, layer, "Parent", None).unwrap();
|
||||||
|
let child = project
|
||||||
|
.add_element(kind, layer, "Child", Some(parent))
|
||||||
|
.unwrap();
|
||||||
|
let survivor = project.add_element(kind, layer, "Survivor", None).unwrap();
|
||||||
|
let edge_kind = if layer == Layer::Oa {
|
||||||
|
RelationKind::Interaction
|
||||||
|
} else {
|
||||||
|
RelationKind::FunctionalExchange
|
||||||
|
};
|
||||||
|
let edge = project.relate(edge_kind, "Incident", child, survivor);
|
||||||
|
let ports = match project.relations[&edge].body {
|
||||||
|
RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} => Some((source_port, target_port)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let evidence = artifact(&mut project, child);
|
||||||
|
let view = diagram(&mut project, layer, ProjectionKind::Overview);
|
||||||
|
let parent_node = project.place(view, parent, pos(0., 0.));
|
||||||
|
let child_node = project.place(view, child, pos(30., 80.));
|
||||||
|
project.place(view, survivor, pos(800., 100.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.routes
|
||||||
|
.push(route(edge));
|
||||||
|
let other_view = diagram(&mut project, layer, ProjectionKind::Overview);
|
||||||
|
let other_child = project.place(other_view, child, pos(100., 100.));
|
||||||
|
project.place(other_view, survivor, pos(800., 100.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&other_view)
|
||||||
|
.unwrap()
|
||||||
|
.view_options
|
||||||
|
.hidden_edges
|
||||||
|
.push(edge);
|
||||||
|
let mut expected = project.clone();
|
||||||
|
expected.elements.remove(&parent);
|
||||||
|
expected.elements.remove(&child);
|
||||||
|
expected.relations.remove(&edge);
|
||||||
|
expected.artifact_links.remove(&evidence);
|
||||||
|
let mut removed = vec![
|
||||||
|
parent,
|
||||||
|
child,
|
||||||
|
edge,
|
||||||
|
evidence,
|
||||||
|
parent_node,
|
||||||
|
child_node,
|
||||||
|
other_child,
|
||||||
|
];
|
||||||
|
if let Some((source_port, _)) = ports {
|
||||||
|
expected.elements.remove(&source_port);
|
||||||
|
removed.push(source_port);
|
||||||
|
}
|
||||||
|
let current = expected.diagrams.get_mut(&view).unwrap();
|
||||||
|
current.nodes.remove(&parent_node);
|
||||||
|
current.nodes.remove(&child_node);
|
||||||
|
current.routes.clear();
|
||||||
|
let other = expected.diagrams.get_mut(&other_view).unwrap();
|
||||||
|
other.nodes.remove(&other_child);
|
||||||
|
other.view_options.hidden_edges.clear();
|
||||||
|
for targets in [
|
||||||
|
vec![
|
||||||
|
Target::Placement(parent_node),
|
||||||
|
Target::Placement(child_node),
|
||||||
|
Target::Edge(edge),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
Target::Edge(edge),
|
||||||
|
Target::Placement(child_node),
|
||||||
|
Target::Placement(parent_node),
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
let plan = check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&targets,
|
||||||
|
&expected,
|
||||||
|
&[parent, child, edge],
|
||||||
|
&removed,
|
||||||
|
&[view, other_view],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(plan.command, Command::Batch(commands) if matches!(commands.as_slice(), [Command::Delete(id)] if *id == parent))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn port_owner_port_and_exchange_admission_preserve_other_endpoint_and_nested_anchor_diff() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let a = project
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||||
|
.unwrap();
|
||||||
|
let b = project
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||||
|
.unwrap();
|
||||||
|
let edge = project.relate(RelationKind::FunctionalExchange, "Payload", a, b);
|
||||||
|
let RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} = project.relations[&edge].body
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let view = diagram(&mut project, Layer::Sa, ProjectionKind::FunctionalDataflow);
|
||||||
|
let na = project.place(view, a, pos(0., 0.));
|
||||||
|
let nb = project.place(view, b, pos(700., 0.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&na)
|
||||||
|
.unwrap()
|
||||||
|
.port_anchors
|
||||||
|
.push(PortAnchor {
|
||||||
|
port: source_port,
|
||||||
|
side: PortAnchorSide::Top,
|
||||||
|
offset: 0.3,
|
||||||
|
});
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.routes
|
||||||
|
.push(route(edge));
|
||||||
|
|
||||||
|
let mut port_expected = project.clone();
|
||||||
|
port_expected.elements.remove(&source_port);
|
||||||
|
port_expected.relations.remove(&edge);
|
||||||
|
let d = port_expected.diagrams.get_mut(&view).unwrap();
|
||||||
|
d.routes.clear();
|
||||||
|
d.nodes.get_mut(&na).unwrap().port_anchors.clear();
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Port {
|
||||||
|
port: source_port,
|
||||||
|
placement: na,
|
||||||
|
},
|
||||||
|
Target::Edge(edge),
|
||||||
|
],
|
||||||
|
&port_expected,
|
||||||
|
&[source_port, edge],
|
||||||
|
&[source_port, edge],
|
||||||
|
&[view, na],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut owner_expected = port_expected;
|
||||||
|
owner_expected.elements.remove(&a);
|
||||||
|
owner_expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.remove(&na);
|
||||||
|
let plan = check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Port {
|
||||||
|
port: source_port,
|
||||||
|
placement: na,
|
||||||
|
},
|
||||||
|
Target::Placement(na),
|
||||||
|
Target::Edge(edge),
|
||||||
|
],
|
||||||
|
&owner_expected,
|
||||||
|
&[a, source_port, edge],
|
||||||
|
&[a, source_port, edge, na],
|
||||||
|
&[view],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
owner_expected.elements.contains_key(&b)
|
||||||
|
&& owner_expected.elements.contains_key(&target_port)
|
||||||
|
);
|
||||||
|
assert!(owner_expected.diagrams[&view].nodes.contains_key(&nb));
|
||||||
|
assert!(matches!(plan.command, Command::Batch(commands) if commands.len() == 1));
|
||||||
|
// A wrong owner placement is invalid even though the selected owner covers it.
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Placement(na),
|
||||||
|
Target::Port {
|
||||||
|
port: source_port,
|
||||||
|
placement: nb
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_rows_delete_relations_not_referenced_work_and_overlap_with_state_cascade() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let machine = project
|
||||||
|
.add_element(Kind::StateMachine, layer, "Modes", None)
|
||||||
|
.unwrap();
|
||||||
|
let region = project
|
||||||
|
.add_element(Kind::Region, layer, "Main", Some(machine))
|
||||||
|
.unwrap();
|
||||||
|
let state = project
|
||||||
|
.add_element(Kind::State, layer, "Ready", Some(region))
|
||||||
|
.unwrap();
|
||||||
|
let other = project
|
||||||
|
.add_element(Kind::State, layer, "Other", Some(region))
|
||||||
|
.unwrap();
|
||||||
|
let work = project
|
||||||
|
.add_element(
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
Kind::Activity
|
||||||
|
} else {
|
||||||
|
Kind::Function
|
||||||
|
},
|
||||||
|
layer,
|
||||||
|
"Work",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let action = project.relate(RelationKind::StateAction, "Entry", state, work);
|
||||||
|
let internal = project.relate(RelationKind::StateTransition, "Internal", state, state);
|
||||||
|
if let RelationBody::StateTransition {
|
||||||
|
transition_kind,
|
||||||
|
effects,
|
||||||
|
..
|
||||||
|
} = &mut project.relations.get_mut(&internal).unwrap().body
|
||||||
|
{
|
||||||
|
*transition_kind = TransitionKind::Internal;
|
||||||
|
*effects = vec![work];
|
||||||
|
}
|
||||||
|
let external = project.relate(RelationKind::StateTransition, "External", state, other);
|
||||||
|
let view = diagram(
|
||||||
|
&mut project,
|
||||||
|
layer,
|
||||||
|
ProjectionKind::StateMachine { machine },
|
||||||
|
);
|
||||||
|
let state_node = project.place(view, state, pos(100., 100.));
|
||||||
|
let other_node = project.place(view, other, pos(700., 100.));
|
||||||
|
let rows = [
|
||||||
|
Target::StateRow {
|
||||||
|
placement: state_node,
|
||||||
|
relation: action,
|
||||||
|
},
|
||||||
|
Target::StateRow {
|
||||||
|
placement: state_node,
|
||||||
|
relation: internal,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut expected = project.clone();
|
||||||
|
expected.relations.remove(&action);
|
||||||
|
expected.relations.remove(&internal);
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&rows,
|
||||||
|
&expected,
|
||||||
|
&[action, internal],
|
||||||
|
&[action, internal],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
expected.relations.remove(&external);
|
||||||
|
expected.elements.remove(&state);
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.remove(&state_node);
|
||||||
|
let mut targets = rows.to_vec();
|
||||||
|
targets.extend([Target::Placement(state_node), Target::Edge(external)]);
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&targets,
|
||||||
|
&expected,
|
||||||
|
&[action, internal, state, external],
|
||||||
|
&[action, internal, state, external, state_node],
|
||||||
|
&[view],
|
||||||
|
);
|
||||||
|
assert_eq!(expected.elements[&work], project.elements[&work]);
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Placement(state_node),
|
||||||
|
Target::StateRow {
|
||||||
|
placement: other_node,
|
||||||
|
relation: action
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(prepare(&project, view, &[Target::Edge(internal)]).is_err());
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::StateRow {
|
||||||
|
placement: state_node,
|
||||||
|
relation: external
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let region_node = project.place(view, region, pos(0., 0.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(®ion_node)
|
||||||
|
.unwrap()
|
||||||
|
.collapsed = true;
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Placement(region_node),
|
||||||
|
Target::StateRow {
|
||||||
|
placement: state_node,
|
||||||
|
relation: action
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"hidden State row cannot borrow its selected ancestor's permission"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_machine_cascades_saved_diagram_and_its_embedded_placements() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let machine = project
|
||||||
|
.add_element(Kind::StateMachine, Layer::Sa, "Modes", None)
|
||||||
|
.unwrap();
|
||||||
|
let region = project
|
||||||
|
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||||
|
.unwrap();
|
||||||
|
let state = project
|
||||||
|
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
||||||
|
.unwrap();
|
||||||
|
let view = diagram(
|
||||||
|
&mut project,
|
||||||
|
Layer::Sa,
|
||||||
|
ProjectionKind::StateMachine { machine },
|
||||||
|
);
|
||||||
|
let machine_node = project.place(view, machine, pos(0., 0.));
|
||||||
|
let region_node = project.place(view, region, pos(30., 90.));
|
||||||
|
let state_node = project.place(view, state, pos(60., 180.));
|
||||||
|
let evidence = artifact(&mut project, view);
|
||||||
|
let architecture = diagram(&mut project, Layer::Sa, ProjectionKind::Overview);
|
||||||
|
let architecture_node = project.place(architecture, machine, pos(0., 0.));
|
||||||
|
let mut expected = project.clone();
|
||||||
|
for id in [machine, region, state] {
|
||||||
|
expected.elements.remove(&id);
|
||||||
|
}
|
||||||
|
expected.diagrams.remove(&view);
|
||||||
|
expected.artifact_links.remove(&evidence);
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&architecture)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.remove(&architecture_node);
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Placement(machine_node),
|
||||||
|
Target::Placement(state_node),
|
||||||
|
],
|
||||||
|
&expected,
|
||||||
|
&[machine, state],
|
||||||
|
&[
|
||||||
|
machine,
|
||||||
|
region,
|
||||||
|
state,
|
||||||
|
view,
|
||||||
|
machine_node,
|
||||||
|
region_node,
|
||||||
|
state_node,
|
||||||
|
evidence,
|
||||||
|
architecture_node,
|
||||||
|
],
|
||||||
|
&[architecture],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_edge_and_endpoint_deletion_preserve_counterpart_unless_explicitly_selected() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let oa = project
|
||||||
|
.add_element(Kind::Activity, Layer::Oa, "Operational", None)
|
||||||
|
.unwrap();
|
||||||
|
let sa = project
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "System", None)
|
||||||
|
.unwrap();
|
||||||
|
let trace = Id::new_v4();
|
||||||
|
project.traces.insert(
|
||||||
|
trace,
|
||||||
|
Trace {
|
||||||
|
id: trace,
|
||||||
|
alias: "refinement".into(),
|
||||||
|
name: "Refinement".into(),
|
||||||
|
description: String::new(),
|
||||||
|
oa,
|
||||||
|
sa,
|
||||||
|
kind: TraceKind::Refinement,
|
||||||
|
baseline: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let evidence = artifact(&mut project, trace);
|
||||||
|
let view = diagram(&mut project, Layer::Oa, ProjectionKind::Traceability);
|
||||||
|
let noa = project.place(view, oa, pos(0., 0.));
|
||||||
|
let nsa = project.place(view, sa, pos(700., 0.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.routes
|
||||||
|
.push(route(trace));
|
||||||
|
let mut expected = project.clone();
|
||||||
|
expected.traces.remove(&trace);
|
||||||
|
expected.artifact_links.remove(&evidence);
|
||||||
|
expected.diagrams.get_mut(&view).unwrap().routes.clear();
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::Edge(trace)],
|
||||||
|
&expected,
|
||||||
|
&[trace],
|
||||||
|
&[trace, evidence],
|
||||||
|
&[view],
|
||||||
|
);
|
||||||
|
expected.elements.remove(&oa);
|
||||||
|
expected.diagrams.get_mut(&view).unwrap().nodes.remove(&noa);
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::Placement(noa), Target::Edge(trace)],
|
||||||
|
&expected,
|
||||||
|
&[oa, trace],
|
||||||
|
&[oa, noa, trace, evidence],
|
||||||
|
&[view],
|
||||||
|
);
|
||||||
|
assert_eq!(expected.elements[&sa], project.elements[&sa]);
|
||||||
|
expected.elements.remove(&sa);
|
||||||
|
expected.diagrams.get_mut(&view).unwrap().nodes.remove(&nsa);
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[
|
||||||
|
Target::Placement(noa),
|
||||||
|
Target::Placement(nsa),
|
||||||
|
Target::Edge(trace),
|
||||||
|
],
|
||||||
|
&expected,
|
||||||
|
&[oa, sa, trace],
|
||||||
|
&[oa, noa, sa, nsa, trace, evidence],
|
||||||
|
&[view],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surviving_semantic_records_report_reference_pruning_as_changed() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let work = project
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Work", None)
|
||||||
|
.unwrap();
|
||||||
|
let machine = project
|
||||||
|
.add_element(Kind::StateMachine, Layer::Sa, "Modes", None)
|
||||||
|
.unwrap();
|
||||||
|
let region = project
|
||||||
|
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||||
|
.unwrap();
|
||||||
|
let a = project
|
||||||
|
.add_element(Kind::State, Layer::Sa, "A", Some(region))
|
||||||
|
.unwrap();
|
||||||
|
let b = project
|
||||||
|
.add_element(Kind::State, Layer::Sa, "B", Some(region))
|
||||||
|
.unwrap();
|
||||||
|
let transition = project.relate(RelationKind::StateTransition, "Advance", a, b);
|
||||||
|
if let RelationBody::StateTransition { effects, .. } =
|
||||||
|
&mut project.relations.get_mut(&transition).unwrap().body
|
||||||
|
{
|
||||||
|
*effects = vec![work];
|
||||||
|
}
|
||||||
|
let chain = project
|
||||||
|
.add_element(Kind::FunctionalChain, Layer::Sa, "Chain", None)
|
||||||
|
.unwrap();
|
||||||
|
let occurrence = project
|
||||||
|
.add_element(Kind::Occurrence, Layer::Sa, "Action", Some(chain))
|
||||||
|
.unwrap();
|
||||||
|
if let Some(SharedBody::Occurrence { body, .. }) =
|
||||||
|
project.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||||
|
{
|
||||||
|
*body = OccurrenceBody::Action {
|
||||||
|
action: Some(work),
|
||||||
|
lifeline: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.get_mut(&chain)
|
||||||
|
.unwrap()
|
||||||
|
.steps_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(occurrence);
|
||||||
|
let view = diagram(&mut project, Layer::Sa, ProjectionKind::Overview);
|
||||||
|
let placement = project.place(view, work, pos(100., 100.));
|
||||||
|
let mut expected = project.clone();
|
||||||
|
expected.elements.remove(&work);
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.remove(&placement);
|
||||||
|
if let RelationBody::StateTransition { effects, .. } =
|
||||||
|
&mut expected.relations.get_mut(&transition).unwrap().body
|
||||||
|
{
|
||||||
|
effects.clear();
|
||||||
|
}
|
||||||
|
if let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Action { action, .. },
|
||||||
|
..
|
||||||
|
}) = expected.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||||
|
{
|
||||||
|
*action = None;
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::Placement(placement)],
|
||||||
|
&expected,
|
||||||
|
&[work],
|
||||||
|
&[work, placement],
|
||||||
|
&[view, occurrence, transition],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_union_is_not_rejected_by_an_invalid_intermediate_singleton() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let sender = project
|
||||||
|
.add_element(Kind::SystemActor, Layer::Sa, "Sender", None)
|
||||||
|
.unwrap();
|
||||||
|
let receiver = project
|
||||||
|
.add_element(Kind::System, Layer::Sa, "Receiver", None)
|
||||||
|
.unwrap();
|
||||||
|
let mut ports = vec![];
|
||||||
|
for owner in [sender, receiver] {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let port = make_port(
|
||||||
|
&project,
|
||||||
|
id,
|
||||||
|
owner,
|
||||||
|
"Service port",
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Standard,
|
||||||
|
orientation: ComponentPortOrientation::Unset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
project.elements.insert(id, port);
|
||||||
|
ports.push(id);
|
||||||
|
}
|
||||||
|
// Ensure the planner's deterministic tie break orders this invalid-alone
|
||||||
|
// deletion first; a singleton transaction implementation would fail here.
|
||||||
|
let protocol = Id::from_u128(1);
|
||||||
|
let mut protocol_element = project
|
||||||
|
.make_element(Kind::Interface, Layer::Sa, "Payload protocol", None)
|
||||||
|
.unwrap();
|
||||||
|
protocol_element.id = protocol;
|
||||||
|
project.elements.insert(protocol, protocol_element);
|
||||||
|
let empty_protocol = project
|
||||||
|
.add_element(Kind::Interface, Layer::Sa, "Other protocol", None)
|
||||||
|
.unwrap();
|
||||||
|
let item = project
|
||||||
|
.add_element(Kind::ExchangeItem, Layer::Sa, "Payload", None)
|
||||||
|
.unwrap();
|
||||||
|
let allocation = project.relate(
|
||||||
|
RelationKind::InterfaceItemAllocation,
|
||||||
|
"Declares payload",
|
||||||
|
protocol,
|
||||||
|
item,
|
||||||
|
);
|
||||||
|
let role = project.relate(
|
||||||
|
RelationKind::InterfaceRequirement,
|
||||||
|
"Requires payload",
|
||||||
|
ports[0],
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
project.relate(
|
||||||
|
RelationKind::InterfaceRequirement,
|
||||||
|
"Requires other",
|
||||||
|
ports[0],
|
||||||
|
empty_protocol,
|
||||||
|
);
|
||||||
|
let boundary = project.relate(
|
||||||
|
RelationKind::BoundaryExchange,
|
||||||
|
"Channel",
|
||||||
|
ports[0],
|
||||||
|
ports[1],
|
||||||
|
);
|
||||||
|
if let RelationBody::BoundaryExchange { items, .. } =
|
||||||
|
&mut project.relations.get_mut(&boundary).unwrap().body
|
||||||
|
{
|
||||||
|
*items = vec![item];
|
||||||
|
}
|
||||||
|
let view = diagram(&mut project, Layer::Sa, ProjectionKind::Overview);
|
||||||
|
project.place(view, sender, pos(0., 0.));
|
||||||
|
project.place(view, receiver, pos(800., 0.));
|
||||||
|
let protocol_node = project.place(view, protocol, pos(400., 300.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.routes
|
||||||
|
.push(route(boundary));
|
||||||
|
validate_candidate(&project).unwrap();
|
||||||
|
// The remaining interface declaration becomes an empty constraint on the
|
||||||
|
// live channel. This is an invalid intermediate state, but deleting the
|
||||||
|
// selected channel in the SAME batch makes the complete intent valid.
|
||||||
|
assert!(execute_batch(&project, &[Command::Delete(protocol)]).is_err());
|
||||||
|
assert!(prepare(&project, view, &[Target::Placement(protocol_node)]).is_err());
|
||||||
|
let mut expected = project.clone();
|
||||||
|
expected.elements.remove(&protocol);
|
||||||
|
for id in [allocation, role, boundary] {
|
||||||
|
expected.relations.remove(&id);
|
||||||
|
}
|
||||||
|
let d = expected.diagrams.get_mut(&view).unwrap();
|
||||||
|
d.nodes.remove(&protocol_node);
|
||||||
|
d.routes.clear();
|
||||||
|
for targets in [
|
||||||
|
vec![Target::Placement(protocol_node), Target::Edge(boundary)],
|
||||||
|
vec![Target::Edge(boundary), Target::Placement(protocol_node)],
|
||||||
|
] {
|
||||||
|
check(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&targets,
|
||||||
|
&expected,
|
||||||
|
&[protocol, boundary],
|
||||||
|
&[protocol, protocol_node, allocation, role, boundary],
|
||||||
|
&[view],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_original_target_must_be_visible_local_supported_and_unique_even_when_covered() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let a = project
|
||||||
|
.add_element(Kind::Activity, Layer::Oa, "A", None)
|
||||||
|
.unwrap();
|
||||||
|
let child = project
|
||||||
|
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||||
|
.unwrap();
|
||||||
|
let b = project
|
||||||
|
.add_element(Kind::Activity, Layer::Oa, "B", None)
|
||||||
|
.unwrap();
|
||||||
|
let edge = project.relate(RelationKind::Interaction, "Flow", child, b);
|
||||||
|
let view = diagram(&mut project, Layer::Oa, ProjectionKind::Overview);
|
||||||
|
let na = project.place(view, a, pos(0., 0.));
|
||||||
|
let nc = project.place(view, child, pos(30., 90.));
|
||||||
|
let nb = project.place(view, b, pos(700., 0.));
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&na)
|
||||||
|
.unwrap()
|
||||||
|
.collapsed = true;
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.view_options
|
||||||
|
.hidden_edges
|
||||||
|
.push(edge);
|
||||||
|
let other = diagram(&mut project, Layer::Oa, ProjectionKind::Overview);
|
||||||
|
let foreign = project.place(other, child, pos(0., 0.));
|
||||||
|
validate_candidate(&project).unwrap();
|
||||||
|
let original = project.clone();
|
||||||
|
for inadmissible in [
|
||||||
|
Target::Whole,
|
||||||
|
Target::Placement(nc),
|
||||||
|
Target::Placement(foreign),
|
||||||
|
Target::Placement(Id::new_v4()),
|
||||||
|
Target::Edge(edge),
|
||||||
|
Target::Edge(Id::new_v4()),
|
||||||
|
Target::Edge(a),
|
||||||
|
Target::Placement(a),
|
||||||
|
Target::Port {
|
||||||
|
port: child,
|
||||||
|
placement: na,
|
||||||
|
},
|
||||||
|
Target::StateRow {
|
||||||
|
placement: na,
|
||||||
|
relation: edge,
|
||||||
|
},
|
||||||
|
Target::AllocationGrouping {
|
||||||
|
relation: edge,
|
||||||
|
work_placement: nc,
|
||||||
|
performer_placement: nb,
|
||||||
|
},
|
||||||
|
Target::ProcessLink(edge),
|
||||||
|
Target::ScenarioObject(child),
|
||||||
|
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||||
|
references: vec![],
|
||||||
|
occurrence: child,
|
||||||
|
}),
|
||||||
|
Target::ProcessPreviewLink {
|
||||||
|
relation: edge,
|
||||||
|
source: OccurrenceEndpoint {
|
||||||
|
references: vec![],
|
||||||
|
occurrence: a,
|
||||||
|
},
|
||||||
|
target: OccurrenceEndpoint {
|
||||||
|
references: vec![],
|
||||||
|
occurrence: b,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::Placement(na), inadmissible.clone()]
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"accepted {inadmissible:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(project, original);
|
||||||
|
}
|
||||||
|
assert!(prepare(&project, view, &[]).is_err());
|
||||||
|
assert!(prepare(&project, Id::new_v4(), &[Target::Placement(na)]).is_err());
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::Placement(na), Target::Placement(na)]
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scenario_surface_and_invalid_duplicate_placements_are_rejected_before_planning() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
let activity = project
|
||||||
|
.add_element(Kind::Activity, Layer::Oa, "Work", None)
|
||||||
|
.unwrap();
|
||||||
|
let view = diagram(&mut project, Layer::Oa, ProjectionKind::Overview);
|
||||||
|
let node = project.place(view, activity, pos(0., 0.));
|
||||||
|
let scenario = project
|
||||||
|
.add_element(Kind::Scenario, Layer::Oa, "Scenario", None)
|
||||||
|
.unwrap();
|
||||||
|
let scenario_view = diagram(
|
||||||
|
&mut project,
|
||||||
|
Layer::Oa,
|
||||||
|
ProjectionKind::Scenario { scenario },
|
||||||
|
);
|
||||||
|
assert!(prepare(&project, scenario_view, &[Target::Placement(node)]).is_err());
|
||||||
|
let duplicate = Id::new_v4();
|
||||||
|
let mut placement = project.diagrams[&view].nodes[&node].clone();
|
||||||
|
placement.id = duplicate;
|
||||||
|
placement.alias = "duplicate_placement".into();
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&view)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.insert(duplicate, placement);
|
||||||
|
assert!(
|
||||||
|
prepare(
|
||||||
|
&project,
|
||||||
|
view,
|
||||||
|
&[Target::Placement(node), Target::Placement(duplicate)]
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("duplicate_placement")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2279,6 +2279,28 @@ transition row, uses confirmed relation deletion for either key. States and
|
|||||||
referenced Activity/Function definitions remain. Hidden or collapsed rows are
|
referenced Activity/Function definitions remain. Hidden or collapsed rows are
|
||||||
not keyboard deletion targets.
|
not keyboard deletion targets.
|
||||||
|
|
||||||
|
For a group in a saved architecture or state view, plain Delete retains its
|
||||||
|
placement-only Hide meaning; a mixture containing ports, edges or state rows
|
||||||
|
MUST refuse Hide rather than silently delete model objects. Explicit group
|
||||||
|
model deletion, including focused Shift+Delete, resolves each exact visible
|
||||||
|
placement to its semantic element, each port to that port, each stored edge to
|
||||||
|
its relation or trace, and each state row to its maintained relation. Navigation
|
||||||
|
groupings and inferred edges are not semantic deletion subjects. Every original
|
||||||
|
typed member MUST be admitted before redundant semantic subjects or cascade
|
||||||
|
commands are reduced. A selected ancestor does not make a hidden or invalid
|
||||||
|
member admissible. Existing project validity constraints, including placement
|
||||||
|
uniqueness within a diagram, remain in force.
|
||||||
|
|
||||||
|
Saved-view aggregate review distinguishes selected visual targets, unique
|
||||||
|
semantic subjects, actual deleted records and changed surviving records. Model
|
||||||
|
deletion affects all placements of deleted elements in all views. A trace does
|
||||||
|
not cause deletion of its counterpart endpoints; an explicitly selected
|
||||||
|
counterpart remains an independent deletion subject. Removing a state action
|
||||||
|
row deletes its maintained relation, not the referenced Activity or Function.
|
||||||
|
The combined candidate and source update MUST form one atomic transaction;
|
||||||
|
neither partial primary-only deletion nor intermediate singleton history is
|
||||||
|
permitted.
|
||||||
|
|
||||||
Process and scenario projections have no independent occurrence placements.
|
Process and scenario projections have no independent occurrence placements.
|
||||||
Delete and Shift+Delete request confirmed removal of the selected occurrence,
|
Delete and Shift+Delete request confirmed removal of the selected occurrence,
|
||||||
participant or maintained link. A link's endpoints and outbound reusable
|
participant or maintained link. A link's endpoints and outbound reusable
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Saved Architecture and State aggregate model removal
|
||||||
|
|
||||||
|
Development policy under [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29),
|
||||||
|
derived from the [multiple-selection contract](multiselect-contract-20260923.md)
|
||||||
|
and `docs/language.rst` section 8.1. Implementation and verification must be
|
||||||
|
identified separately; this document does not establish App/native acceptance.
|
||||||
|
|
||||||
|
## User operation and identity
|
||||||
|
|
||||||
|
Keep **Hide selected placements** and plain Delete placement-only. An explicit
|
||||||
|
**Delete selected from model…** action, also available through focused
|
||||||
|
Shift+Delete, requests one semantic operation for the complete current group.
|
||||||
|
Adding a port, relation or State row must never turn plain Delete into model
|
||||||
|
deletion. Unsupported Hide mixtures explain the refusal without changing any
|
||||||
|
member. Existing singleton behavior remains governed by section 8.1.
|
||||||
|
|
||||||
|
Every original typed target must be unique, structurally current and actually
|
||||||
|
visible in the captured saved projection before deduplication or cascade
|
||||||
|
normalization. A hidden, stale or unsupported member refuses the whole request,
|
||||||
|
even if a selected ancestor would otherwise delete it. Pointer focus, current
|
||||||
|
tool, revision, exact selection generation/primary and pending drafts remain
|
||||||
|
App admission requirements, beyond this pure model planner.
|
||||||
|
|
||||||
|
| Selected visual target | Semantic deletion subject | Required identity evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Placement | Its referenced model element | Exact placement belongs to captured diagram and current projected node |
|
||||||
|
| Port | The semantic port itself | Exact visible port and its owner placement, not a promoted owner node |
|
||||||
|
| Stored edge | Its relation or trace record | Exact visible edge with corresponding stored identity; inferred edges are not deletable objects |
|
||||||
|
| State action/internal-transition row | Its StateAction or StateTransition relation | Exact visible row and state placement, not its referenced activity/function |
|
||||||
|
|
||||||
|
Whole-diagram targets, navigation-only Allocation groupings, Process/Scenario
|
||||||
|
targets and unsupported synthetic identities refuse this saved-view operation.
|
||||||
|
Saved Scenario presentations retain their dedicated local-occurrence adapter.
|
||||||
|
Admit each visual identity first, then delete each semantic subject once.
|
||||||
|
Current model validation forbids two placements of the same element in one
|
||||||
|
diagram (`duplicate_placement`); semantic deduplication must not admit such an
|
||||||
|
invalid project. The same element can appear in different diagrams, all of
|
||||||
|
which participate in deletion impact. Selecting both a state and one of its
|
||||||
|
rows is valid if both are visible.
|
||||||
|
|
||||||
|
## Candidate and impact
|
||||||
|
|
||||||
|
Validate the accepted input project, resolve the complete subject set, then
|
||||||
|
normalize only redundant Delete commands covered by existing core cascades.
|
||||||
|
Ancestry alone is insufficient: selected owners, ports, incident relations,
|
||||||
|
traces and action rows can overlap through dependencies. Deterministic
|
||||||
|
normalization must not depend on click order or primary selection. Do not run
|
||||||
|
separate validated singleton transactions; an intermediate candidate may be
|
||||||
|
invalid even though the full group deletion is valid.
|
||||||
|
|
||||||
|
Execute one final Batch candidate using the existing core deletion semantics.
|
||||||
|
The final candidate is authoritative for actual removed identities and changed
|
||||||
|
surviving records. Assert every selected semantic subject was removed and no
|
||||||
|
identity was created. Include elements, relations, traces, artifact links,
|
||||||
|
diagrams and embedded placements in impact accounting. Do not estimate union
|
||||||
|
impact merely by summing individual deletion counts; conditional allocation
|
||||||
|
dependents and global view/reference pruning require the combined result.
|
||||||
|
|
||||||
|
The foundation chooses original core deletion-impact coverage to suppress
|
||||||
|
redundant selected commands, with deterministic subject ordering. Coverage is
|
||||||
|
only command normalization, never the final impact report. Compared with
|
||||||
|
mutating a scratch project after every root, this keeps admission and coverage
|
||||||
|
bound to the same accepted input and avoids treating immediate scratch cleanup
|
||||||
|
as identical to the engine's deferred allocation cleanup. The final Batch and
|
||||||
|
complete selected-subject removal check remain mandatory. Tests cover two
|
||||||
|
endpoint allocations whose deferred cascades overlap, together with a direct
|
||||||
|
exchange deletion. An attempted alternate-support counterexample was invalid:
|
||||||
|
the current unique allocation, single performer and distinct boundary-owner
|
||||||
|
rules exclude simultaneous direct/reverse mappings. Preserve those rules and
|
||||||
|
test refusal of the invalid fixture; do not claim a union-only cascade case.
|
||||||
|
|
||||||
|
Show the number of visual targets separately from unique semantic subjects,
|
||||||
|
then actual removed/updated records with searchable names, aliases and kinds.
|
||||||
|
All placements of deleted elements across views are affected. Referenced work
|
||||||
|
survives removal of a State action relation; trace endpoint counterparts
|
||||||
|
survive deletion of a trace. A counterpart may itself be deleted when it was
|
||||||
|
explicitly selected, but a trace must not cause implicit counterpart deletion.
|
||||||
|
Do not invent replacement links, activities or state transitions.
|
||||||
|
|
||||||
|
Deleting an exchange can remove Process occurrences and alter implicit order
|
||||||
|
or a reused definition's interpretation. Changed owner records must be shown;
|
||||||
|
unchanged stored caller records do not prove unchanged caller behavior. Before
|
||||||
|
App acceptance, compare derived effects on reused callers using the Process
|
||||||
|
review approach, or explicitly resolve that review requirement.
|
||||||
|
|
||||||
|
The App must confirm the captured candidate through one Document transaction,
|
||||||
|
which includes source synchronization. A failed source update, cancelled review
|
||||||
|
or stale token must not partially change model, source, selection, camera or
|
||||||
|
history. Success removes transient deleted selection, chooses a surviving
|
||||||
|
inspection context and supports one exact model/source Undo and Redo. The
|
||||||
|
review footer remains reachable while impact content scrolls or is filtered.
|
||||||
|
|
||||||
|
## Verification obligations
|
||||||
|
|
||||||
|
- OA and SA; exact placement-to-subject mapping and other-view occurrences;
|
||||||
|
invalid duplicate placements; nested semantic ownership;
|
||||||
|
selected ancestors with selected descendants and incident connections.
|
||||||
|
- Exact ports with owner/edge mixtures, allocation dependency interactions,
|
||||||
|
State rows and transitions preserving referenced work, and trace deletion
|
||||||
|
preserving endpoints/counterparts.
|
||||||
|
- Wrong placement/port/row association, foreign/hidden/synthetic members,
|
||||||
|
duplicates and cross-family inputs refuse before normalization. Selected
|
||||||
|
ancestor coverage must not make an invalid descendant admissible.
|
||||||
|
- Independently construct the complete expected Project; inspect final Batch
|
||||||
|
effects and exact source/history rather than comparing two calls to the same
|
||||||
|
planner. Preserve unrelated views, authored metadata and surviving records.
|
||||||
|
- App stale/ABA/private-gesture/draft/tool/modal admission and failure recovery;
|
||||||
|
actual Windows build plus native group review, Cancel, confirmation, Save,
|
||||||
|
Undo/Redo/reopen. Bounded planner checks do not satisfy these later gates.
|
||||||
|
|
||||||
|
Rejected UI approach: overloaded plain Delete changes destructiveness when a
|
||||||
|
row or edge joins a placement group. Explicit model deletion preserves a stable
|
||||||
|
meaning and still supports a clear mixed semantic selection. Rejected planner
|
||||||
|
approach: only removing selected ancestor duplicates misses non-ownership
|
||||||
|
cascade dependencies; repeated validated singleton deletes can reject a valid
|
||||||
|
union or create partial history.
|
||||||
Reference in New Issue
Block a user