Preserve responsibility assignments when creating child work

This commit is contained in:
2026-09-11 11:04:12 -07:00
parent f796a27277
commit 865483b4c6
2 changed files with 437 additions and 12 deletions
+52 -12
View File
@@ -3290,6 +3290,52 @@ impl App {
id,
));
}
// Refining a leaf moves its explicit responsibilities to the new leaf.
// Later children inherit only the existing unambiguous performer summary.
if self.create_kind.is_work()
&& let Some(owner) = parent
&& self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.layer() == self.layer && e.kind() == self.create_kind)
{
let performer = oa_core::validation::allocation_summary(self.project(), owner);
let leaf = !self
.project()
.children(owner)
.any(|e| e.kind() == self.create_kind);
let responsibilities: Vec<_> = self
.project()
.relations
.values()
.filter(|r| {
r.target() == owner
&& matches!(
r.kind(),
RelationKind::Allocation | RelationKind::RoleAssignment
)
})
.collect();
if leaf && !responsibilities.is_empty() {
for original in responsibilities {
let mut responsibility = original.clone();
match &mut responsibility.body {
RelationBody::Allocation { target, .. }
| RelationBody::RoleAssignment { target, .. } => *target = id,
_ => unreachable!(),
}
commands.push(Command::EditRelation(original.id, responsibility));
}
} else if let Some(performer) = performer {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
performer,
id,
));
}
}
if self.create_kind == Kind::Occurrence
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
&& let Some(steps) = owner.steps()
@@ -3307,19 +3353,13 @@ impl App {
.get(&diagram)
.is_none_or(|d| d.projection.includes(self.create_kind))
{
let mut p = self.project().clone();
for command in &commands {
if let Command::CreateDiagram(d) = command {
p.diagrams.insert(d.id, d.clone());
let mut p = match execute_batch(self.project(), &commands) {
Ok(candidate) => candidate,
Err(error) => {
self.status = error.to_string();
return;
}
}
p.elements.insert(
id,
match &commands[0] {
Command::CreateElement(e) => e.clone(),
_ => unreachable!(),
},
);
};
let owner = if allocated {
Some(self.selected)
} else {
+385
View File
@@ -1663,3 +1663,388 @@ fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
}]
);
}
#[test]
fn child_work_preserves_allocated_context_and_atomic_history() {
use oa_core::projection::project_view;
for (layer, performer_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
for folded in [false, true] {
let mut p = Project::empty();
let performer = p
.add_element(performer_kind, layer, "Performer", None)
.unwrap();
let work = p
.add_element(work_kind, layer, "Parent work", None)
.unwrap();
let allocation = p.relate(
RelationKind::Allocation,
"Engineering assignment",
performer,
work,
);
p.relations.get_mut(&allocation).unwrap().description =
"Retain assignment metadata".into();
let original = p.relations[&allocation].clone();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let performer_node = p.place(diagram, performer, Position { x: 60., y: 60. });
let work_node = p.place(diagram, work, Position { x: 84., y: 124. });
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
for node in [performer_node, work_node] {
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.collapsed = folded;
}
let mut app = App::with_project(p);
send(&mut app, Message::Layer(layer));
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: diagram,
label: String::new(),
}),
);
let mut first = None;
for step in 0..3 {
let parent = if step == 2 { first.unwrap() } else { work };
send(&mut app, Message::Select(parent));
let before = app.project().clone();
let before_source = app.document.bundle().clone();
send(&mut app, Message::CreateContextElement(work_kind, true));
let created = app.selected;
assert!(
!before.elements.contains_key(&created),
"{layer:?} step {step}: {}",
app.status
);
assert_eq!(app.project().elements.len(), before.elements.len() + 1);
assert_eq!(app.project().elements[&created].parent, Some(parent));
for (id, element) in &before.elements {
assert_eq!(&app.project().elements[id], element);
}
assert!(
!oa_core::validation::validate(app.project())
.iter()
.any(|i| i.severity == oa_core::validation::Severity::Error)
);
assert_eq!(
oa_core::validation::allocation_summary(app.project(), work),
Some(performer)
);
assert_eq!(
oa_core::validation::effective_performer(app.project(), created),
Some(performer)
);
assert!(
!app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
);
if step != 1 {
let mut expected = original.clone();
if let RelationBody::Allocation { target, .. } = &mut expected.body {
*target = created;
}
assert_eq!(app.project().relations[&allocation], expected);
} else {
assert_eq!(
app.project().relations[&allocation],
before.relations[&allocation]
);
}
assert_eq!(
app.project().relations.len(),
before.relations.len() + usize::from(step == 1)
);
let view = project_view(app.project(), diagram);
let parent_node = view.nodes.iter().find(|n| n.element == parent).unwrap();
let child_node = view.nodes.iter().find(|n| n.element == created).unwrap();
let root_node = view.nodes.iter().find(|n| n.element == work).unwrap();
assert_eq!(root_node.visual_parent, Some(performer_node));
assert_eq!(child_node.visual_parent, Some(parent_node.id));
assert!(!app.project().diagrams[&diagram].nodes[&performer_node].collapsed);
assert!(!app.project().diagrams[&diagram].nodes[&parent_node.id].collapsed);
assert!(child_node.position.x >= parent_node.position.x);
assert!(child_node.position.y >= parent_node.position.y + 64.);
assert!(
child_node.position.x + child_node.size.width
<= parent_node.position.x + parent_node.size.width
);
assert!(
child_node.position.y + child_node.size.height
<= parent_node.position.y + parent_node.size.height
);
for sibling in view
.nodes
.iter()
.filter(|n| n.visual_parent == Some(parent_node.id) && n.id != child_node.id)
{
assert!(sibling.position.y + sibling.size.height <= child_node.position.y);
}
let after = app.project().clone();
let after_source = app.document.bundle().clone();
send(&mut app, Message::Undo);
assert_eq!(
app.project(),
&before,
"creation, allocation and reveal/reflow undo together"
);
assert_eq!(app.document.bundle(), &before_source);
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after);
assert_eq!(app.document.bundle(), &after_source);
if step == 0 {
first = Some(created);
}
}
}
}
}
#[test]
fn child_work_does_not_invent_unallocated_or_mixed_performers() {
for (layer, performer_kind, other_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::SystemActor, Kind::Function),
] {
// Root leaf, partly unallocated composite, and mixed-performer composite.
for context in 0..3 {
let mut p = Project::empty();
let performer = p
.add_element(performer_kind, layer, "Performer", None)
.unwrap();
let other = p
.add_element(other_kind, layer, "Other performer", None)
.unwrap();
let work = p
.add_element(work_kind, layer, "Parent work", None)
.unwrap();
let mut placed = vec![performer, other, work];
if context != 0 {
let first = p
.add_element(work_kind, layer, "First leaf", Some(work))
.unwrap();
let second = p
.add_element(work_kind, layer, "Second leaf", Some(work))
.unwrap();
p.relate(
RelationKind::Allocation,
"First assignment",
performer,
first,
);
if context == 2 {
p.relate(RelationKind::Allocation, "Other assignment", other, second);
}
placed.extend([first, second]);
}
assert_eq!(oa_core::validation::allocation_summary(&p, work), None);
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
for id in placed {
p.place(diagram, id, Position { x: 60., y: 60. });
}
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let mut app = App::with_project(p);
send(&mut app, Message::Layer(layer));
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: diagram,
label: String::new(),
}),
);
send(&mut app, Message::Select(work));
let before = app.project().clone();
let before_source = app.document.bundle().clone();
send(&mut app, Message::CreateContextElement(work_kind, true));
let child = app.selected;
assert!(!before.elements.contains_key(&child), "{}", app.status);
assert_eq!(app.project().elements[&child].parent, Some(work));
assert_eq!(app.project().relations, before.relations);
assert_eq!(
oa_core::validation::effective_performer(app.project(), child),
None
);
assert_eq!(
oa_core::validation::allocation_summary(app.project(), work),
None
);
let after = app.project().clone();
let after_source = app.document.bundle().clone();
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before);
assert_eq!(app.document.bundle(), &before_source);
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after);
assert_eq!(app.document.bundle(), &after_source);
}
}
}
#[test]
fn child_work_transfers_leaf_roles_without_fabricating_responsibilities() {
// Direct allocation + matching roles, uniquely realized roles only,
// unresolved roles, and roles realized by different performers.
for context in 0..4 {
let mut p = Project::empty();
let performer = p
.add_element(Kind::Entity, Layer::Oa, "Performer", None)
.unwrap();
let other = p
.add_element(Kind::Entity, Layer::Oa, "Other performer", None)
.unwrap();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Responsible work", None)
.unwrap();
let mut responsibilities = Vec::new();
if context == 0 {
responsibilities.push(p.relate(
RelationKind::Allocation,
"Direct responsibility",
performer,
work,
));
}
for index in 0..2 {
let role = p
.add_element(Kind::Role, Layer::Oa, &format!("Role {index}"), None)
.unwrap();
responsibilities.push(p.relate(
RelationKind::RoleAssignment,
&format!("Assigned role {index}"),
role,
work,
));
if context != 2 {
let source = if context == 3 && index == 1 {
other
} else {
performer
};
p.relate(RelationKind::RoleRealization, "Realizes role", source, role);
}
}
for (index, id) in responsibilities.iter().enumerate() {
let relation = p.relations.get_mut(id).unwrap();
relation.description =
format!("Responsibility {index}: preserve engineering rationale");
let link = Id::new_v4();
p.artifact_links.insert(
link,
ArtifactLink {
id: link,
alias: format!("responsibility_evidence_{index}"),
name: format!("Responsibility evidence {index}"),
description: "Preserve the reference to this exact relation".into(),
owner: *id,
relation: ArtifactRelation::References,
uri: format!("https://example.invalid/responsibility/{index}"),
revision: Some("review-7".into()),
fragment: Some("assignment".into()),
},
);
}
let original_relations = p.relations.clone();
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
p.place(diagram, performer, Position { x: 60., y: 60. });
p.place(diagram, work, Position { x: 84., y: 124. });
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let expected_performer = (context < 2).then_some(performer);
assert_eq!(
oa_core::validation::allocation_summary(&p, work),
expected_performer
);
let mut app = App::with_project(p);
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: diagram,
label: String::new(),
}),
);
let mut first = None;
for step in 0..2 {
send(&mut app, Message::Select(work));
let before = app.project().clone();
let before_source = app.document.bundle().clone();
send(
&mut app,
Message::CreateContextElement(Kind::Activity, true),
);
let child = app.selected;
assert!(
!before.elements.contains_key(&child),
"context {context}, step {step}: {}",
app.status
);
assert_eq!(app.project().elements[&child].parent, Some(work));
if step == 0 {
first = Some(child);
}
for (id, original) in &original_relations {
let mut expected = original.clone();
if responsibilities.contains(id) {
match &mut expected.body {
RelationBody::Allocation { target, .. }
| RelationBody::RoleAssignment { target, .. } => *target = first.unwrap(),
_ => unreachable!(),
}
}
assert_eq!(app.project().relations[id], expected);
}
assert_eq!(app.project().artifact_links, before.artifact_links);
assert!(!app.project().artifact_links.is_empty());
let added = usize::from(step == 1 && expected_performer.is_some());
assert_eq!(
app.project().relations.len(),
before.relations.len() + added
);
assert_eq!(
oa_core::validation::effective_performer(app.project(), child),
expected_performer
);
assert_eq!(
app.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == child)
.count(),
if step == 0 { 2 } else { 0 }
);
if context != 0 && step == 0 {
assert!(
!app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation)
);
}
assert!(
!oa_core::validation::validate(app.project())
.iter()
.any(|i| i.severity == oa_core::validation::Severity::Error)
);
let after = app.project().clone();
let after_source = app.document.bundle().clone();
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before);
assert_eq!(app.document.bundle(), &before_source);
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after);
assert_eq!(app.document.bundle(), &after_source);
}
}
}