Plan whole saved-view model deletion from exact visible groups

This commit is contained in:
2026-09-24 21:24:43 -07:00
parent afcea12cd9
commit 7750ecd751
6 changed files with 1490 additions and 0 deletions
+2
View File
@@ -43,6 +43,8 @@ pub use port_labels::PortLabels;
#[path = "diagram/focus.rs"]
pub(crate) mod focus;
pub(crate) mod group_gesture;
#[cfg(test)]
pub(crate) mod group_removal;
mod multiselect;
#[cfg(test)]
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(&region_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")
);
}
+22
View File
@@ -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
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.
Delete and Shift+Delete request confirmed removal of the selected occurrence,
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.