1663 lines
57 KiB
Rust
1663 lines
57 KiB
Rust
use crate::{
|
|
app::{App, LoadedSnapshot, Message, Page, Pending},
|
|
forms::Draft,
|
|
workflow::{Event, Workflow},
|
|
};
|
|
use iced::widget::text_editor;
|
|
use oa_core::*;
|
|
use serde_json::json;
|
|
|
|
fn send(app: &mut App, m: Message) {
|
|
let _ = app.update(m);
|
|
}
|
|
fn fixture() -> Project {
|
|
let mut p = Project::empty();
|
|
let owner = p.add(Kind::Entity, "Rescue team", 40., 40.);
|
|
let work = p.add(Kind::Activity, "Assess incident", 340., 40.);
|
|
p.relate(RelationKind::Allocation, "Performs", owner, work);
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn focused_explorer_follows_reparenting_without_changing_identity() {
|
|
use crate::explorer::Group;
|
|
let mut p = Project::empty();
|
|
let system = p
|
|
.add_element(Kind::System, Layer::Sa, "Platform", None)
|
|
.unwrap();
|
|
let machine = p
|
|
.add_element(Kind::StateMachine, Layer::Sa, "Operating behavior", None)
|
|
.unwrap();
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Explore(Some(machine)));
|
|
assert_eq!(app.explorer_group, Some(Group::Behavior));
|
|
send(&mut app, Message::ExplorerCollapseAll);
|
|
send(
|
|
&mut app,
|
|
Message::Command(Command::Reparent {
|
|
element: machine,
|
|
parent: Some(system),
|
|
}),
|
|
);
|
|
assert_eq!(
|
|
app.project().elements[&machine].parent,
|
|
Some(system),
|
|
"{}",
|
|
app.status
|
|
);
|
|
assert_eq!(app.selected, machine);
|
|
assert_eq!(app.explorer_focus, Some(machine));
|
|
assert_eq!(app.explorer_group, Some(Group::Participants));
|
|
assert!(!app.explorer_collapsed.contains(&system));
|
|
assert!(!app.explorer_groups_collapsed.contains(&Group::Participants));
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.explorer_group, Some(Group::Behavior));
|
|
send(&mut app, Message::Redo);
|
|
assert_eq!(app.explorer_group, Some(Group::Participants));
|
|
}
|
|
|
|
#[test]
|
|
fn actor_port_creation_updates_containment_and_locate_reveals_its_owner() {
|
|
let mut project = Project::empty();
|
|
let actor = project
|
|
.add_element(Kind::SystemActor, Layer::Sa, "External operator", None)
|
|
.unwrap();
|
|
let mut app = App::with_project(project.clone());
|
|
send(&mut app, Message::Explore(Some(actor)));
|
|
send(
|
|
&mut app,
|
|
Message::PortAction(crate::port_workflow::Action::Component),
|
|
);
|
|
assert!(app.workflow.is_some(), "{}", app.status);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
let port = app.selected;
|
|
assert_eq!(app.project().elements[&port].parent, Some(actor));
|
|
assert!(matches!(
|
|
port_contract(&app.project().elements[&port]),
|
|
Some(PortContract::Component {
|
|
kind: ComponentPortKind::Standard,
|
|
..
|
|
})
|
|
));
|
|
assert!(app.explorer_index.children(actor).contains(&port));
|
|
send(&mut app, Message::ExplorerCollapseAll);
|
|
send(&mut app, Message::Search("unrelated filter".into()));
|
|
send(&mut app, Message::ExplorerLocate);
|
|
assert!(app.search.is_empty());
|
|
assert!(!app.explorer_collapsed.contains(&actor));
|
|
assert!(
|
|
!app.explorer_groups_collapsed
|
|
.contains(&crate::explorer::Group::Participants)
|
|
);
|
|
send(&mut app, Message::AddRelation);
|
|
let Some(Workflow::Relation(relation)) = &app.workflow else {
|
|
panic!("boundary workflow")
|
|
};
|
|
assert_eq!(relation.kind, RelationKind::BoundaryExchange);
|
|
assert_eq!(relation.source.as_ref().map(|c| c.id), Some(port));
|
|
send(&mut app, Message::Workflow(Event::Cancel));
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &project);
|
|
assert!(app.explorer_index.children(actor).is_empty());
|
|
send(&mut app, Message::Redo);
|
|
assert!(app.explorer_index.children(actor).contains(&port));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_source_prevents_property_draft_collision_and_preserves_both_if_present() {
|
|
let mut app = App::with_project(Project::empty());
|
|
let original = app.project().clone();
|
|
send(&mut app, Message::Page(Page::Text));
|
|
let source = format!("// Review rationale\n{}", app.source_editor.text());
|
|
send(
|
|
&mut app,
|
|
Message::SourceEdit(text_editor::Action::SelectAll),
|
|
);
|
|
send(
|
|
&mut app,
|
|
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
|
std::sync::Arc::new(source.clone()),
|
|
))),
|
|
);
|
|
send(&mut app, Message::Page(Page::Diagram));
|
|
send(
|
|
&mut app,
|
|
Message::Field(
|
|
"/name".into(),
|
|
"Must not enter a hidden property draft".into(),
|
|
),
|
|
);
|
|
assert!(!app.draft_dirty());
|
|
assert_eq!(app.source_editor.text(), source);
|
|
assert_eq!(app.project(), &original);
|
|
// A stale adapter or future integration must not let Apply drop an already
|
|
// present property draft either. Preserve both and require an explicit choice.
|
|
app.draft
|
|
.as_mut()
|
|
.unwrap()
|
|
.input("/name", "Retained property draft".into());
|
|
assert!(app.draft_dirty());
|
|
assert!(!app.apply_source());
|
|
assert!(app.source_dirty());
|
|
assert!(app.draft_dirty());
|
|
assert_eq!(app.source_editor.text(), source);
|
|
assert_eq!(app.project(), &original);
|
|
send(&mut app, Message::RevertProperties);
|
|
assert!(app.apply_source());
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "Reviewed model".into()),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert_eq!(app.project().name, "Reviewed model");
|
|
assert!(
|
|
app.document.bundle().files[&app.document.bundle().entry]
|
|
.starts_with("// Review rationale")
|
|
);
|
|
}
|
|
#[test]
|
|
fn system_only_models_open_the_populated_perspective() {
|
|
let mut p = Project::empty();
|
|
p.add_element(Kind::Function, Layer::Sa, "Receive report", None)
|
|
.unwrap();
|
|
let diagram = p.ensure_diagram(Layer::Sa);
|
|
let app = App::with_project(p);
|
|
assert_eq!(app.layer, Layer::Sa);
|
|
assert_eq!(app.active_diagram, Some(diagram));
|
|
assert_eq!(app.create_kind, Kind::Function);
|
|
assert_eq!(App::with_project(Project::empty()).layer, Layer::Oa);
|
|
assert_eq!(App::with_project(Project::demo()).layer, Layer::Oa);
|
|
}
|
|
fn source(app: &mut App, text: &str) {
|
|
send(app, Message::Page(Page::Text));
|
|
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
|
send(
|
|
app,
|
|
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
|
std::sync::Arc::new(text.into()),
|
|
))),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opening_and_creating_models_preserve_the_actual_window_size_for_fit() {
|
|
let size = iced::Size::new(1100., 700.);
|
|
let project = fixture();
|
|
let mut expected = App::with_project(project.clone());
|
|
send(&mut expected, Message::WindowResized(size));
|
|
send(&mut expected, Message::Fit);
|
|
|
|
let mut app = App::with_project(Project::empty());
|
|
send(&mut app, Message::WindowResized(size));
|
|
send(
|
|
&mut app,
|
|
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
|
|
std::path::PathBuf::from("resized-model.arcadia"),
|
|
Box::new(persistence::DecodeResult {
|
|
project: project.clone(),
|
|
migration: None,
|
|
}),
|
|
std::sync::Arc::from(language::format(&project).into_bytes()),
|
|
)))),
|
|
);
|
|
assert_eq!(app.window_size, size);
|
|
assert_eq!(app.zoom, expected.zoom);
|
|
assert_eq!(app.pan, expected.pan);
|
|
send(&mut app, Message::Fit);
|
|
assert_eq!(app.zoom, expected.zoom);
|
|
|
|
send(&mut app, Message::Request(Pending::New));
|
|
assert!(app.project().elements.is_empty());
|
|
assert_eq!(app.window_size, size);
|
|
send(&mut app, Message::Request(Pending::Demo));
|
|
assert_eq!(app.window_size, size);
|
|
let mut expected_demo = App::with_project(app.project().clone());
|
|
send(&mut expected_demo, Message::WindowResized(size));
|
|
send(&mut expected_demo, Message::Fit);
|
|
assert_eq!(app.zoom, expected_demo.zoom);
|
|
assert_eq!(app.pan, expected_demo.pan);
|
|
}
|
|
|
|
#[test]
|
|
fn every_demo_record_has_a_lossless_graphical_draft() {
|
|
let p = Project::demo();
|
|
for (id, _) in p.identities() {
|
|
let draft = Draft::from_project(&p, id).expect("Graphical record");
|
|
assert!(!draft.dirty());
|
|
let candidate = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
|
|
assert_eq!(candidate, p, "untouched property form changed {id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
|
|
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(
|
|
include_bytes!("../../oa-core/tests/fixtures/v2-coastal-rescue.json").as_slice(),
|
|
);
|
|
let decoded = persistence::decode_with_report(&bytes).unwrap();
|
|
let original = decoded.project.clone();
|
|
let report = decoded.migration.clone().unwrap();
|
|
let mut app = App::with_project(Project::empty());
|
|
send(
|
|
&mut app,
|
|
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
|
|
std::path::PathBuf::from("legacy.json"),
|
|
Box::new(decoded),
|
|
bytes.clone(),
|
|
)))),
|
|
);
|
|
assert_eq!(app.migration, Some(report));
|
|
assert_eq!(app.page, Page::Validation);
|
|
assert_eq!(app.project(), &original);
|
|
assert!(!app.dirty());
|
|
assert!(app.source_snapshot.starts_with("arcadia 3;"));
|
|
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
|
|
}
|
|
|
|
#[test]
|
|
fn fit_uses_measured_canvas_bounds_and_preserves_them_on_open() {
|
|
let p = fixture();
|
|
let mut app = App::with_project(p.clone());
|
|
let canvas = iced::Size::new(510., 275.);
|
|
send(&mut app, Message::CanvasResized(canvas));
|
|
send(
|
|
&mut app,
|
|
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
|
|
std::path::PathBuf::from("measured.arcadia"),
|
|
Box::new(persistence::DecodeResult {
|
|
project: p.clone(),
|
|
migration: None,
|
|
}),
|
|
std::sync::Arc::from(language::format(&p).into_bytes()),
|
|
)))),
|
|
);
|
|
send(&mut app, Message::Fit);
|
|
for node in projection::project_view(app.project(), app.active_diagram.unwrap()).nodes {
|
|
let x = app.pan.x + node.position.x * app.zoom;
|
|
let y = app.pan.y + node.position.y * app.zoom;
|
|
assert!(x >= 23.9 && y >= 23.9);
|
|
assert!(x + node.size.width * app.zoom <= canvas.width - 23.9);
|
|
assert!(y + node.size.height * app.zoom <= canvas.height - 23.9);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fit_adapts_to_canvas_returning_after_help_but_preserves_manual_navigation() {
|
|
let p = fixture();
|
|
let mut app = App::with_project(p);
|
|
send(
|
|
&mut app,
|
|
Message::CanvasResized(iced::Size::new(900., 600.)),
|
|
);
|
|
send(&mut app, Message::Page(Page::Guide));
|
|
send(
|
|
&mut app,
|
|
Message::WindowResized(iced::Size::new(1100., 700.)),
|
|
);
|
|
send(&mut app, Message::Page(Page::Diagram));
|
|
let compact = iced::Size::new(500., 270.);
|
|
send(&mut app, Message::CanvasResized(compact));
|
|
let fitted = (app.zoom, app.pan);
|
|
send(&mut app, Message::Fit);
|
|
assert_eq!((app.zoom, app.pan), fitted);
|
|
send(&mut app, Message::Zoom(1.2));
|
|
let manual = (app.zoom, app.pan);
|
|
send(
|
|
&mut app,
|
|
Message::CanvasResized(iced::Size::new(650., 400.)),
|
|
);
|
|
assert_eq!((app.zoom, app.pan), manual);
|
|
}
|
|
|
|
#[test]
|
|
fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
|
|
let mut p = Project::empty();
|
|
let sender = p.add(Kind::Activity, "Report", 0., 0.);
|
|
let target = p.add(Kind::Activity, "Receive", 600., 0.);
|
|
let edge = p.relate(RelationKind::Interaction, "Report signal", sender, target);
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let mut app = App::with_project(p);
|
|
let canvas = iced::Size::new(900., 600.);
|
|
send(&mut app, Message::CanvasResized(canvas));
|
|
let viewport = (app.zoom, app.pan);
|
|
send(
|
|
&mut app,
|
|
Message::Command(Command::SetEdgeRoute {
|
|
diagram,
|
|
route: EdgeRoute {
|
|
edge,
|
|
waypoints: vec![Position { x: 400., y: 400. }],
|
|
label: Some(EdgeLabel {
|
|
fraction: 0.5,
|
|
offset: Position { x: 0., y: 150. },
|
|
}),
|
|
},
|
|
}),
|
|
);
|
|
assert_eq!((app.zoom, app.pan), viewport);
|
|
let mut fitted = App::with_project(app.project().clone());
|
|
send(&mut fitted, Message::CanvasResized(canvas));
|
|
assert_ne!(
|
|
(fitted.zoom, fitted.pan),
|
|
viewport,
|
|
"The edited route must change the fitted bounds to expose an accidental refit"
|
|
);
|
|
|
|
// The shared file modal remounts the canvas sensor. Its on_show measurement
|
|
// describes the existing viewport, not a user request to fit the edited model.
|
|
send(&mut app, Message::Export);
|
|
assert!(app.file_prompt.is_some());
|
|
send(&mut app, Message::CanvasResized(canvas));
|
|
assert_eq!((app.zoom, app.pan), viewport);
|
|
send(&mut app, Message::CancelFile);
|
|
send(&mut app, Message::CanvasResized(canvas));
|
|
assert_eq!((app.zoom, app.pan), viewport);
|
|
|
|
let compact = iced::Size::new(650., 400.);
|
|
send(&mut app, Message::CanvasResized(compact));
|
|
send(&mut fitted, Message::CanvasResized(compact));
|
|
assert_eq!((app.zoom, app.pan), (fitted.zoom, fitted.pan));
|
|
assert_ne!((app.zoom, app.pan), viewport);
|
|
}
|
|
|
|
#[test]
|
|
fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
|
|
let p = fixture();
|
|
let id = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(id));
|
|
send(&mut app, Message::SourceForSelection);
|
|
assert_eq!(app.page, Page::Text);
|
|
assert!(app.source_jump.is_some());
|
|
send(&mut app, Message::SourceViewport);
|
|
assert!(app.source_jump.is_none());
|
|
send(&mut app, Message::SourceForSelection);
|
|
assert!(app.source_jump.is_some());
|
|
send(
|
|
&mut app,
|
|
Message::SourceEdit(text_editor::Action::SelectAll),
|
|
);
|
|
assert!(app.source_jump.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
|
|
use crate::source_navigation::Event;
|
|
let mut app = App::with_project(fixture());
|
|
let accepted = app.project().clone();
|
|
source(&mut app, "é 救助\nthis is an unfinished model: 救助");
|
|
send(&mut app, Message::SourceNavigation(Event::Open(false)));
|
|
send(
|
|
&mut app,
|
|
Message::SourceNavigation(Event::Query("救助".into())),
|
|
);
|
|
send(&mut app, Message::SourceNavigation(Event::Next(false)));
|
|
let cursor = app.source_jump.unwrap();
|
|
assert_eq!(
|
|
cursor.position,
|
|
text_editor::Position { line: 0, column: 3 }
|
|
);
|
|
assert_eq!(
|
|
cursor.selection,
|
|
Some(text_editor::Position { line: 0, column: 9 })
|
|
);
|
|
send(&mut app, Message::SourceViewport);
|
|
assert_eq!(app.source_editor.cursor(), cursor);
|
|
send(
|
|
&mut app,
|
|
Message::SourceNavigation(Event::Line("99".into())),
|
|
);
|
|
send(&mut app, Message::SourceNavigation(Event::Go));
|
|
assert!(app.status.contains("1 to 2"));
|
|
assert_eq!(*app.project(), accepted);
|
|
assert!(app.source_dirty());
|
|
}
|
|
#[test]
|
|
fn graphical_rename_keeps_aliases_and_references_and_undo() {
|
|
let mut app = App::with_project(fixture());
|
|
let before = app.project().clone();
|
|
let id = before
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
send(&mut app, Message::Select(id));
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "Coordinate rescue".into()),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert_eq!(
|
|
app.project().elements[&id].alias,
|
|
before.elements[&id].alias
|
|
);
|
|
assert_eq!(
|
|
crate::source_workspace::compile(&app.source_workspace.bundle())
|
|
.unwrap()
|
|
.decoded
|
|
.project,
|
|
*app.project()
|
|
);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(*app.project(), before);
|
|
}
|
|
#[test]
|
|
fn identity_property_change_is_atomic_and_rewrites_all_live_references() {
|
|
let mut app = App::with_project(fixture());
|
|
let before = app.project().clone();
|
|
let old = before
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
let new = Id::new_v4();
|
|
send(&mut app, Message::Select(old));
|
|
send(&mut app, Message::Field("/id".into(), new.to_string()));
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "New identity".into()),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert!(!app.project().contains(old));
|
|
assert!(app.project().relations.values().any(|r| r.target() == new));
|
|
assert!(
|
|
app.project()
|
|
.diagrams
|
|
.values()
|
|
.any(|d| d.nodes.values().any(|n| n.element == new))
|
|
);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(*app.project(), before);
|
|
}
|
|
#[test]
|
|
fn invalid_numeric_draft_survives_navigation_save_and_help() {
|
|
let mut app = App::with_project(fixture());
|
|
let p = app.project().clone();
|
|
let node = p
|
|
.diagrams
|
|
.values()
|
|
.next()
|
|
.unwrap()
|
|
.nodes
|
|
.values()
|
|
.next()
|
|
.unwrap()
|
|
.id;
|
|
send(&mut app, Message::Select(node));
|
|
send(&mut app, Message::Field("/position/x".into(), "-".into()));
|
|
send(&mut app, Message::Save(false));
|
|
assert!(!app.busy);
|
|
assert_eq!(*app.project(), p);
|
|
send(&mut app, Message::Page(Page::Guide));
|
|
send(&mut app, Message::Page(Page::Diagram));
|
|
assert_eq!(app.page, Page::Diagram);
|
|
assert_eq!(app.draft.as_ref().unwrap().inputs["/position/x"].raw, "-");
|
|
send(&mut app, Message::SelectProject);
|
|
assert_eq!(app.selected, node);
|
|
}
|
|
#[test]
|
|
fn malformed_source_preserves_model_and_is_recoverable() {
|
|
let mut app = App::with_project(fixture());
|
|
let p = app.project().clone();
|
|
source(&mut app, "arcadia 2; ?");
|
|
send(&mut app, Message::ApplySource);
|
|
assert_eq!(*app.project(), p);
|
|
assert!(!app.source_diagnostics.is_empty());
|
|
send(&mut app, Message::Request(Pending::New));
|
|
assert!(app.pending.is_some());
|
|
send(&mut app, Message::Cancel);
|
|
assert_eq!(app.source_editor.text(), "arcadia 2; ?");
|
|
send(&mut app, Message::RevertSource);
|
|
assert_eq!(
|
|
crate::source_workspace::compile(&app.source_workspace.bundle())
|
|
.unwrap()
|
|
.decoded
|
|
.project,
|
|
p
|
|
);
|
|
}
|
|
#[test]
|
|
fn escaped_string_mode_preserves_exact_control_characters() {
|
|
let mut app = App::with_project(fixture());
|
|
send(&mut app, Message::ExactStrings(true));
|
|
let exact = "Plan\r\nRescue\t\"North\"";
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), serde_json::to_string(exact).unwrap()),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert_eq!(app.project().name, exact);
|
|
assert!(!app.draft_dirty());
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "not quoted".into()),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert_eq!(app.project().name, exact);
|
|
assert!(app.draft_dirty());
|
|
send(&mut app, Message::RevertProperties);
|
|
assert!(!app.draft_dirty());
|
|
}
|
|
#[test]
|
|
fn explicit_relationship_builder_never_mutates_on_preview() {
|
|
let mut p = Project::empty();
|
|
let owner = p.add(Kind::Entity, "Team", 0., 0.);
|
|
let work = p.add(Kind::Activity, "Respond", 300., 0.);
|
|
let mut workflow = Workflow::relationship(&p, owner, Layer::Oa);
|
|
let before = p.clone();
|
|
workflow
|
|
.update(&p, Event::RelationName("Performs".into()))
|
|
.unwrap();
|
|
workflow
|
|
.update(
|
|
&p,
|
|
Event::Target(crate::app::Choice {
|
|
id: work,
|
|
label: "Respond".into(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(p, before);
|
|
let result = workflow.update(&p, Event::Apply).unwrap().unwrap();
|
|
assert_eq!(result.relations.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn explorer_and_creation_selection_follow_their_own_placement() {
|
|
let p = fixture();
|
|
let owner = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Entity)
|
|
.unwrap()
|
|
.id;
|
|
let work = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(owner));
|
|
let owner_node = app.selected_node.expect("placed explorer selection");
|
|
send(&mut app, Message::Select(work));
|
|
let work_node = app.selected_node.expect("placed work selection");
|
|
assert_ne!(owner_node, work_node);
|
|
let before = app.project().clone();
|
|
send(&mut app, Message::PlaceSelected);
|
|
assert_eq!(
|
|
*app.project(),
|
|
before,
|
|
"showing an existing placement does not create a duplicate"
|
|
);
|
|
send(&mut app, Message::CreateKind(Kind::Capability));
|
|
send(&mut app, Message::Add(false));
|
|
let diagram = &app.project().diagrams[&app.active_diagram.unwrap()];
|
|
let new_node = &diagram.nodes[&app.selected_node.expect("new element placement")];
|
|
assert_eq!(new_node.element, app.selected);
|
|
assert_ne!(
|
|
new_node.id, work_node,
|
|
"placement actions cannot target previously selected work"
|
|
);
|
|
}
|
|
#[test]
|
|
fn bidirectional_transition_ui_commits_once_and_preserves_other_diagrams() {
|
|
let mut p = Project::empty();
|
|
let activity = p.add(Kind::Activity, "Coordinate rescue", 120.5, 85.25);
|
|
let original = p.clone();
|
|
let mut w = Workflow::transition(&p, activity).unwrap();
|
|
let next = w.update(&p, Event::Apply).unwrap().unwrap();
|
|
assert_eq!(
|
|
next.diagrams[&original.diagrams.keys().copied().next().unwrap()],
|
|
*original.diagrams.values().next().unwrap()
|
|
);
|
|
let function = next
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Function)
|
|
.unwrap()
|
|
.id;
|
|
let mut reverse = Workflow::transition(&next, function).unwrap();
|
|
let again = reverse.update(&next, Event::Apply).unwrap().unwrap();
|
|
assert_eq!(again.elements.len(), next.elements.len());
|
|
assert_eq!(again.traces.len(), 1);
|
|
let mut app = App::with_project(original.clone());
|
|
send(&mut app, Message::Command(Command::Replace(next)));
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(*app.project(), original);
|
|
}
|
|
|
|
#[test]
|
|
fn mission_capability_authoring_is_contextual_atomic_and_text_equivalent() {
|
|
for (layer, mission_kind, capability_kind, participant_kind) in [
|
|
(
|
|
Layer::Oa,
|
|
Kind::OperationalMission,
|
|
Kind::Capability,
|
|
Kind::Actor,
|
|
),
|
|
(
|
|
Layer::Sa,
|
|
Kind::Mission,
|
|
Kind::SystemCapability,
|
|
Kind::SystemActor,
|
|
),
|
|
] {
|
|
let mut p = Project::empty();
|
|
let mission = p
|
|
.add_element(mission_kind, layer, "Protect life", None)
|
|
.unwrap();
|
|
let base = p
|
|
.add_element(capability_kind, layer, "Rescue people", None)
|
|
.unwrap();
|
|
let extension = p
|
|
.add_element(capability_kind, layer, "Rescue in heavy weather", None)
|
|
.unwrap();
|
|
let actor = p
|
|
.add_element(participant_kind, layer, "Rescue operator", None)
|
|
.unwrap();
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(mission));
|
|
send(&mut app, Message::AddRelation);
|
|
assert!(
|
|
matches!(app.workflow.as_ref(), Some(Workflow::Relation(d)) if d.kind == RelationKind::MissionCapability && d.source.as_ref().unwrap().id == mission)
|
|
);
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Target(crate::app::Choice {
|
|
id: base,
|
|
label: "Rescue people".into(),
|
|
})),
|
|
);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
assert_eq!(app.project().relations.len(), 1);
|
|
assert!(
|
|
app.project()
|
|
.relations
|
|
.values()
|
|
.any(|r| r.name == "Protect life exploits Rescue people")
|
|
);
|
|
send(&mut app, Message::Select(mission));
|
|
send(
|
|
&mut app,
|
|
Message::AddRelationKind(RelationKind::MissionInvolvement, true),
|
|
);
|
|
assert!(
|
|
matches!(app.workflow.as_ref(), Some(Workflow::Relation(d)) if d.target.as_ref().unwrap().id == mission && d.source.is_none())
|
|
);
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Source(crate::app::Choice {
|
|
id: actor,
|
|
label: "Rescue operator".into(),
|
|
})),
|
|
);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
let before = app.project().clone();
|
|
send(&mut app, Message::Select(extension));
|
|
send(
|
|
&mut app,
|
|
Message::AddRelationKind(RelationKind::CapabilityExtend, false),
|
|
);
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Target(crate::app::Choice {
|
|
id: base,
|
|
label: "Rescue people".into(),
|
|
})),
|
|
);
|
|
let condition = "Sea state >= 6\nAND crew is qualified";
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Condition(text_editor::Action::Edit(
|
|
text_editor::Edit::Paste(std::sync::Arc::new(condition.into())),
|
|
))),
|
|
);
|
|
assert_eq!(
|
|
*app.project(),
|
|
before,
|
|
"preview must preserve committed model"
|
|
);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
assert!(app.project().relations.values().any(|r| matches!(&r.body, RelationBody::CapabilityExtend {source,target,condition: value} if *source == extension && *target == base && value == condition)));
|
|
assert_eq!(
|
|
crate::source_workspace::compile(&app.source_workspace.bundle())
|
|
.unwrap()
|
|
.decoded
|
|
.project,
|
|
*app.project()
|
|
);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(*app.project(), before);
|
|
send(&mut app, Message::Redo);
|
|
assert_eq!(app.project().relations.len(), 3);
|
|
}
|
|
}
|
|
#[test]
|
|
fn property_enum_and_optional_reference_roundtrip() {
|
|
let mut p = Project::empty();
|
|
let item = p
|
|
.add_element(Kind::DataType, Layer::Oa, "Alarm severity", None)
|
|
.unwrap();
|
|
let mut d = Draft::from_project(&p, item).unwrap();
|
|
d.shape(
|
|
"/body/Oa/Shared/DataType/definition",
|
|
json!({"Enumeration":{"literals":["minor","major"]}}),
|
|
)
|
|
.unwrap();
|
|
let p = execute_batch(&p, &[d.command().unwrap()]).unwrap();
|
|
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
|
}
|
|
|
|
#[test]
|
|
fn modal_owns_background_commands_and_shortcuts() {
|
|
let mut app = App::with_project(fixture());
|
|
send(&mut app, Message::AddRelation);
|
|
let before = app.project().clone();
|
|
send(&mut app, Message::Layer(Layer::Sa));
|
|
send(&mut app, Message::Page(Page::Text));
|
|
send(&mut app, Message::ShortcutUndo(false));
|
|
send(&mut app, Message::Save(false));
|
|
assert_eq!(app.layer, Layer::Oa);
|
|
assert_eq!(app.page, Page::Diagram);
|
|
assert!(!app.busy);
|
|
assert_eq!(*app.project(), before);
|
|
send(&mut app, Message::Escape);
|
|
assert!(app.workflow.is_none());
|
|
}
|
|
#[test]
|
|
fn selecting_current_constructors_preserves_payloads() {
|
|
let mut app = App::with_project(Project::demo());
|
|
let before = app.project().clone();
|
|
for e in before.elements.values() {
|
|
send(&mut app, Message::Select(e.id));
|
|
send(&mut app, Message::ElementKind(e.kind()));
|
|
assert!(!app.draft_dirty());
|
|
}
|
|
for r in before.relations.values() {
|
|
send(&mut app, Message::Select(r.id));
|
|
send(&mut app, Message::RelationKind(r.kind()));
|
|
assert!(!app.draft_dirty());
|
|
}
|
|
let mut p = Project::empty();
|
|
let id = p
|
|
.add_element(Kind::DataType, Layer::Oa, "Severity", None)
|
|
.unwrap();
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(id));
|
|
let path = "/body/Oa/Shared/DataType/definition";
|
|
send(
|
|
&mut app,
|
|
Message::FieldVariant(
|
|
path.into(),
|
|
json!({"Enumeration":{"literals":["minor","major"]}}),
|
|
),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
let accepted = app.project().clone();
|
|
send(
|
|
&mut app,
|
|
Message::FieldVariant(path.into(), json!({"Enumeration":{"literals":[]}})),
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert_eq!(*app.project(), accepted);
|
|
}
|
|
#[test]
|
|
fn dragging_keeps_semantic_selection_and_same_node_remains_editable() {
|
|
let mut app = App::with_project(fixture());
|
|
let diagram = app.active_diagram.unwrap();
|
|
let placement = app.project().diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.next()
|
|
.unwrap()
|
|
.clone();
|
|
let semantic = placement.element;
|
|
send(&mut app, Message::SelectNode(placement.id));
|
|
let before = app.project().clone();
|
|
for distance in [40., 80.] {
|
|
send(
|
|
&mut app,
|
|
Message::MoveNode(
|
|
placement.id,
|
|
Position {
|
|
x: placement.position.x + distance,
|
|
y: placement.position.y + distance,
|
|
},
|
|
),
|
|
);
|
|
assert_eq!(app.selected, semantic);
|
|
assert_eq!(app.selected_node, Some(placement.id));
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().kind,
|
|
crate::forms::RecordKind::Element
|
|
);
|
|
send(&mut app, Message::SelectNode(placement.id));
|
|
assert_eq!(app.selected, semantic);
|
|
}
|
|
send(&mut app, Message::Undo);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &before);
|
|
// Explicit placement inspection must not make its canvas card unselectable.
|
|
send(&mut app, Message::Select(placement.id));
|
|
send(&mut app, Message::SelectNode(placement.id));
|
|
assert_eq!(app.selected, semantic);
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "Still editable after dragging".into()),
|
|
);
|
|
send(&mut app, Message::SelectNode(placement.id));
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
|
"Still editable after dragging"
|
|
);
|
|
send(&mut app, Message::Apply);
|
|
assert_eq!(
|
|
app.project().elements[&semantic].name,
|
|
"Still editable after dragging"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn placement_identity_edit_then_drag_and_diagram_identity_then_arrange() {
|
|
let mut app = App::with_project(fixture());
|
|
let diagram = app.active_diagram.unwrap();
|
|
let node = *app.project().diagrams[&diagram]
|
|
.nodes
|
|
.keys()
|
|
.next()
|
|
.unwrap();
|
|
send(&mut app, Message::Select(node));
|
|
let changed = Id::new_v4();
|
|
send(&mut app, Message::Field("/id".into(), changed.to_string()));
|
|
send(&mut app, Message::SelectNode(node));
|
|
send(
|
|
&mut app,
|
|
Message::MoveNode(node, Position { x: 200., y: 150. }),
|
|
);
|
|
assert_eq!(
|
|
app.project().diagrams[&diagram].nodes[&changed].position,
|
|
Position { x: 200., y: 150. }
|
|
);
|
|
send(&mut app, Message::Select(diagram));
|
|
let changed_diagram = Id::new_v4();
|
|
send(
|
|
&mut app,
|
|
Message::Field("/id".into(), changed_diagram.to_string()),
|
|
);
|
|
send(&mut app, Message::Command(Command::Layout(diagram)));
|
|
assert_eq!(app.active_diagram, Some(changed_diagram));
|
|
assert!(!app.project().contains(diagram));
|
|
}
|
|
#[test]
|
|
fn placement_relocation_follows_cross_layer_traceability_view() {
|
|
let mut p = fixture();
|
|
let original = p.ensure_diagram(Layer::Oa);
|
|
let target = p.ensure_diagram(Layer::Sa);
|
|
p.diagrams.get_mut(&target).unwrap().projection = ProjectionKind::Traceability;
|
|
let node = *p.diagrams[&original].nodes.keys().next().unwrap();
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(node));
|
|
send(
|
|
&mut app,
|
|
Message::Field("/diagram".into(), target.to_string()),
|
|
);
|
|
assert!(app.apply(), "{}", app.status);
|
|
assert_eq!(app.layer, Layer::Sa);
|
|
assert_eq!(app.active_diagram, Some(target));
|
|
assert_eq!(app.selected_node, Some(node));
|
|
assert!(app.project().diagrams[&target].nodes.contains_key(&node));
|
|
assert!(!app.project().diagrams[&original].nodes.contains_key(&node));
|
|
send(&mut app, Message::Select(target));
|
|
send(&mut app, Message::Field("/layer".into(), "Oa".into()));
|
|
assert!(app.apply(), "{}", app.status);
|
|
assert_eq!(app.layer, Layer::Oa);
|
|
assert_eq!(app.active_diagram, Some(target));
|
|
}
|
|
#[test]
|
|
fn manual_trace_requires_target_and_selection_normalizes_creation_kind() {
|
|
let p = Project::demo();
|
|
let a = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
let b = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Function)
|
|
.unwrap()
|
|
.id;
|
|
let mut app = App::with_project(p.clone());
|
|
send(&mut app, Message::Select(b));
|
|
assert_eq!(app.create_kind, Kind::Function);
|
|
send(&mut app, Message::AddTrace);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
assert_eq!(*app.project(), p);
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Target(crate::app::Choice {
|
|
id: a,
|
|
label: "OA".into(),
|
|
})),
|
|
);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
assert_eq!(app.project().traces.len(), p.traces.len() + 1);
|
|
send(&mut app, Message::Select(a));
|
|
assert_eq!(app.create_kind, Kind::Activity);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_save_and_continue_returns_to_repairable_draft() {
|
|
let mut app = App::with_project(fixture());
|
|
source(&mut app, "arcadia 2; ?");
|
|
send(&mut app, Message::Request(Pending::New));
|
|
send(&mut app, Message::SaveAndContinue);
|
|
assert!(app.pending.is_none());
|
|
assert_eq!(app.page, Page::Text);
|
|
send(&mut app, Message::RevertSource);
|
|
assert!(!app.source_dirty());
|
|
}
|
|
|
|
#[test]
|
|
fn counterpart_creation_focuses_visible_target_and_reuse_keeps_views() {
|
|
let mut app = App::with_project(fixture());
|
|
let source = app
|
|
.project()
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
send(&mut app, Message::Select(source));
|
|
send(&mut app, Message::StartTransition);
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Scope("Selected only".into())),
|
|
);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
let target = app.selected;
|
|
assert_eq!(app.project().elements[&target].kind(), Kind::Function);
|
|
assert!(
|
|
app.project().diagrams[&app.active_diagram.unwrap()]
|
|
.nodes
|
|
.values()
|
|
.any(|n| n.element == target)
|
|
);
|
|
let before = app.project().clone();
|
|
send(&mut app, Message::StartTransition);
|
|
send(
|
|
&mut app,
|
|
Message::Workflow(Event::Scope("Selected only".into())),
|
|
);
|
|
send(&mut app, Message::Workflow(Event::Apply));
|
|
assert_eq!(app.project().diagrams, before.diagrams);
|
|
assert_eq!(app.selected, source);
|
|
assert!(
|
|
app.project().diagrams[&app.active_diagram.unwrap()]
|
|
.nodes
|
|
.values()
|
|
.any(|n| n.element == source)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transition_resolutions_belong_to_the_effective_target_and_kind() {
|
|
use oa_core::transitions::{FieldDecision, TransitionField};
|
|
fn transition(workflow: &Workflow) -> &crate::workflow::Transition {
|
|
let Workflow::Transition(transition) = workflow else {
|
|
panic!("Transition workflow")
|
|
};
|
|
transition
|
|
}
|
|
let mut p = Project::empty();
|
|
let source = p
|
|
.add_element(Kind::Activity, Layer::Oa, "Source work", None)
|
|
.unwrap();
|
|
let first = p
|
|
.add_element(Kind::Function, Layer::Sa, "First target", None)
|
|
.unwrap();
|
|
let second = p
|
|
.add_element(Kind::Function, Layer::Sa, "Second target", None)
|
|
.unwrap();
|
|
let trace = Id::new_v4();
|
|
p.traces.insert(
|
|
trace,
|
|
Trace {
|
|
id: trace,
|
|
alias: "work_trace".into(),
|
|
name: "Work trace".into(),
|
|
description: String::new(),
|
|
oa: source,
|
|
sa: first,
|
|
kind: TraceKind::Refinement,
|
|
baseline: None,
|
|
},
|
|
);
|
|
let mut workflow = Workflow::transition(&p, source).unwrap();
|
|
workflow
|
|
.update(
|
|
&p,
|
|
Event::Resolve(
|
|
source,
|
|
TransitionField::Name,
|
|
FieldDecision::Value("Merged for first target".into()),
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
transition(&workflow).request.mappings.is_empty(),
|
|
"First target was inferred"
|
|
);
|
|
let choose = |id| crate::app::Choice {
|
|
id,
|
|
label: String::new(),
|
|
};
|
|
workflow
|
|
.update(&p, Event::MappingTarget(source, choose(first)))
|
|
.unwrap();
|
|
assert_eq!(
|
|
transition(&workflow).request.resolutions.len(),
|
|
1,
|
|
"Same inferred target preserves merged draft"
|
|
);
|
|
workflow
|
|
.update(&p, Event::MappingTarget(source, choose(second)))
|
|
.unwrap();
|
|
assert!(transition(&workflow).request.resolutions.is_empty());
|
|
assert!(
|
|
transition(&workflow)
|
|
.plan
|
|
.conflicts
|
|
.iter()
|
|
.any(|c| c.source == Some(source)
|
|
&& c.target == Some(second)
|
|
&& c.field == Some(TransitionField::Name))
|
|
);
|
|
assert!(
|
|
workflow.update(&p, Event::Apply).is_err(),
|
|
"Old resolution cannot overwrite second target"
|
|
);
|
|
workflow
|
|
.update(
|
|
&p,
|
|
Event::Resolve(
|
|
source,
|
|
TransitionField::Name,
|
|
FieldDecision::Value("Reviewed second target".into()),
|
|
),
|
|
)
|
|
.unwrap();
|
|
workflow
|
|
.update(&p, Event::MappingTarget(source, choose(second)))
|
|
.unwrap();
|
|
assert_eq!(transition(&workflow).request.resolutions.len(), 1);
|
|
let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap();
|
|
assert_eq!(accepted.elements[&second].name, "Reviewed second target");
|
|
assert_eq!(accepted.elements[&first].name, "First target");
|
|
|
|
workflow
|
|
.update(&p, Event::MappingTarget(source, choose(Id::nil())))
|
|
.unwrap();
|
|
assert!(
|
|
transition(&workflow).request.resolutions.is_empty(),
|
|
"Creation is a different target context"
|
|
);
|
|
workflow
|
|
.update(
|
|
&p,
|
|
Event::Resolve(
|
|
source,
|
|
TransitionField::Description,
|
|
FieldDecision::Value("Draft for inferred function kind".into()),
|
|
),
|
|
)
|
|
.unwrap();
|
|
workflow
|
|
.update(&p, Event::MappingKind(source, Kind::Function))
|
|
.unwrap();
|
|
assert_eq!(
|
|
transition(&workflow).request.resolutions.len(),
|
|
1,
|
|
"Same inferred kind preserves draft"
|
|
);
|
|
|
|
let participant = p
|
|
.add_element(Kind::Entity, Layer::Oa, "Participant", None)
|
|
.unwrap();
|
|
let mut kinds = Workflow::transition(&p, participant).unwrap();
|
|
kinds
|
|
.update(&p, Event::MappingKind(participant, Kind::SystemActor))
|
|
.unwrap();
|
|
kinds
|
|
.update(
|
|
&p,
|
|
Event::Resolve(
|
|
participant,
|
|
TransitionField::Description,
|
|
FieldDecision::Value("Actor review".into()),
|
|
),
|
|
)
|
|
.unwrap();
|
|
kinds
|
|
.update(&p, Event::MappingKind(participant, Kind::SystemActor))
|
|
.unwrap();
|
|
assert_eq!(transition(&kinds).request.resolutions.len(), 1);
|
|
kinds
|
|
.update(&p, Event::MappingKind(participant, Kind::System))
|
|
.unwrap();
|
|
assert!(
|
|
transition(&kinds).request.resolutions.is_empty(),
|
|
"Changed kind requires a new decision"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_views() {
|
|
let p = Project::demo();
|
|
let scenario = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Scenario)
|
|
.unwrap()
|
|
.id;
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Layer(Layer::Sa));
|
|
send(&mut app, Message::Page(Page::Behavior));
|
|
let diagrams = app.project().diagrams.clone();
|
|
send(&mut app, Message::Select(scenario));
|
|
send(&mut app, Message::CreateKind(Kind::Occurrence));
|
|
send(&mut app, Message::Add(true));
|
|
let fragment = app.selected;
|
|
send(
|
|
&mut app,
|
|
Message::FieldVariant(
|
|
"/body/Sa/Shared/Occurrence/body".into(),
|
|
json!({"Fragment":{"operator":"Optional","branches":[]}}),
|
|
),
|
|
);
|
|
assert!(app.apply(), "{}", app.status);
|
|
send(&mut app, Message::Select(fragment));
|
|
send(&mut app, Message::Add(true));
|
|
let branch = app.selected;
|
|
send(&mut app, Message::Select(branch));
|
|
send(&mut app, Message::Add(true));
|
|
let occurrence = app.selected;
|
|
assert_eq!(app.project().elements[&fragment].parent, Some(scenario));
|
|
assert!(
|
|
app.project().elements[&scenario]
|
|
.steps()
|
|
.unwrap()
|
|
.contains(&fragment)
|
|
);
|
|
assert!(matches!(app.project().elements[&fragment].shared(),
|
|
Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { branches, .. }, .. })
|
|
if branches == &vec![branch]));
|
|
assert_eq!(app.project().elements[&occurrence].parent, Some(branch));
|
|
assert_eq!(
|
|
app.project().elements[&branch].steps().unwrap(),
|
|
&vec![occurrence]
|
|
);
|
|
assert_eq!(app.project().diagrams, diagrams);
|
|
|
|
send(&mut app, Message::Page(Page::Data));
|
|
send(&mut app, Message::CreateKind(Kind::ExchangeItem));
|
|
send(&mut app, Message::Add(false));
|
|
assert_eq!(
|
|
app.project().elements[&app.selected].kind(),
|
|
Kind::ExchangeItem
|
|
);
|
|
assert_eq!(app.project().diagrams, diagrams);
|
|
|
|
send(&mut app, Message::Page(Page::Diagram));
|
|
let active = app.active_diagram.unwrap();
|
|
send(&mut app, Message::CreateKind(Kind::Function));
|
|
send(&mut app, Message::Add(false));
|
|
assert_eq!(
|
|
app.project().diagrams[&active].nodes.len(),
|
|
diagrams[&active].nodes.len() + 1
|
|
);
|
|
assert!(
|
|
app.project().diagrams[&active]
|
|
.nodes
|
|
.values()
|
|
.any(|node| node.element == app.selected)
|
|
);
|
|
for (id, before) in &diagrams {
|
|
if *id != active {
|
|
assert_eq!(&app.project().diagrams[id], before);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner() {
|
|
let mut p = Project::demo();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
|
.unwrap()
|
|
.id;
|
|
let view = oa_core::projection::project_view(&p, diagram);
|
|
let function = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| p.elements[&n.element].kind() == Kind::Function && n.visual_parent.is_some())
|
|
.unwrap();
|
|
let ancestor = function.visual_parent.unwrap();
|
|
let port = p
|
|
.children(function.element)
|
|
.find(|e| e.kind() == Kind::Port)
|
|
.unwrap()
|
|
.id;
|
|
p.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&ancestor)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::RevealInDiagram(diagram, port));
|
|
assert_eq!(app.selected, port);
|
|
assert_eq!(app.active_diagram, Some(diagram));
|
|
assert!(!app.project().diagrams[&diagram].nodes[&ancestor].collapsed);
|
|
assert!(
|
|
oa_core::projection::project_view(app.project(), diagram)
|
|
.ports
|
|
.iter()
|
|
.any(|p| p.element == port)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_from_capabilities_routes_work_to_architecture_and_folded_allocation_inserts_below_existing_work()
|
|
{
|
|
let p = Project::demo();
|
|
let system = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::System)
|
|
.unwrap()
|
|
.id;
|
|
let capabilities = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Capabilities)
|
|
.unwrap()
|
|
.id;
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Layer(Layer::Sa));
|
|
send(
|
|
&mut app,
|
|
Message::Diagram(crate::app::Choice {
|
|
id: capabilities,
|
|
label: String::new(),
|
|
}),
|
|
);
|
|
send(&mut app, Message::CreateKind(Kind::Function));
|
|
send(&mut app, Message::Add(false));
|
|
let diagram = app.active_diagram.unwrap();
|
|
assert_eq!(
|
|
app.project().diagrams[&diagram].projection,
|
|
ProjectionKind::Allocation
|
|
);
|
|
assert!(
|
|
app.project().diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.any(|n| n.element == app.selected)
|
|
);
|
|
send(&mut app, Message::Select(system));
|
|
let owner = app.selected_node.unwrap();
|
|
send(&mut app, Message::ToggleCollapse);
|
|
assert!(app.project().diagrams[&diagram].nodes[&owner].collapsed);
|
|
assert_eq!(
|
|
app.selected, system,
|
|
"Folding must retain semantic selection"
|
|
);
|
|
let before = app.project().clone();
|
|
send(&mut app, Message::Add(true));
|
|
let created = app.selected;
|
|
let view = oa_core::projection::project_view(app.project(), diagram);
|
|
let child = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == created)
|
|
.unwrap_or_else(|| panic!("Created work is not visible: {}", app.status));
|
|
assert_eq!(child.visual_parent, Some(owner));
|
|
assert!(!app.project().diagrams[&diagram].nodes[&owner].collapsed);
|
|
assert!(
|
|
view.nodes
|
|
.iter()
|
|
.filter(|n| n.visual_parent == Some(owner) && n.id != child.id)
|
|
.all(|n| n.position.y + n.size.height <= child.position.y)
|
|
);
|
|
let parent = view.nodes.iter().find(|n| n.id == owner).unwrap();
|
|
assert!(
|
|
view.nodes
|
|
.iter()
|
|
.filter(|n| n.visual_parent.is_none() && n.id != owner)
|
|
.all(|n| n.position.x + n.size.width <= parent.position.x
|
|
|| parent.position.x + parent.size.width <= n.position.x
|
|
|| n.position.y + n.size.height <= parent.position.y
|
|
|| parent.position.y + parent.size.height <= n.position.y)
|
|
);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &before);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state() {
|
|
use oa_core::projection::{project_diagram, project_view};
|
|
for folded in [false, true] {
|
|
let mut p = Project::empty();
|
|
let root = p
|
|
.add_element(Kind::Entity, Layer::Oa, "Root", None)
|
|
.unwrap();
|
|
let a = p
|
|
.add_element(Kind::Entity, Layer::Oa, "A", Some(root))
|
|
.unwrap();
|
|
let b = p
|
|
.add_element(Kind::Entity, Layer::Oa, "B", Some(root))
|
|
.unwrap();
|
|
let other = p
|
|
.add_element(Kind::Entity, Layer::Oa, "Other root", None)
|
|
.unwrap();
|
|
let a_work = p
|
|
.add_element(Kind::Activity, Layer::Oa, "A work", None)
|
|
.unwrap();
|
|
let b_work = p
|
|
.add_element(Kind::Activity, Layer::Oa, "B work", None)
|
|
.unwrap();
|
|
// A preserved OA boundary remains explicit Legacy content in schema3.
|
|
let b_port = Id::new_v4();
|
|
p.elements.insert(
|
|
b_port,
|
|
oa_core::Element {
|
|
id: b_port,
|
|
alias: "b_boundary".into(),
|
|
name: "B boundary".into(),
|
|
description: String::new(),
|
|
parent: Some(b),
|
|
body: ElementBody::shared_in(
|
|
Layer::Oa,
|
|
SharedBody::Port {
|
|
contract: PortContract::Legacy,
|
|
legacy: Some(LegacyPortContract {
|
|
direction: PortDirection::InOut,
|
|
interface: None,
|
|
}),
|
|
},
|
|
),
|
|
},
|
|
);
|
|
p.relate(RelationKind::Allocation, "A performs", a, a_work);
|
|
p.relate(RelationKind::Allocation, "B performs", b, b_work);
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
|
for id in [root, a, b, other, a_work, b_work] {
|
|
p.place(diagram, id, Position { x: 60., y: 60. });
|
|
}
|
|
let mut p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
|
let view = project_view(&p, diagram);
|
|
let root_node = view.nodes.iter().find(|n| n.element == root).unwrap();
|
|
let a_node = view.nodes.iter().find(|n| n.element == a).unwrap();
|
|
let b_node = view.nodes.iter().find(|n| n.element == b).unwrap();
|
|
// Layout's UUID order is intentionally irrelevant to this scenario:
|
|
// explicitly put A before B, as a user can arrange these participants.
|
|
p = execute_batch(
|
|
&p,
|
|
&[
|
|
Command::MoveNode {
|
|
diagram,
|
|
node: a_node.id,
|
|
position: Position {
|
|
x: root_node.position.x + 24.,
|
|
y: root_node.position.y + 64.,
|
|
},
|
|
with_descendants: true,
|
|
},
|
|
Command::MoveNode {
|
|
diagram,
|
|
node: b_node.id,
|
|
position: Position {
|
|
x: root_node.position.x + 24.,
|
|
y: root_node.position.y + 64. + a_node.size.height + 16.,
|
|
},
|
|
with_descendants: true,
|
|
},
|
|
],
|
|
)
|
|
.unwrap();
|
|
let view = project_view(&p, diagram);
|
|
let root_node = view.nodes.iter().find(|n| n.element == root).unwrap();
|
|
let other_node = view.nodes.iter().find(|n| n.element == other).unwrap().id;
|
|
let b_node = view.nodes.iter().find(|n| n.element == b).unwrap().id;
|
|
let b_work_node = view.nodes.iter().find(|n| n.element == b_work).unwrap().id;
|
|
p.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&other_node)
|
|
.unwrap()
|
|
.position = Position {
|
|
x: root_node.position.x,
|
|
y: root_node.position.y + root_node.size.height + 24.,
|
|
};
|
|
p.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&b_node)
|
|
.unwrap()
|
|
.collapsed = folded;
|
|
// Deliberately overlapping unrelated roots must not be rearranged.
|
|
let mut unrelated = Vec::new();
|
|
for name in ["Unrelated one", "Unrelated two"] {
|
|
let id = p.add_element(Kind::Entity, Layer::Oa, name, None).unwrap();
|
|
unrelated.push(p.place(diagram, id, Position { x: 2000., y: 1000. }));
|
|
}
|
|
let before = p.clone();
|
|
let mut expanded = p.diagrams[&diagram].clone();
|
|
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
|
|
let before_view = project_diagram(&p, &expanded);
|
|
let before_port = before_view
|
|
.ports
|
|
.iter()
|
|
.find(|p| p.element == b_port)
|
|
.unwrap()
|
|
.position;
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(a));
|
|
send(&mut app, Message::CreateKind(Kind::Activity));
|
|
send(&mut app, Message::Add(true));
|
|
let created = app.selected;
|
|
assert_eq!(
|
|
app.project().elements[&created].kind(),
|
|
Kind::Activity,
|
|
"{}",
|
|
app.status
|
|
);
|
|
let after = &app.project().diagrams[&diagram];
|
|
assert_eq!(after.nodes[&b_node].collapsed, folded);
|
|
let dy =
|
|
after.nodes[&b_node].position.y - before.diagrams[&diagram].nodes[&b_node].position.y;
|
|
assert!(dy > 0., "B must move below the enlarged A");
|
|
assert_eq!(
|
|
after.nodes[&b_work_node].position.y,
|
|
before.diagrams[&diagram].nodes[&b_work_node].position.y + dy
|
|
);
|
|
assert!(
|
|
after.nodes[&other_node].position.y
|
|
> before.diagrams[&diagram].nodes[&other_node].position.y
|
|
);
|
|
for id in unrelated {
|
|
assert_eq!(after.nodes[&id], before.diagrams[&diagram].nodes[&id]);
|
|
}
|
|
let mut expanded = after.clone();
|
|
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
|
|
let view = project_diagram(app.project(), &expanded);
|
|
let after_port = view
|
|
.ports
|
|
.iter()
|
|
.find(|p| p.element == b_port)
|
|
.unwrap()
|
|
.position;
|
|
assert_eq!(
|
|
after_port,
|
|
Position {
|
|
x: before_port.x,
|
|
y: before_port.y + dy
|
|
}
|
|
);
|
|
for (left, right) in [(a, b), (root, other), (a_work, created)] {
|
|
let left = view.nodes.iter().find(|n| n.element == left).unwrap();
|
|
let right = view.nodes.iter().find(|n| n.element == right).unwrap();
|
|
assert!(left.position.y + left.size.height <= right.position.y);
|
|
}
|
|
assert!(crate::diagram::insertion_adjustments(app.project(), diagram, created).is_empty());
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(
|
|
app.project(),
|
|
&before,
|
|
"Creation and all reflow must undo together"
|
|
);
|
|
|
|
if folded {
|
|
send(&mut app, Message::Select(root));
|
|
send(&mut app, Message::CreateKind(Kind::Entity));
|
|
send(&mut app, Message::Add(true));
|
|
let mut expanded = app.project().diagrams[&diagram].clone();
|
|
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
|
|
let view = project_diagram(app.project(), &expanded);
|
|
let b = view.nodes.iter().find(|n| n.id == b_node).unwrap();
|
|
let created = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == app.selected)
|
|
.unwrap();
|
|
assert!(b.position.y + b.size.height <= created.position.y);
|
|
assert!(app.project().diagrams[&diagram].nodes[&b_node].collapsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn routing_actions_are_atomic_guarded_and_fit_the_authored_detour() {
|
|
use crate::route_edit::Event as Route;
|
|
let mut p = Project::empty();
|
|
let sender = p.add(Kind::Activity, "Report", 0., 0.);
|
|
let target = p.add(Kind::Activity, "Receive", 600., 0.);
|
|
let edge = p.relate(RelationKind::Interaction, "Report signal", sender, target);
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let mut app = App::with_project(p.clone());
|
|
send(&mut app, Message::Select(edge));
|
|
send(&mut app, Message::Route(Route::AddBend));
|
|
assert_eq!(
|
|
app.project().diagrams[&diagram].routes[0].waypoints.len(),
|
|
1
|
|
);
|
|
let context = app.route_context().unwrap();
|
|
let mut route = app.project().diagrams[&diagram].routes[0].clone();
|
|
route.waypoints[0] = Position { x: 400., y: -700. };
|
|
send(
|
|
&mut app,
|
|
Message::Route(Route::Commit(context, route.clone(), Some(0))),
|
|
);
|
|
assert_eq!(app.project().diagrams[&diagram].routes[0], route);
|
|
send(&mut app, Message::Fit);
|
|
let pin = route.waypoints[0];
|
|
assert!(pin.y * app.zoom + app.pan.y >= 0.);
|
|
send(&mut app, Message::Route(Route::RemoveBend));
|
|
assert!(
|
|
app.project().diagrams[&diagram].routes[0]
|
|
.waypoints
|
|
.is_empty()
|
|
);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project().diagrams[&diagram].routes[0], route);
|
|
let accepted = app.project().clone();
|
|
send(
|
|
&mut app,
|
|
Message::Route(Route::Commit(context, route.clone(), Some(0))),
|
|
);
|
|
assert_eq!(
|
|
app.project(),
|
|
&accepted,
|
|
"A stale gesture must not overwrite newer routing"
|
|
);
|
|
let current = app.route_context().unwrap();
|
|
send(&mut app, Message::Escape);
|
|
send(
|
|
&mut app,
|
|
Message::Route(Route::Commit(current, route.clone(), Some(0))),
|
|
);
|
|
assert_eq!(
|
|
app.project(),
|
|
&accepted,
|
|
"Escape invalidates the gesture epoch"
|
|
);
|
|
send(&mut app, Message::Route(Route::Reset));
|
|
assert!(app.project().diagrams[&diagram].routes.is_empty());
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &accepted);
|
|
let current = app.route_context().unwrap();
|
|
source(&mut app, "invalid unsaved source");
|
|
send(
|
|
&mut app,
|
|
Message::Route(Route::Commit(current, route, Some(0))),
|
|
);
|
|
assert_eq!(app.project(), &accepted);
|
|
assert_eq!(app.source_editor.text(), "invalid unsaved source");
|
|
}
|
|
|
|
#[test]
|
|
fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
|
|
let mut p = Project::empty();
|
|
let diagram = p.ensure_diagram(Layer::Sa);
|
|
let a = p
|
|
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
|
.unwrap();
|
|
let b = p
|
|
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
|
.unwrap();
|
|
let edge = p.relate(RelationKind::FunctionalExchange, "Status", a, b);
|
|
p.place(diagram, a, Position { x: 0., y: 100. });
|
|
p.place(diagram, b, Position { x: 260., y: 100. });
|
|
let mut app = App::with_project(p);
|
|
send(&mut app, Message::Select(edge));
|
|
send(&mut app, Message::Route(crate::route_edit::Event::AddBend));
|
|
let p = app.project();
|
|
let view = projection::project_view(p, diagram);
|
|
let routed = routing::route_diagram(p, &p.diagrams[&diagram], &view);
|
|
assert!(
|
|
routed.edges[&edge].warnings.is_empty(),
|
|
"{:?}",
|
|
routed.edges[&edge].warnings
|
|
);
|
|
assert_eq!(
|
|
p.diagrams[&diagram].routes[0].waypoints,
|
|
vec![Position {
|
|
x: 242.,
|
|
y: view
|
|
.ports
|
|
.iter()
|
|
.find(|port| p.elements[&port.element].parent == Some(a))
|
|
.unwrap()
|
|
.position
|
|
.y
|
|
}]
|
|
);
|
|
}
|