Add direct Allocation drawing with captured-view context
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user