Preserve responsibility assignments when creating child work
This commit is contained in:
@@ -3290,6 +3290,52 @@ impl App {
|
|||||||
id,
|
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
|
if self.create_kind == Kind::Occurrence
|
||||||
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
|
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
|
||||||
&& let Some(steps) = owner.steps()
|
&& let Some(steps) = owner.steps()
|
||||||
@@ -3307,19 +3353,13 @@ impl App {
|
|||||||
.get(&diagram)
|
.get(&diagram)
|
||||||
.is_none_or(|d| d.projection.includes(self.create_kind))
|
.is_none_or(|d| d.projection.includes(self.create_kind))
|
||||||
{
|
{
|
||||||
let mut p = self.project().clone();
|
let mut p = match execute_batch(self.project(), &commands) {
|
||||||
for command in &commands {
|
Ok(candidate) => candidate,
|
||||||
if let Command::CreateDiagram(d) = command {
|
Err(error) => {
|
||||||
p.diagrams.insert(d.id, d.clone());
|
self.status = error.to_string();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
p.elements.insert(
|
|
||||||
id,
|
|
||||||
match &commands[0] {
|
|
||||||
Command::CreateElement(e) => e.clone(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let owner = if allocated {
|
let owner = if allocated {
|
||||||
Some(self.selected)
|
Some(self.selected)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user