diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 8859132a..87ea4a63 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -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 { diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index fe0476fd..23f7e16e 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -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); + } + } +}