diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index a7f9ba55..596d4b43 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -4504,6 +4504,14 @@ impl App { if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) { return; } + if tool == crate::connection_tool::Tool::Allocation + && !tool.available_in( + self.layer, + self.project().diagrams[&context.diagram].projection, + ) + { + return; + } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); let context = self.route_context().unwrap(); self.connection = Some(Session::new( @@ -4534,14 +4542,32 @@ impl App { if let Some(source) = session.source { match session.tool.command(self.project(), source, id) { Ok(command) => { + let previous_layer = self.layer; + if session.tool == crate::connection_tool::Tool::Allocation { + // Accepted edits belong to the captured view. A + // retained mixed view can differ from the current + // perspective; sync must not choose another view. + self.layer = self.project().diagrams[&context.diagram].layer; + } if self.execute(command) { self.selected_node = None; self.inspector_tab = Default::default(); - self.status = format!( - "{} created · edit its name and carried items in Properties · Undo restores the complete operation", - session.tool.label() - ); + self.status = if session.tool + == crate::connection_tool::Tool::Allocation + { + format!( + "Allocated {} to {} · view filters are unchanged · Undo restores the complete operation", + self.project().named(id), + self.project().named(source) + ) + } else { + format!( + "{} created · edit its name and carried items in Properties · Undo restores the complete operation", + session.tool.label() + ) + }; } else { + self.layer = previous_layer; self.connection = Some(session); } } @@ -4556,7 +4582,18 @@ impl App { &project_view(self.project(), context.diagram), id, ); - self.select(id); + if session.tool == crate::connection_tool::Tool::Allocation + && self.project().diagrams[&context.diagram].projection + == ProjectionKind::Traceability + { + // Mixed views can allocate either perspective. Choosing + // a visible source must not navigate to its preferred view. + self.selected = id; + self.selected_attachment = None; + self.refresh_draft(); + } else { + self.select(id); + } self.status = if session.candidates.is_empty() { "No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into() } else { diff --git a/crates/oa-desktop/src/app/architecture_authoring/palette.rs b/crates/oa-desktop/src/app/architecture_authoring/palette.rs index 16880120..a2bbb3df 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/palette.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/palette.rs @@ -229,13 +229,7 @@ pub fn view(app: &App) -> Element<'_, Message> { content = content.push(group("Connections")); for connection in crate::connection_tool::Tool::for_layer(app.layer) { let d = &app.project().diagrams[&app.active_diagram.unwrap()]; - let kind = match connection { - crate::connection_tool::Tool::Interaction => Kind::Activity, - crate::connection_tool::Tool::FunctionalExchange => Kind::Function, - crate::connection_tool::Tool::CommunicationMean => Kind::Entity, - crate::connection_tool::Tool::BoundaryExchange => Kind::System, - }; - if !d.projection.includes(kind) { + if !connection.available_in(app.layer, d.projection) { continue; } content = content.push(tool( diff --git a/crates/oa-desktop/src/connection_allocation_tests.rs b/crates/oa-desktop/src/connection_allocation_tests.rs new file mode 100644 index 00000000..de132fa8 --- /dev/null +++ b/crates/oa-desktop/src/connection_allocation_tests.rs @@ -0,0 +1,465 @@ +// Included in connection_edit::tests: exercise the real application event path. +use oa_core::projection::project_view; + +fn allocation_fixture(layer: Layer, projection: ProjectionKind) -> (App, Id, Id, Id) { + let mut project = Project::empty(); + let (performer_kind, work_kind) = match layer { + Layer::Oa => (Kind::Entity, Kind::Activity), + Layer::Sa => (Kind::System, Kind::Function), + }; + let performer = project.add(performer_kind, "Assigned performer", 20., 20.); + let work = project.add(work_kind, "Existing leaf work", 420., 20.); + let diagram = project.ensure_diagram(layer); + project.diagrams.get_mut(&diagram).unwrap().projection = projection; + let reference_id = Id::new_v4(); + let mut reference = project.diagrams[&diagram].clone(); + reference.id = reference_id; + reference.alias = project.fresh_alias("Reference view", "diagram", reference_id); + reference.name = "Reference view".into(); + reference.nodes.clear(); + project.diagrams.insert(reference_id, reference); + project.place(reference_id, performer, Position { x: 80., y: 100. }); + project.place(reference_id, work, Position { x: 500., y: 300. }); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(diagram)); + (app, performer, work, diagram) +} + +fn start_allocation(app: &mut App, performer: Id) -> Context { + send(app, Event::Choose(Some(Tool::Allocation))); + let context = app.route_context().unwrap(); + assert!( + app.connection + .as_ref() + .unwrap() + .candidates + .contains(&performer) + ); + send(app, Event::Endpoint(context, performer)); + assert_eq!(app.connection.as_ref().unwrap().source, Some(performer)); + context +} + +#[test] +fn allocation_projection_eligibility_requires_both_endpoint_categories() { + for layer in [Layer::Oa, Layer::Sa] { + for projection in [ + ProjectionKind::Overview, + ProjectionKind::Decomposition, + ProjectionKind::Traceability, + ProjectionKind::Allocation, + ] { + assert!(Tool::Allocation.available_in(layer, projection)); + } + for projection in [ + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ProjectionKind::StateMachine { + machine: Id::new_v4(), + }, + ] { + assert!(!Tool::Allocation.available_in(layer, projection)); + } + for projection in [ + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ] { + let (mut app, _, _, _) = allocation_fixture(layer, projection); + let before = app.document.bundle().clone(); + send(&mut app, Event::Choose(Some(Tool::Allocation))); + assert!(app.connection.is_none()); + assert_eq!(app.document.bundle(), &before); + } + } + // The new predicate retains the four established exchange-tool policies. + assert!(Tool::Interaction.available_in(Layer::Oa, ProjectionKind::FunctionalDataflow)); + assert!(Tool::FunctionalExchange.available_in(Layer::Sa, ProjectionKind::FunctionalDataflow)); + assert!(Tool::CommunicationMean.available_in(Layer::Oa, ProjectionKind::Capabilities)); + assert!(Tool::BoundaryExchange.available_in(Layer::Sa, ProjectionKind::Capabilities)); + assert!(!Tool::FunctionalExchange.available_in(Layer::Oa, ProjectionKind::Overview)); +} + +#[test] +fn allocation_canvas_commits_only_one_relation_with_exact_source_undo_redo() { + use oa_core::source_project::{ProjectLimits, compile_bundle}; + for layer in [Layer::Oa, Layer::Sa] { + for projection in [ + ProjectionKind::Overview, + ProjectionKind::Decomposition, + ProjectionKind::Traceability, + ProjectionKind::Allocation, + ] { + let (mut app, performer, work, diagram) = allocation_fixture(layer, projection); + let original = app.project().clone(); + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + let context = start_allocation(&mut app, performer); + assert_eq!(app.document.bundle(), &before); + send(&mut app, Event::Endpoint(context, work)); + assert!(app.connection.is_none()); + assert_eq!(app.document.revision(), revision + 1); + let relation = &app.project().relations[&app.selected]; + let name = "Allocation: Assigned performer → Existing leaf work"; + let expected_relation = Relation { + id: relation.id, + alias: original.fresh_alias(name, "relation", relation.id), + name: name.into(), + description: String::new(), + body: RelationBody::new(RelationKind::Allocation, performer, work), + }; + let relation_id = relation.id; + let mut expected = original.clone(); + expected.relations.insert(relation_id, expected_relation); + assert_eq!(app.project(), &expected, "Only the new relation may change"); + assert_eq!(app.active_diagram, Some(diagram)); + assert!( + app.status + .contains("Allocated Existing leaf work to Assigned performer") + ); + let projected = project_view(app.project(), diagram); + if projection == ProjectionKind::Allocation { + assert!(!projected.edges.iter().any(|edge| edge.id == relation_id)); + let owner = projected + .nodes + .iter() + .find(|node| node.element == performer) + .unwrap(); + let child = projected + .nodes + .iter() + .find(|node| node.element == work) + .unwrap(); + assert_eq!(child.visual_parent, Some(owner.id)); + } else { + assert!(projected.edges.iter().any(|edge| edge.id == relation_id)); + } + let after = app.document.bundle().clone(); + assert_eq!( + compile_bundle(&after, ProjectLimits::default()) + .unwrap() + .decoded + .project, + expected + ); + send(&mut app, Event::Endpoint(context, work)); + assert_eq!( + app.document.revision(), + revision + 1, + "Repeated release is not another transaction" + ); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &after); + } + } +} + +#[test] +fn allocation_candidates_reject_nonleaf_occupied_cross_layer_port_and_hidden_work() { + for layer in [Layer::Oa, Layer::Sa] { + let (base, performer, work, diagram) = + allocation_fixture(layer, ProjectionKind::Traceability); + let mut project = base.project().clone(); + let work_kind = project.elements[&work].kind(); + let composite = project + .add_element(work_kind, layer, "Composite work", None) + .unwrap(); + project + .add_element(work_kind, layer, "Child work", Some(composite)) + .unwrap(); + let occupied = project + .add_element(work_kind, layer, "Occupied work", None) + .unwrap(); + let allocation = Tool::Allocation + .command(&project, performer, occupied) + .unwrap(); + project = prepare_command(&project, &allocation).unwrap().project; + let hidden = project + .add_element(work_kind, layer, "Unplaced work", None) + .unwrap(); + let other_layer = if layer == Layer::Oa { + Layer::Sa + } else { + Layer::Oa + }; + let other_kind = if other_layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let foreign = project + .add_element(other_kind, other_layer, "Other perspective", None) + .unwrap(); + let foreign_performer_kind = if other_layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }; + let foreign_performer = project + .add_element(foreign_performer_kind, other_layer, "Other performer", None) + .unwrap(); + for (index, id) in [composite, occupied, foreign, foreign_performer] + .into_iter() + .enumerate() + { + project.place( + diagram, + id, + Position { + x: index as f32 * 300., + y: 400., + }, + ); + } + let port_owner = if layer == Layer::Sa { work } else { foreign }; + let port_id = Id::new_v4(); + let port = make_port( + &project, + port_id, + port_owner, + "Work input", + PortContract::FunctionInput { + incoming_items: vec![], + }, + ) + .unwrap(); + project.elements.insert(port_id, port); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(diagram)); + let before = app.document.bundle().clone(); + let original = app.project().clone(); + let revision = app.document.revision(); + send(&mut app, Event::Choose(Some(Tool::Allocation))); + assert!( + app.connection + .as_ref() + .unwrap() + .candidates + .contains(&foreign_performer) + ); + let context = app.route_context().unwrap(); + send(&mut app, Event::Endpoint(context, performer)); + assert!(app.connection.as_ref().unwrap().candidates.contains(&work)); + assert!(Tool::Allocation.can_connect(app.project(), performer, hidden)); + for target in [composite, occupied, foreign, port_id, hidden, performer] { + assert!( + !app.connection + .as_ref() + .unwrap() + .candidates + .contains(&target) + ); + send(&mut app, Event::Endpoint(context, target)); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + } + for target in [composite, occupied, foreign, port_id, performer] { + assert!( + Tool::Allocation + .command(app.project(), performer, target) + .is_err() + ); + } + send(&mut app, Event::Endpoint(context, work)); + assert_eq!( + app.document.revision(), + revision + 1, + "Invalid clicks do not prevent recovery" + ); + } +} + +#[test] +fn allocation_creation_preserves_saved_edge_filters() { + let (base, performer, work, diagram) = + allocation_fixture(Layer::Oa, ProjectionKind::Decomposition); + let mut project = base.project().clone(); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .view_options + .relation_kinds = oa_core::view_options::RelationKinds::Only(vec![]); + let diagrams = project.diagrams.clone(); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(diagram)); + let context = start_allocation(&mut app, performer); + send(&mut app, Event::Endpoint(context, work)); + assert_eq!( + app.project().relations[&app.selected].kind(), + RelationKind::Allocation + ); + assert_eq!(app.project().diagrams, diagrams); + assert!(project_view(app.project(), diagram).edges.is_empty()); + assert!(app.status.contains("view filters are unchanged")); +} + +#[test] +fn allocation_mixed_view_keeps_captured_canvas_for_opposite_layer_pairs() { + for owner_layer in [Layer::Oa, Layer::Sa] { + for app_layer in [Layer::Oa, Layer::Sa] { + let (base, owner_performer, owner_work, diagram) = + allocation_fixture(owner_layer, ProjectionKind::Traceability); + let mut project = base.project().clone(); + let layer = if owner_layer == Layer::Oa { + Layer::Sa + } else { + Layer::Oa + }; + let (performer_kind, work_kind) = if layer == Layer::Oa { + (Kind::Actor, Kind::Activity) + } else { + (Kind::SystemActor, Kind::Function) + }; + let performer = project.add(performer_kind, "Opposite-layer performer", 40., 60.); + let work = project.add(work_kind, "Opposite-layer work", 420., 60.); + project.place(diagram, performer, Position { x: 40., y: 350. }); + project.place(diagram, work, Position { x: 420., y: 350. }); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(diagram)); + let _ = app.update(Message::Pan(iced::Vector::new(73., 29.))); + app.layer = app_layer; + let camera = (app.pan, app.zoom); + let before = app.document.bundle().clone(); + let original = app.project().clone(); + let revision = app.document.revision(); + let cancelled = start_allocation(&mut app, performer); + let _ = app.update(Message::Escape); + send(&mut app, Event::Endpoint(cancelled, work)); + assert!(app.connection.is_none()); + assert_eq!(app.layer, app_layer); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + assert_eq!(app.document.bundle(), &before); + let context = start_allocation(&mut app, performer); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.layer, app_layer); + assert_eq!(app.route_context(), Some(context)); + assert_eq!((app.pan, app.zoom), camera); + assert_eq!(app.selected, performer); + assert!(app.connection.as_ref().unwrap().candidates.contains(&work)); + for wrong_target in [owner_performer, owner_work] { + assert!( + !app.connection + .as_ref() + .unwrap() + .candidates + .contains(&wrong_target) + ); + send(&mut app, Event::Endpoint(context, wrong_target)); + assert_eq!(app.project(), &original); + assert_eq!(app.layer, app_layer); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + assert_eq!(app.document.bundle(), &before); + } + send(&mut app, Event::Endpoint(context, work)); + assert!(app.connection.is_none()); + assert_eq!(app.document.revision(), revision + 1); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.layer, owner_layer); + assert_eq!((app.pan, app.zoom), camera); + let relation = &app.project().relations[&app.selected]; + assert_eq!( + relation.body, + RelationBody::new(RelationKind::Allocation, performer, work) + ); + let mut expected = original.clone(); + expected.relations.insert(relation.id, relation.clone()); + assert_eq!(app.project(), &expected); + let after = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &after); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + } + } +} + +#[test] +fn allocation_cancel_navigation_stale_context_and_pending_edits_preserve_model() { + let (mut app, performer, work, diagram) = + allocation_fixture(Layer::Oa, ProjectionKind::Decomposition); + let original = app.project().clone(); + let before = app.document.bundle().clone(); + let old = start_allocation(&mut app, performer); + let _ = app.update(Message::Escape); + assert!(app.connection.is_none()); + send(&mut app, Event::Endpoint(old, work)); + let current = start_allocation(&mut app, performer); + send(&mut app, Event::Endpoint(old, work)); + assert_eq!(app.connection.as_ref().unwrap().context, current); + let _ = app.update(Message::Page(Page::Catalog)); + assert!(app.connection.is_none()); + let _ = app.update(Message::Page(Page::Diagram)); + let _ = app.update(Message::Diagram(diagram)); + start_allocation(&mut app, performer); + send(&mut app, Event::Choose(None)); + assert!(app.connection.is_none()); + start_allocation(&mut app, performer); + send(&mut app, Event::Choose(Some(Tool::Interaction))); + assert_eq!(app.connection.as_ref().unwrap().tool, Tool::Interaction); + assert_eq!(app.project(), &original); + let current = start_allocation(&mut app, performer); + for stale in [ + Context { + document: Id::new_v4(), + ..current + }, + Context { + diagram: Id::new_v4(), + ..current + }, + Context { + revision: current.revision + 1, + ..current + }, + ] { + send(&mut app, Event::Endpoint(stale, work)); + assert_eq!(app.connection.as_ref().unwrap().context, current); + } + app.delete_pending = true; + send(&mut app, Event::Endpoint(current, work)); + assert_eq!(app.project(), &original); + let _ = app.update(Message::CancelDelete); + let _ = app.update(Message::Escape); + let _ = app.update(Message::Select(performer)); + let _ = app.update(Message::Field( + "/name".into(), + "Unapplied performer name".into(), + )); + send(&mut app, Event::Choose(Some(Tool::Allocation))); + assert!(app.connection.is_none()); + assert!(app.draft_dirty()); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + + let (mut source_app, performer, work, _) = + allocation_fixture(Layer::Sa, ProjectionKind::Decomposition); + let before = source_app.document.bundle().clone(); + let context = start_allocation(&mut source_app, performer); + use iced::widget::text_editor; + let _ = source_app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = source_app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(std::sync::Arc::new("invalid raw source retained".into())), + ))); + send(&mut source_app, Event::Endpoint(context, work)); + send(&mut source_app, Event::Choose(Some(Tool::Allocation))); + assert!(source_app.connection.is_none()); + assert!(source_app.source_dirty()); + assert_eq!( + source_app.source_editor.text(), + "invalid raw source retained" + ); + assert_eq!(source_app.document.bundle(), &before); +} diff --git a/crates/oa-desktop/src/connection_edit.rs b/crates/oa-desktop/src/connection_edit.rs index bd627fae..8166be23 100644 --- a/crates/oa-desktop/src/connection_edit.rs +++ b/crates/oa-desktop/src/connection_edit.rs @@ -52,6 +52,8 @@ mod tests { use crate::app::{App, Message, Page}; use oa_core::*; + include!("connection_allocation_tests.rs"); + fn fixture() -> (App, Id, Id, Id) { let mut p = Project::empty(); let a = p.add(Kind::Function, "Send command", 0., 0.); diff --git a/crates/oa-desktop/src/connection_tool.rs b/crates/oa-desktop/src/connection_tool.rs index 83392c75..10910961 100644 --- a/crates/oa-desktop/src/connection_tool.rs +++ b/crates/oa-desktop/src/connection_tool.rs @@ -9,13 +9,18 @@ pub enum Tool { CommunicationMean, FunctionalExchange, BoundaryExchange, + Allocation, } impl Tool { pub fn for_layer(layer: Layer) -> &'static [Self] { match layer { - Layer::Oa => &[Self::Interaction, Self::CommunicationMean], - Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange], + Layer::Oa => &[Self::Interaction, Self::CommunicationMean, Self::Allocation], + Layer::Sa => &[ + Self::FunctionalExchange, + Self::BoundaryExchange, + Self::Allocation, + ], } } @@ -25,11 +30,15 @@ impl Tool { Self::CommunicationMean => "Communication means", Self::FunctionalExchange => "Functional exchange", Self::BoundaryExchange => "Boundary exchange", + Self::Allocation => "Allocation", } } pub fn hint(self) -> &'static str { match self { + Self::Allocation => { + "Click a performer, then highlighted unallocated leaf work. In allocated-work views, allocation appears as nesting rather than a line." + } Self::Interaction => "Click a source activity, then a target activity.", Self::CommunicationMean => { "Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation." @@ -45,6 +54,10 @@ impl Tool { pub fn can_start(self, project: &Project, source: Id) -> bool { match self { + Self::Allocation => project + .elements + .get(&source) + .is_some_and(|e| e.kind().is_performer()), Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| { e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor) }), @@ -65,6 +78,9 @@ impl Tool { return false; } match self { + Self::Allocation => { + validation::can_connect(project, RelationKind::Allocation, source, target) + } Self::CommunicationMean => { validation::can_connect(project, RelationKind::CommunicationMean, source, target) } @@ -140,6 +156,7 @@ impl Tool { } let name = engineering_name(project, self, source, target); let body = match self { + Self::Allocation => RelationBody::new(RelationKind::Allocation, source, target), Self::CommunicationMean => { RelationBody::new(RelationKind::CommunicationMean, source, target) } @@ -162,6 +179,28 @@ impl Tool { prepare_command(project, &command)?; Ok(command) } + + /// Both endpoint categories must be supported before offering Allocation. + /// Preserve the established projection eligibility of the exchange tools. + pub fn available_in(self, layer: Layer, projection: ProjectionKind) -> bool { + if !Self::for_layer(layer).contains(&self) { + return false; + } + let kind = match self { + Self::Interaction => Kind::Activity, + Self::CommunicationMean => Kind::Entity, + Self::FunctionalExchange => Kind::Function, + Self::BoundaryExchange => Kind::System, + Self::Allocation => { + let (performer, work) = match layer { + Layer::Oa => (Kind::Entity, Kind::Activity), + Layer::Sa => (Kind::System, Kind::Function), + }; + return projection.includes(performer) && projection.includes(work); + } + }; + projection.includes(kind) + } } /// A clicked port determines its own function owner. It cannot be paired with @@ -291,7 +330,7 @@ mod tests { let before = p.clone(); assert_eq!( Tool::for_layer(Layer::Oa), - &[Tool::Interaction, Tool::CommunicationMean] + &[Tool::Interaction, Tool::CommunicationMean, Tool::Allocation] ); assert_eq!( Tool::Interaction.targets(&p, source), @@ -592,6 +631,8 @@ mod tests { (Tool::CommunicationMean, oa_team, oa_actor), (Tool::FunctionalExchange, sa_a, sa_b), (Tool::BoundaryExchange, boundary_a, boundary_b), + (Tool::Allocation, oa_team, oa_a), + (Tool::Allocation, system, sa_a), ] { let before = document.bundle().clone(); let original = document.project().clone(); diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index 56ff4881..0fc9a5c8 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -263,7 +263,8 @@ fn article(topic: Topic) -> Article { &[ "Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.", "In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.", - "Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. + Relationship with Allocation and Model explorer's + Allocated provide explicit alternatives.", + "To allocate existing leaf work in Containment, choose Allocation in the diagram palette. Click a visible performer, then the highlighted Activity or Function it performs. The work must belong to the performer's layer and have no direct performer yet. In a mixed OA ↔ SA traceability view, either layer's performer can be used and the current view stays open. Completing the allocation sets the application perspective to that view's layer; choosing only a source or canceling leaves the prior perspective unchanged. Escape cancels. This creates a responsibility relationship without changing the work's semantic parent.", + "Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. Create relationship… with Allocation and Model explorer's + Allocated provide explicit alternatives.", "Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.", ], ), @@ -308,7 +309,7 @@ fn article(topic: Topic) -> Article { &[ "Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.", "Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.", - "Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.", + "Select an element and edit Semantic parent to change ownership. Use the Allocation palette tool to connect a performer to existing unallocated leaf work in Containment. The relationship can appear as a line there; Architecture · allocated work shows responsibility through nesting instead. Saved visibility filters can hide a line without removing the relationship.", "Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.", "Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.", ], diff --git a/harness/allocation-drawing-20260923.md b/harness/allocation-drawing-20260923.md new file mode 100644 index 00000000..895f686d --- /dev/null +++ b/harness/allocation-drawing-20260923.md @@ -0,0 +1,83 @@ +# Direct Allocation drawing — issue 30 + +Branch-specific design above `17f1f89fef4a6bf520f4b09a2116548b967d030e`, +on `codex/allocation-drawing-ytr83r-20260923`. +[Issue 30](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/30) +holds live scope and acceptance. This note records the intended interaction and +verification criteria, not completed tests, native acceptance or integration. + +The user reports that Allocation cannot be drawn from the tools menu in +Containment. Core semantics and the existing relationship form already support +the relationship there; the direct canvas tool is the missing affordance. +Adding that tool follows the same source/target workflow as neighboring tools. +A form shortcut alone would still require a dropdown instead of clicking the +visible endpoints, leaving the reported drawing task unresolved. + +## Interaction and scope + +Choose Allocation in the diagram palette, click any visible eligible performer, +then click compatible unallocated leaf work in the same semantic layer as that +performer. The performer's layer need not match a mixed traceability view's owner +layer. OA uses an Entity or Actor and an +Activity; SA uses a System or Actor and a Function. Highlighting and the current +source identify the proposed semantic direction: performer to work, independent +of where their cards are drawn. Escape cancels the unfinished connection. +The existing Create relationship form remains an alternative for explicit +relationship entry; no new Activity or Function is created by this tool. + +Projection eligibility requires support for both performer and work cards: +Containment, All relationships, OA ↔ SA traceability and Architecture · allocated +work offer Allocation; Functional dataflow, Missions & capabilities and dedicated +State-machine views do not. Containment and All relationships can display the resulting Allocation line +when its placements and filters permit. Architecture · allocated work represents +the same responsibility through nesting, not an additional Allocation arrow. +OA ↔ SA traceability can contain both layers, but Allocation endpoints must +still belong to the same layer as each other. Selecting an opposite-layer source +retains the captured mixed diagram and prior application perspective; cancellation +also preserves that perspective. After successful creation, the ordinary model +synchronization sets the application perspective to the captured diagram's owner +layer while keeping that diagram open. A projection that omits one endpoint category +must not offer an unusable drawing workflow. Existing filters remain unchanged; +successful creation does not promise that a filtered line is visible. + +## Preservation rules + +- Allocate only eligible leaf work with no existing direct performer. Reject + duplicate, occupied, composite, cross-layer and Port endpoints; do not treat + drawing a new relation as permission to reassign existing work. +- Core validation is authoritative at completion, in addition to candidate + highlighting. Candidates must be actual projected endpoints, not hidden + children or computed parent summaries. +- Create one Allocation in one document transaction. Preserve semantic parents, + elements, other relationships, traces and every saved placement, route and + view option. Derived projection geometry may change because responsibility + changed; that is distinct from rewriting stored layout. +- Arming, choosing only a source, refusal and cancellation do not alter model + history. Stale document/revision/diagram, navigation and pending property or + source edits must prevent delayed completion without discarding those drafts. +- Moving cards in Containment remains ordinary arrangement. Assignment-changing + drag and Arrange next drag retain their existing allocated-work-view scope. + +No general relationship browser, new reassignment policy, port generation or +change to ownership semantics is introduced. The existing connection lifecycle +and ordinary validated command boundary are preferred over a parallel gesture +framework. Broader form-only relationship kinds are outside this bounded fix. + +## Acceptance criteria + +Exercise real connection events for valid OA and SA endpoint pairs, rejected +endpoint classes, cancel/retry, hidden endpoints, stale captures and pending +drafts. Compare the complete Project so the only semantic difference is the new +Allocation; bind its fresh identity explicitly. Check exact source Undo/Redo +and unchanged stored reference-view records. Verify projection eligibility for +the new tool without regressing the existing four connection tools. + +In the actual application, find Allocation in Containment's palette, choose a +performer and existing leaf work directly, inspect the resulting relation, +Undo/Redo, Save, close normally and reopen. Confirm feedback explains nesting +when using the allocated-work projection, rather than promising a new line. +Inspect nested-card hit selection and filtered-result feedback. Native evidence +must distinguish observed interactions from automated coverage and bind source, +binary, process and predeclared full-Project expectations. No successful result +is asserted here. Linux remains user-paused; combined release acceptance belongs +to the integration owner.