Files
ARCADIA-Studio/crates/oa-desktop/src/model_menu.rs
T

1223 lines
47 KiB
Rust

//! Contextual actions reuse the ordinary guarded application commands.
use crate::{
app::{App, Message, Page},
context_menu::{Entry, Menu},
port_workflow, route_edit,
};
use iced::Point;
use oa_core::*;
#[derive(Debug, Clone)]
pub struct Request {
pub anchor: Point,
pub object: Id,
pub placement: Option<Id>,
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
pub bend: Option<usize>,
pub route_point: Option<Position>,
pub route_segment: Option<(usize, Position)>,
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub process: Option<Box<crate::behavior::topology::Context>>,
pub source_file: Option<String>,
}
impl Request {
pub fn object(object: Id, anchor: Point) -> Self {
Self {
anchor,
object,
placement: None,
selection_target: None,
bend: None,
route_point: None,
route_segment: None,
creation_point: None,
canvas: None,
process: None,
source_file: None,
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Open(Request),
Invoke(u64, Box<Message>),
Close,
}
pub struct Opened {
pub token: u64,
pub request: Request,
document: Id,
revision: u64,
epoch: u64,
page: Page,
layer: Layer,
diagram: Option<Id>,
}
impl Opened {
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
if !app.project().contains(request.object)
|| !target_matches(app, &request)
|| request
.canvas
.is_some_and(|c| Some(c) != current_canvas(app))
|| request
.process
.as_deref()
.is_some_and(|c| Some(*c) != app.topology_context())
{
return None;
}
Some(Self {
token,
request,
document: app.canvas_document,
revision: app.document.revision(),
epoch: app.canvas_epoch,
page: app.page,
layer: app.layer,
diagram: current_diagram(app),
})
}
pub fn valid(&self, app: &App) -> bool {
self.document == app.canvas_document
&& self.revision == app.document.revision()
&& self.epoch == app.canvas_epoch
&& self.page == app.page
&& self.layer == app.layer
&& self.diagram == current_diagram(app)
&& app.project().contains(self.request.object)
&& target_matches(app, &self.request)
&& self
.request
.canvas
.is_none_or(|c| Some(c) == current_canvas(app))
&& self
.request
.process
.as_deref()
.is_none_or(|c| Some(*c) == app.topology_context())
&& self
.request
.source_file
.as_ref()
.is_none_or(|path| app.source_workspace.files.contains_key(path))
}
}
fn target_matches(app: &App, request: &Request) -> bool {
use crate::diagram_usage::Target;
let Some(target) = request.selection_target.as_deref() else {
return true;
};
if (request.canvas.is_none() && request.process.is_none())
|| !app.multiselect_target_visible(target)
{
return false;
}
match target {
Target::ProcessOccurrence(endpoint) => {
request.process.is_some()
&& request.canvas.is_none()
&& request.placement.is_none()
&& endpoint.occurrence == request.object
}
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => {
request.process.is_some()
&& request.canvas.is_none()
&& request.placement.is_none()
&& *id == request.object
}
Target::Placement(id) => {
request.placement == Some(*id)
&& request
.canvas
.and_then(|context| app.project().diagrams.get(&context.diagram))
.and_then(|diagram| diagram.nodes.get(id))
.is_some_and(|node| node.element == request.object || node.id == request.object)
}
Target::Port { port, .. } => request.placement.is_none() && *port == request.object,
Target::Edge(id) => request.placement.is_none() && *id == request.object,
Target::StateRow { relation, .. } => {
request.placement.is_none() && *relation == request.object
}
_ => false,
}
}
fn current_canvas(app: &App) -> Option<route_edit::Context> {
if app.page == Page::Behavior {
app.state_context()
} else {
app.route_context()
}
}
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.topology_context()
.map(|context| context.owner)
.or_else(|| app.state_context().map(|context| context.diagram))
} else {
app.active_diagram
}
}
pub fn open(object: Id, anchor: Point) -> Message {
Message::Menu(Event::Open(Request::object(object, anchor)))
}
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
crate::context_menu::Area::left(
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
move |anchor| {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
_ => None,
};
} else if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
Message::Menu(Event::Open(request))
},
)
.into()
}
pub fn popup(app: &App) -> Option<Menu<Message>> {
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
let request = &opened.request;
let p = app.project();
let id = request.object;
let clean = !app.busy
&& (request.source_file.is_some()
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
let item = |caption: &str, message: Message, enabled: bool| {
if clean && enabled {
Entry::action(
caption,
Message::Menu(Event::Invoke(opened.token, Box::new(message))),
)
} else {
Entry::disabled(caption)
}
};
if let Some(path) = &request.source_file {
let buffer = app.source_workspace.files.get(path)?;
let entries = vec![
Entry::title(path),
item("Open file", Message::SourceFile(path.clone()), true),
item("Reveal active file", Message::SourceTreeLocate, true),
Entry::separator(),
item(
"Close detached buffer",
Message::SourceCloseFile(path.clone()),
!app.source_workspace.reachable.contains(path) && !buffer.dirty(),
),
Entry::title(if app.source_workspace.reachable.contains(path) {
"Included files remain part of the project"
} else if buffer.dirty() {
"Apply or revert this file before closing"
} else {
"Closing leaves the file on disk"
}),
];
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if (request.canvas.is_some() || request.process.is_some())
&& let Some(context) = app.multiselect_context()
{
use crate::app::multiselect::{Choice, Event as SelectionEvent};
use crate::diagram_usage::Target;
let view = request
.canvas
.map(|context| context.diagram)
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
let target = request
.selection_target
.as_deref()
.cloned()
.or_else(|| request.placement.map(Target::Placement))
.or_else(|| {
view.as_ref()?
.ports
.iter()
.find(|port| port.element == id)
.map(|port| Target::Port {
port: port.element,
placement: port.owner,
})
})
.or_else(|| {
view.as_ref()?
.edges
.iter()
.any(|edge| edge.id == id)
.then_some(Target::Edge(id))
});
if let Some(target) = target {
let selected = app
.diagram_selection
.as_ref()
.is_some_and(|s| s.contains(&target));
entries.push(item(
if selected {
"Remove from selection"
} else {
"Add to selection"
},
Message::DiagramSelection(SelectionEvent::Choose(
context,
target.clone(),
if selected {
Choice::Remove
} else {
Choice::Add
},
)),
true,
));
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
&& selected
{
entries.push(Entry::title(format!(
"{} selected",
snapshot.targets().len()
)));
entries.push(item(
"Inspect this element",
Message::DiagramSelection(SelectionEvent::Choose(
context,
target.clone(),
Choice::Inspect,
)),
true,
));
entries.push(item(
"Select only this element",
Message::DiagramSelection(SelectionEvent::Choose(
context,
target,
Choice::Replace,
)),
true,
));
if request.process.is_some() {
entries.push(Entry::title(
"Drag contributions within one column to reorder",
));
entries.push(Entry::disabled(
"Hide unavailable · cards are model contributions",
));
} else {
entries.push(item(
"Hide selected placements",
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
crate::app::multiselect::placements(&snapshot).is_some(),
));
}
entries.push(Entry::disabled(
"Delete selected from model (not yet available)",
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
entries.push(Entry::separator());
if let Some(context) = request.process.as_deref().copied() {
let endpoint = match request.selection_target.as_deref() {
Some(Target::ProcessOccurrence(endpoint))
if !endpoint.references.is_empty() =>
{
Some(endpoint.clone())
}
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
_ => None,
};
if let Some(endpoint) = endpoint {
entries.push(Entry::title("Reused contents · read-only in this process"));
entries.push(item(
"Open referenced definition",
Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::OpenQualified(
context, endpoint,
),
),
true,
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
}
}
if app.page == Page::Behavior
&& let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean)
{
if entries.len() > 1 {
entries.extend(menu.entries.into_iter().skip(1));
menu.entries = entries;
}
return Some(menu);
}
if !clean {
entries.push(Entry::title("Apply or revert pending edits first"));
}
if request.canvas.is_some() && app.active_diagram == Some(id) {
entries.extend([
item("View properties", Message::EditProperties(id), true),
item("Duplicate view", Message::DuplicateDiagram(id), true),
item("Fit diagram", Message::Fit, true),
item(
"Arrange elements",
Message::Command(Command::Layout(id)),
true,
),
item("Export SVG…", Message::Export, true),
Entry::separator(),
]);
for kind in crate::app::architecture_authoring::kinds(app) {
entries.push(item(
&format!("Create {} here…", kind.label()),
Message::Architecture(crate::app::architecture_authoring::Event::Start(
kind,
request.creation_point,
)),
true,
));
}
entries.push(item(
"Add existing here…",
Message::Architecture(crate::app::architecture_authoring::Event::Existing(
request.creation_point,
)),
true,
));
} else {
if request.route_point.is_some()
|| request.bend.is_some()
|| (request.canvas.is_some() && p.relations.contains_key(&id))
{
let diagram = app.active_diagram.and_then(|d| p.diagrams.get(&d));
let view = diagram.map(|d| projection::project_view(p, d.id));
let projected = view
.as_ref()
.and_then(|v| v.edges.iter().find(|e| e.id == id));
let editable = diagram.zip(projected).is_some_and(|(d, e)| {
projection::edge_endpoint_placements(p, d, id)
.is_none_or(|(s, t)| s == e.source && t == e.target)
});
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
for (end, choose, reset) in [
(
EdgeEnd::Source,
"Move source attachment",
"Reset source attachment",
),
(
EdgeEnd::Target,
"Move target attachment",
"Reset target attachment",
),
] {
let eligible = editable
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
entries.push(item(
choose,
Message::Route(route_edit::Event::ChooseAttachment(end)),
eligible,
));
entries.push(item(
reset,
Message::Route(route_edit::Event::ResetAttachment(end)),
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
));
}
}
if let Some(point) = request.route_point {
entries.push(
item(
"Add bend here",
Message::Route(route_edit::Event::AddBendAt(point)),
editable && route.is_none_or(|r| r.waypoints.len() < 128),
)
.shortcut("Insert"),
);
} else {
entries.push(
item(
"Add bend",
Message::Route(route_edit::Event::AddBend),
editable && route.is_none_or(|r| r.waypoints.len() < 128),
)
.shortcut("Insert"),
);
}
if request.bend.is_some() {
entries.push(
item(
"Remove bend",
Message::Route(route_edit::Event::RemoveBend),
editable,
)
.shortcut("Delete"),
);
}
entries.push(item(
"Reset routing",
Message::Route(route_edit::Event::Reset),
editable && route.is_some(),
));
if !editable {
entries.push(Entry::title("Expand endpoint containers to edit routing"));
}
entries.push(Entry::separator());
}
entries.push(item("Properties", Message::EditProperties(id), true));
entries.push(item("Open source", Message::SourceForSelection, true));
entries.push(item(
"Show in diagrams…",
app.diagram_usage_message(id),
true,
));
entries.push(item(
"Connections & references",
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
true,
));
if let Some(e) = p.elements.get(&id) {
if e.kind() == Kind::Port
&& app.page == Page::Diagram
&& let Some(context) = app.route_context()
&& let Some(port) = projection::project_view(p, context.diagram)
.ports
.iter()
.find(|port| port.element == id)
{
let manual = p.diagrams[&context.diagram].nodes[&port.owner]
.port_anchors
.iter()
.any(|anchor| anchor.port == id);
entries.push(item(
"Reset automatic position",
Message::PortAnchor(crate::port_edit::Event::Reset(context, port.owner, id)),
manual,
));
}
if app.page == Page::Diagram {
for tool in crate::connection_tool::Tool::for_layer(e.layer()) {
if tool.can_start(p, id) {
entries.push(item(
&format!("Draw {}", tool.label().to_lowercase()),
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
app.active_diagram.is_some_and(|diagram| {
crate::connection_edit::visible(&projection::project_view(
p, diagram,
))
.any(|element| element == id)
}),
));
}
}
}
if let Some(owner) = e.parent {
entries.push(item("Open owner", Message::Select(owner), true));
}
entries.push(Entry::separator());
for action in port_workflow::actions(p, id) {
if matches!(
action,
port_workflow::Action::Flow | port_workflow::Action::Standard
) {
continue;
}
let caption = match action {
port_workflow::Action::Component => "Add port…",
port_workflow::Action::Input => "Add input port…",
port_workflow::Action::Output => "Add output port…",
_ => action.label(),
};
entries.push(item(caption, Message::PortAction(action), true));
}
if let Some(PortContract::Component { kind, orientation }) = port_contract(e) {
entries.push(item(
"Configure boundary exchange…",
Message::AddRelationKind(
RelationKind::BoundaryExchange,
*kind == ComponentPortKind::Flow
&& *orientation == ComponentPortOrientation::In,
),
true,
));
}
let work = if e.layer() == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
if e.kind().is_performer() && request.canvas.is_some() {
if crate::app::architecture_authoring::kinds(app).contains(&work) {
entries.push(item(
if e.layer() == Layer::Oa {
"Add activity…"
} else {
"Add function…"
},
Message::Architecture(crate::app::architecture_authoring::Event::Allocate(
work,
id,
request.creation_point,
)),
true,
));
}
} else if e.kind().is_performer() || e.kind().is_work() {
entries.push(item(
if e.kind().is_performer() {
"Create allocated work"
} else {
"Create child work"
},
Message::CreateContextElement(work, true),
true,
));
} else if validation::can_parent(p, app.create_kind, e.layer(), Some(id))
&& app.create_kind != Kind::Port
{
entries.push(item(
&format!("Create child {}", app.create_kind.label()),
Message::CreateContextElement(app.create_kind, true),
true,
));
}
for (caption, kind, incoming) in relation_actions(e.kind()) {
entries.push(item(
caption,
Message::AddRelationKind(*kind, *incoming),
true,
));
}
if let Some(node) = request.placement.and_then(|node| {
app.active_diagram
.and_then(|d| p.diagrams.get(&d))?
.nodes
.get(&node)
}) {
entries.push(Entry::separator());
entries.push(item(
if node.collapsed {
"Unfold contents"
} else {
"Fold contents"
},
Message::ToggleCollapse,
true,
));
entries.push(item("Placement properties", Message::Select(node.id), true));
entries.push(
item("Hide from this diagram", Message::HideSelected, true).shortcut("Delete"),
);
} else if e.kind() != Kind::Port
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
{
entries.push(item(
"Show in this diagram",
Message::PlaceSelected,
(d.layer == e.layer() || d.projection == ProjectionKind::Traceability)
&& d.projection.includes(e.kind()),
));
}
}
if let Some(r) = p.relations.get(&id) {
for action in port_workflow::actions(p, id) {
entries.push(item(action.label(), Message::PortAction(action), true));
}
entries.push(item(
"Open source endpoint",
Message::Select(r.source()),
true,
));
entries.push(item(
"Open target endpoint",
Message::Select(r.target()),
true,
));
}
entries.push(Entry::separator());
if p.layer_of(id).is_some() {
entries.push(item("Create relationship…", Message::AddRelation, true));
entries.push(item(
if p.layer_of(id) == Some(Layer::Oa) {
"Create System counterpart…"
} else {
"Create Operational counterpart…"
},
Message::StartTransition,
true,
));
entries.push(item(
"Review counterpart changes…",
Message::StartSemanticReview(id),
p.traces.values().any(|t| t.oa == id || t.sa == id),
));
}
entries.push(item(
"Attach artifact reference…",
Message::AddArtifact,
true,
));
if id != p.id {
entries.push(
item("Delete from model…", Message::Delete, true).shortcut(
if request.canvas.is_some_and(|context| context.diagram != id)
&& (request.placement.is_some()
|| p.relations.contains_key(&id)
|| p.elements
.get(&id)
.is_some_and(|element| element.kind() == Kind::Port))
{
if request.placement.is_some() {
"Shift+Delete"
} else {
"Delete / Shift+Delete"
}
} else if app.selected == id
&& (app.topology_context().is_some_and(|context| {
app.behavior.topology.selecting()
&& app.behavior.topology.removal_target(p, context.owner, id)
}) || app.scenario_context().is_some_and(|context| {
app.behavior.scenario.canvas_tool(context.scenario)
== crate::behavior::scenario::CanvasTool::Select
&& crate::behavior::scenario::removal_target(
p,
context.scenario,
id,
)
}))
{
"Delete / Shift+Delete"
} else {
""
},
),
);
}
}
Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
})
}
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
match kind {
Kind::Entity | Kind::Actor => &[(
"Configure communication means…",
RelationKind::CommunicationMean,
false,
)],
Kind::OperationalMission | Kind::Mission => &[
(
"Exploit capability…",
RelationKind::MissionCapability,
false,
),
(
"Involve participant…",
RelationKind::MissionInvolvement,
true,
),
],
Kind::Capability | Kind::SystemCapability => &[
(
"Include capability…",
RelationKind::CapabilityInclude,
false,
),
("Extend capability…", RelationKind::CapabilityExtend, false),
(
"Specialize capability…",
RelationKind::CapabilityGeneralization,
false,
),
(
"Involve participant or work…",
RelationKind::CapabilityInvolvement,
true,
),
],
_ => &[],
}
}
#[cfg(test)]
mod tests {
use super::*;
include!("model_menu_traceability_tests.rs");
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn choose(app: &App, caption: &str) -> Message {
popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
Entry::Action { label, message, .. } if label == caption => message,
_ => None,
})
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
}
#[test]
fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
use crate::behavior::state_edit;
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let architecture = project.ensure_diagram(Layer::Oa);
project.place(architecture, state, Position { x: 80., y: 90. });
let first = state_edit::create_view(&project, machine).unwrap();
let first_id = first.diagram;
project = execute_batch(&project, &[first.command]).unwrap();
let second = state_edit::create_view(&project, machine).unwrap();
let second_id = second.diagram;
project = execute_batch(&project, &[second.command]).unwrap();
let placement = project.diagrams[&first_id]
.nodes
.values()
.find(|node| node.element == state)
.unwrap()
.id;
let mut app = App::with_project(project.clone());
send(&mut app, Message::Select(machine));
send(&mut app, Message::Page(Page::Behavior));
app.behavior.diagram.diagram = Some(first_id);
app.active_diagram = Some(architecture);
let open_state = |app: &mut App| {
let mut request = Request::object(state, Point::ORIGIN);
request.canvas = app.state_context();
request.placement = Some(placement);
send(app, Message::Menu(Event::Open(request)));
};
// A canvas menu from Architecture is stale on the state page even when
// the architecture diagram itself remains the active architecture view.
let mut wrong = Request::object(state, Point::ORIGIN);
wrong.canvas = app.route_context();
assert!(Opened::new(&app, wrong, 1).is_none());
open_state(&mut app);
let fold = choose(&app, "Fold contents");
send(&mut app, fold);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
assert_eq!(app.selected, state, "Folding retains semantic selection");
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
open_state(&mut app);
let unfold = choose(&app, "Unfold contents");
send(&mut app, unfold);
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
assert_eq!(app.project(), &project);
assert_eq!(app.active_diagram, Some(architecture));
send(&mut app, Message::Undo);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
open_state(&mut app);
let stale = choose(&app, "Fold contents");
app.behavior.diagram.diagram = Some(second_id);
send(&mut app, stale);
assert_eq!(app.project(), &project);
app.behavior.diagram.diagram = Some(first_id);
open_state(&mut app);
let hide = choose(&app, "Hide from this diagram");
send(&mut app, hide);
assert!(
!app.project().diagrams[&first_id]
.nodes
.contains_key(&placement)
);
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
// Placement properties deliberately select a placement record, but an
// accepted numeric edit must keep the Behavior view's own context.
let source_before = app.document.bundle().clone();
open_state(&mut app);
let properties = choose(&app, "Placement properties");
send(&mut app, properties);
assert_eq!(app.selected, placement);
assert_eq!(
app.draft.as_ref().unwrap().kind,
crate::forms::RecordKind::Node(first_id)
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(architecture));
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
send(
&mut app,
Message::Field("/position/x".into(), x.to_string()),
);
assert!(app.draft_dirty());
send(&mut app, Message::Apply);
assert!(!app.draft_dirty());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(machine));
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(
app.active_diagram,
Some(architecture),
"Placement Apply must not replace the Architecture view"
);
let mut expected = project.clone();
expected
.diagrams
.get_mut(&first_id)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.position
.x = x;
assert_eq!(app.project(), &expected);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source_before);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
}
#[test]
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let entry = project
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(entry));
send(&mut app, Message::Page(Page::Behavior));
send(&mut app, open(entry, Point::ORIGIN));
let start = choose(&app, "New transition with details…");
send(&mut app, start);
assert_eq!(app.behavior.pending_owner(), Some(machine));
send(
&mut app,
Message::Behavior(crate::behavior::Event::States(
crate::behavior::states::Event::Cancel,
)),
);
send(
&mut app,
Message::Field("/name".into(), "Unapplied entry".into()),
);
let before = app.draft.as_ref().unwrap().materialize().unwrap();
send(&mut app, open(entry, Point::ORIGIN));
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
entry,
Entry::Action {
message: Some(_),
..
}
)));
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
}
#[test]
fn right_click_targets_actor_without_changing_selection_until_action() {
let mut app = App::with_project(Project::demo());
let actor = app
.project()
.elements
.values()
.find(|e| e.kind() == Kind::SystemActor)
.unwrap()
.id;
let selected = app.selected;
let model = app.project().clone();
send(&mut app, open(actor, Point::new(240., 180.)));
assert_eq!(app.selected, selected);
assert_eq!(app.project(), &model);
let action = choose(&app, "Add port…");
send(&mut app, action);
assert_eq!(app.selected, actor);
assert!(matches!(
&app.workflow,
Some(crate::workflow::Workflow::Ports(_))
));
assert!(app.model_menu.is_none());
assert_eq!(
app.project(),
&model,
"Opening the existing creation workflow does not create a port"
);
send(&mut app, Message::Workflow(crate::workflow::Event::Apply));
assert_eq!(app.project().elements.len(), model.elements.len() + 1);
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
assert_eq!(app.project().elements[&app.selected].kind(), Kind::Port);
}
#[test]
fn menu_preserves_pending_properties_and_rejects_delayed_mutations() {
let mut app = App::with_project(Project::demo());
let target = app.project().elements.keys().next().copied().unwrap();
let selected = app.selected;
let model = app.project().clone();
send(
&mut app,
Message::Field("/name".into(), "Unapplied project title".into()),
);
assert!(app.draft_dirty());
let draft = app.draft.as_ref().unwrap().materialize().unwrap();
send(&mut app, open(target, Point::ORIGIN));
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
entry,
Entry::Action {
message: Some(_),
..
}
)));
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(Event::Invoke(token, Box::new(Message::Delete))),
);
assert_eq!(app.selected, selected);
assert_eq!(app.project(), &model);
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), draft);
assert!(!app.delete_pending);
}
#[test]
fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() {
let mut app = App::with_project(Project::demo());
let id = app.selected;
send(&mut app, open(id, Point::ORIGIN));
let old = choose(&app, "Open source");
send(&mut app, open(id, Point::new(200., 80.)));
let current = app.model_menu.as_ref().unwrap().token;
send(&mut app, old);
assert_eq!(app.model_menu.as_ref().unwrap().token, current);
let stale = choose(&app, "Open source");
app.canvas_epoch += 1;
send(&mut app, stale);
assert_eq!(app.page, Page::Diagram);
assert!(app.model_menu.is_none());
}
#[test]
fn pending_source_allows_file_navigation_but_disables_model_actions() {
let mut app = App::with_project(Project::demo());
send(&mut app, Message::Page(Page::Text));
send(
&mut app,
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
)),
);
assert!(app.source_dirty());
let original = app.source_editor.text();
let file = app.source_workspace.active.clone();
let mut request = Request::object(app.project().id, Point::ORIGIN);
request.source_file = Some(file.clone());
send(&mut app, Message::Menu(Event::Open(request)));
let action = choose(&app, "Open file");
send(&mut app, action);
assert_eq!(app.source_editor.text(), original);
assert_eq!(app.source_workspace.active, file);
let id = app.project().id;
send(&mut app, open(id, Point::ORIGIN));
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
entry,
Entry::Action {
message: Some(_),
..
}
)));
}
#[test]
fn add_bend_here_uses_context_point_and_one_source_aware_history_change() {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 0., 0.);
let b = p.add(Kind::Activity, "Receive", 650., 0.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
let mut app = App::with_project(p);
let original = app.document.bundle().clone();
let point = Position { x: 420., y: -80. };
let mut request = Request::object(edge, Point::new(540., 360.));
request.canvas = app.route_context();
request.route_point = Some(point);
send(&mut app, Message::Menu(Event::Open(request)));
let action = choose(&app, "Add bend here");
send(&mut app, action);
assert_eq!(app.selected, edge);
assert_eq!(app.selected_bend, Some((edge, 0)));
assert_eq!(
app.project().diagrams[&diagram].routes[0].waypoints,
vec![point]
);
let changed = app.document.bundle().clone();
assert_ne!(original, changed);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &original);
send(&mut app, Message::Redo);
assert_eq!(app.document.bundle(), &changed);
}
#[test]
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
let mut project = Project::empty();
let source = project.add(Kind::Activity, "Send", 0., 0.);
let target = project.add(Kind::Activity, "Receive", 650., 0.);
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
let mut app = App::with_project(project);
let before = app.document.bundle().clone();
let mut request = Request::object(edge, Point::ORIGIN);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request.clone())));
let action = choose(&app, "Move target attachment");
send(&mut app, action);
assert_eq!(
app.selected_attachment,
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
);
assert_eq!(app.document.bundle(), &before);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request)));
let stale = choose(&app, "Move source attachment");
send(&mut app, Message::Escape);
send(&mut app, stale);
assert!(
app.selected_attachment
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
let mut p = Project::empty();
let parent = p.add(Kind::Activity, "Respond", 0., 0.);
let child = p
.add_element(Kind::Activity, Layer::Oa, "Assess", Some(parent))
.unwrap();
let diagram = p.ensure_diagram(Layer::Oa);
let parent_node = p.diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == parent)
.unwrap()
.id;
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&parent_node)
.unwrap()
.collapsed = true;
let mut app = App::with_project(p);
let original = app.document.bundle().clone();
send(&mut app, Message::Explore(Some(child)));
send(&mut app, open(child, Point::new(90., 300.)));
let action = choose(&app, "Show in this diagram");
send(&mut app, action);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.selected, child);
assert!(
app.selected_node
.is_some_and(|node| app.project().diagrams[&diagram].nodes[&node].element == child)
);
assert!(!app.project().diagrams[&diagram].nodes[&parent_node].collapsed);
assert!(
projection::project_view(app.project(), diagram)
.nodes
.iter()
.any(|n| n.element == child)
);
send(&mut app, Message::Undo);
assert_eq!(
app.document.bundle(),
&original,
"One Undo restores both placement and folded ancestors"
);
}
}