1125 lines
42 KiB
Rust
1125 lines
42 KiB
Rust
//! Process selection through the real App admission, document and source boundary.
|
|
use super::*;
|
|
use crate::behavior::topology::group_reorder::Destination;
|
|
use crate::behavior::topology::{self, CanvasEvent, CanvasTool};
|
|
use oa_core::OccurrenceEndpoint;
|
|
|
|
struct Fixture {
|
|
app: App,
|
|
owner: Id,
|
|
nodes: [Id; 5],
|
|
link: Id,
|
|
work: Id,
|
|
other_owner: Id,
|
|
}
|
|
|
|
fn local(id: Id) -> Target {
|
|
Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![],
|
|
occurrence: id,
|
|
})
|
|
}
|
|
|
|
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
|
let mut element = project
|
|
.make_element(
|
|
Kind::Occurrence,
|
|
project.elements[&owner].layer(),
|
|
name,
|
|
Some(owner),
|
|
)
|
|
.unwrap();
|
|
let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else {
|
|
unreachable!()
|
|
};
|
|
*value = body;
|
|
let id = element.id;
|
|
*project = execute_batch(project, &[Command::CreateElement(element)]).unwrap();
|
|
id
|
|
}
|
|
|
|
fn open(project: Project, owner: Id) -> App {
|
|
let mut app = App::with_project(project);
|
|
let _ = app.update(Message::Select(owner));
|
|
let _ = app.update(Message::Page(Page::Behavior));
|
|
app.reconcile_multiselect();
|
|
assert_eq!(app.topology_context().unwrap().owner, owner);
|
|
assert_eq!(
|
|
app.multiselect_context().unwrap().scope.surface,
|
|
Surface::Process(owner)
|
|
);
|
|
app
|
|
}
|
|
|
|
fn fixture(layer: Layer) -> Fixture {
|
|
let (f, _) = topology::direct_tests::empty_exchange_fixture(layer);
|
|
let mut project = f.p;
|
|
let mut nodes = vec![];
|
|
for name in ["a", "u", "b", "v", "c"] {
|
|
nodes.push(add(
|
|
&mut project,
|
|
f.owner,
|
|
name,
|
|
OccurrenceBody::Action {
|
|
action: Some(f.work[0]),
|
|
lifeline: None,
|
|
},
|
|
));
|
|
}
|
|
let nodes: [Id; 5] = nodes.try_into().unwrap();
|
|
let [a, u, b, v, _] = nodes;
|
|
let link = project.relate(RelationKind::SequenceLink, "a to u", a, u);
|
|
project.relate(RelationKind::SequenceLink, "b to v", b, v);
|
|
let other = project
|
|
.make_element(
|
|
if layer == Layer::Oa {
|
|
Kind::Process
|
|
} else {
|
|
Kind::FunctionalChain
|
|
},
|
|
layer,
|
|
"Other owner",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let other_owner = other.id;
|
|
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
|
|
validate_candidate(&project).unwrap();
|
|
Fixture {
|
|
app: open(project, f.owner),
|
|
owner: f.owner,
|
|
nodes,
|
|
link,
|
|
work: f.work[0],
|
|
other_owner,
|
|
}
|
|
}
|
|
|
|
fn send(app: &mut App, event: Event) {
|
|
let _ = app.update(Message::DiagramSelection(event));
|
|
}
|
|
fn choose(app: &mut App, target: Target, choice: Choice) {
|
|
let context = app.multiselect_context().unwrap();
|
|
send(app, Event::Choose(context, target, choice));
|
|
}
|
|
fn pair(f: &mut Fixture) {
|
|
choose(&mut f.app, local(f.nodes[4]), Choice::Replace);
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().targets(),
|
|
&[local(f.nodes[4]), local(f.nodes[0])]
|
|
);
|
|
}
|
|
fn source_matches(app: &App) {
|
|
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
|
assert_eq!(&compiled.decoded.project, app.project());
|
|
assert_eq!(&compiled.bundle, app.document.bundle());
|
|
assert_eq!(
|
|
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
|
*app.document.bundle()
|
|
);
|
|
}
|
|
fn marker(app: &mut App, owner: Id, occurrence: Id) {
|
|
use crate::diagram_usage as usage;
|
|
let target = usage::collect(
|
|
app.project(),
|
|
occurrence,
|
|
&app.behavior.topology,
|
|
&app.behavior.scenario,
|
|
)
|
|
.into_iter()
|
|
.find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence))
|
|
.unwrap();
|
|
let context = usage::Context {
|
|
document: app.canvas_document,
|
|
revision: app.document.revision(),
|
|
subject: occurrence,
|
|
};
|
|
app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch));
|
|
app.diagram_navigation_focus = Some(usage::Focus {
|
|
context,
|
|
usage: target,
|
|
selected: app.selected,
|
|
});
|
|
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn completed_text_apply_rejects_captured_process_reorder_without_retiring_current_state() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
pair(&mut f);
|
|
let stale = f.app.multiselect_snapshot().unwrap();
|
|
let before = f.app.document.snapshot();
|
|
assert!(!f.app.document.can_undo());
|
|
let element = f.nodes[0];
|
|
let location = f.app.document.compiled().index.declarations[&element].clone();
|
|
let original = &before.bundle.files[&location.path];
|
|
let quoted_name = format!("\"{}\"", f.app.project().elements[&element].name);
|
|
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
|
|
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
|
|
let mut expected = before.decoded.project.clone();
|
|
expected.elements.get_mut(&element).unwrap().name = "Changed through Text".into();
|
|
let mut expected_bundle = before.bundle.clone();
|
|
expected_bundle
|
|
.files
|
|
.insert(location.path.clone(), edited.clone());
|
|
|
|
let _ = f.app.update(Message::Page(Page::Text));
|
|
let _ = f.app.update(Message::SourceFile(location.path));
|
|
let _ = f
|
|
.app
|
|
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
|
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
|
text_editor::Edit::Paste(Arc::new(edited.clone())),
|
|
)));
|
|
assert!(f.app.source_dirty());
|
|
assert_eq!(f.app.source_editor.text(), edited);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
let _ = f.app.update(Message::ApplySource);
|
|
assert!(!f.app.source_dirty(), "{}", f.app.status);
|
|
assert_eq!(f.app.project(), &expected);
|
|
assert_eq!(f.app.document.bundle(), &expected_bundle);
|
|
source_matches(&f.app);
|
|
|
|
let _ = f.app.update(Message::Page(Page::Behavior));
|
|
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
|
pair(&mut f);
|
|
let current = f.app.multiselect_snapshot().unwrap();
|
|
assert_eq!(current.targets(), stale.targets());
|
|
assert_eq!(current.primary(), stale.primary());
|
|
assert_ne!(current.context(), stale.context());
|
|
let destination = Destination::After(f.nodes[2]);
|
|
assert!(
|
|
matches!(
|
|
Prepared::new(f.app.project(), f.owner, current.targets())
|
|
.unwrap()
|
|
.plan(destination)
|
|
.unwrap(),
|
|
Plan::Change(_)
|
|
),
|
|
"the replay must be a real reorder, not a no-op"
|
|
);
|
|
assert!(
|
|
f.app
|
|
.multiselect_envelope(&Event::ProcessReorder(current.clone(), destination))
|
|
);
|
|
let accepted = f.app.document.snapshot();
|
|
let revision = f.app.document.revision();
|
|
let workspace = f.app.source_workspace.bundle();
|
|
let editor = f.app.source_editor.text();
|
|
let selected = (
|
|
f.app.selected,
|
|
f.app.selected_node,
|
|
f.app.selected_bend,
|
|
f.app.selected_attachment,
|
|
);
|
|
let camera = (f.app.zoom, f.app.pan, f.app.fit_to_view, f.app.canvas_size);
|
|
let process_camera = f.app.behavior.topology.camera_boundary();
|
|
let epochs = (
|
|
f.app.canvas_epoch,
|
|
f.app.camera_epoch,
|
|
f.app.allocation_drag.epoch,
|
|
);
|
|
let draft = f
|
|
.app
|
|
.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
|
let form = f.app.behavior.recovery_snapshot();
|
|
let status = f.app.status.clone();
|
|
let pending_reveal = f.app.pending_diagram_reveal.clone();
|
|
|
|
send(&mut f.app, Event::ProcessReorder(stale, destination));
|
|
assert!(Arc::ptr_eq(&accepted, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.project(), &expected);
|
|
assert_eq!(f.app.document.bundle(), &expected_bundle);
|
|
assert_eq!(f.app.document.revision(), revision);
|
|
assert_eq!(f.app.source_workspace.bundle(), workspace);
|
|
assert_eq!(f.app.source_editor.text(), editor);
|
|
assert_eq!(f.app.multiselect_snapshot(), Some(current));
|
|
assert_eq!(
|
|
(
|
|
f.app.selected,
|
|
f.app.selected_node,
|
|
f.app.selected_bend,
|
|
f.app.selected_attachment
|
|
),
|
|
selected
|
|
);
|
|
assert_eq!(
|
|
(f.app.zoom, f.app.pan, f.app.fit_to_view, f.app.canvas_size),
|
|
camera
|
|
);
|
|
assert!(f.app.behavior.topology.camera_boundary() == process_camera);
|
|
assert_eq!(
|
|
(
|
|
f.app.canvas_epoch,
|
|
f.app.camera_epoch,
|
|
f.app.allocation_drag.epoch
|
|
),
|
|
epochs
|
|
);
|
|
assert_eq!(
|
|
f.app
|
|
.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
|
draft
|
|
);
|
|
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
|
assert_eq!(f.app.pending_diagram_reveal, pending_reveal);
|
|
assert_eq!(f.app.status, status);
|
|
assert!(!f.app.source_dirty());
|
|
assert!(f.app.document.can_undo());
|
|
assert!(!f.app.document.can_redo());
|
|
|
|
let _ = f.app.update(Message::Undo);
|
|
assert_eq!(f.app.project(), &before.decoded.project);
|
|
assert_eq!(f.app.document.bundle(), &before.bundle);
|
|
assert!(!f.app.document.can_undo());
|
|
assert!(f.app.document.can_redo());
|
|
source_matches(&f.app);
|
|
let _ = f.app.update(Message::Redo);
|
|
assert_eq!(f.app.project(), &expected);
|
|
assert_eq!(f.app.document.bundle(), &expected_bundle);
|
|
assert!(f.app.document.can_undo());
|
|
assert!(!f.app.document.can_redo());
|
|
source_matches(&f.app);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let [a, u, b, v, c] = f.nodes;
|
|
pair(&mut f);
|
|
let before = f.app.document.snapshot();
|
|
let selection = f.app.multiselect_snapshot().unwrap();
|
|
assert_eq!(selection.primary(), Some(&local(a)));
|
|
send(
|
|
&mut f.app,
|
|
Event::ProcessReorder(selection.clone(), Destination::After(b)),
|
|
);
|
|
let mut expected = before.decoded.project.clone();
|
|
*expected
|
|
.elements
|
|
.get_mut(&f.owner)
|
|
.unwrap()
|
|
.steps_mut()
|
|
.unwrap() = vec![b, u, a, v, c];
|
|
assert_eq!(f.app.project(), &expected);
|
|
assert_eq!(f.app.selected, a);
|
|
let after_selection = f.app.multiselect_snapshot().unwrap();
|
|
assert_eq!(after_selection.targets(), selection.targets());
|
|
assert_eq!(after_selection.primary(), selection.primary());
|
|
source_matches(&f.app);
|
|
let after = f.app.document.snapshot();
|
|
let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap();
|
|
assert_eq!(reopened.project(), &expected);
|
|
let _ = f.app.update(Message::Undo);
|
|
assert_eq!(f.app.project(), &before.decoded.project);
|
|
assert_eq!(f.app.document.bundle(), &before.bundle);
|
|
assert!(!f.app.document.can_undo());
|
|
source_matches(&f.app);
|
|
let _ = f.app.update(Message::Redo);
|
|
assert_eq!(f.app.project(), &expected);
|
|
assert_eq!(f.app.document.bundle(), &after.bundle);
|
|
assert!(!f.app.document.can_redo());
|
|
source_matches(&f.app);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() {
|
|
for group in [true, false] {
|
|
let mut f = fixture(Layer::Oa);
|
|
if group {
|
|
pair(&mut f);
|
|
} else {
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
|
}
|
|
marker(&mut f.app, f.owner, f.nodes[0]);
|
|
let before = f.app.document.snapshot();
|
|
let selection = f.app.multiselect_snapshot().unwrap();
|
|
let pending = f.app.pending_diagram_reveal.clone();
|
|
let camera = f.app.behavior.topology.camera_boundary();
|
|
let epochs = (f.app.canvas_epoch, f.app.camera_epoch);
|
|
f.app.status = "Unchanged order feedback".into();
|
|
let message = if group {
|
|
Message::DiagramSelection(Event::ProcessReorder(
|
|
selection.clone(),
|
|
Destination::Before(f.nodes[4]),
|
|
))
|
|
} else {
|
|
Message::TopologyCanvas(CanvasEvent::Reorder {
|
|
context: f.app.topology_context().unwrap(),
|
|
occurrence: f.nodes[0],
|
|
before: f.nodes[2],
|
|
})
|
|
};
|
|
let _ = f.app.update(message);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
|
assert_eq!(f.app.pending_diagram_reveal, pending);
|
|
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
|
assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs);
|
|
assert_eq!(f.app.status, "Unchanged order feedback");
|
|
assert!(!f.app.document.can_undo());
|
|
assert!(!f.app.document.can_redo());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
for mixed in [true, false] {
|
|
let mut f = fixture(layer);
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
|
let other = if mixed {
|
|
Target::ProcessLink(f.link)
|
|
} else {
|
|
local(f.nodes[1])
|
|
};
|
|
choose(&mut f.app, other.clone(), Choice::Add);
|
|
let selection = f.app.multiselect_snapshot().unwrap();
|
|
assert_eq!(selection.targets(), &[local(f.nodes[0]), other]);
|
|
let before = f.app.document.snapshot();
|
|
send(
|
|
&mut f.app,
|
|
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
|
);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
|
assert!(!f.app.document.can_undo());
|
|
source_matches(&f.app);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() {
|
|
for stale_by in ["document", "revision", "owner", "epoch", "ABA"] {
|
|
let mut f = fixture(Layer::Oa);
|
|
pair(&mut f);
|
|
let stale = f.app.multiselect_snapshot().unwrap();
|
|
match stale_by {
|
|
"document" => f.app.canvas_document = Id::new_v4(),
|
|
"revision" => {
|
|
f.app
|
|
.document
|
|
.execute(Command::Describe(f.work, "New revision".into()))
|
|
.unwrap();
|
|
}
|
|
"owner" => {
|
|
f.app.behavior.active_owner = Some(f.other_owner);
|
|
f.app.selected = f.other_owner;
|
|
f.app.reconcile_multiselect();
|
|
}
|
|
"epoch" => f.app.canvas_epoch += 1,
|
|
_ => {
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().targets(),
|
|
stale.targets()
|
|
);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().primary(),
|
|
stale.primary()
|
|
);
|
|
}
|
|
}
|
|
let before = f.app.document.snapshot();
|
|
let selection = f.app.multiselect_snapshot();
|
|
let camera = f.app.behavior.topology.camera_boundary();
|
|
let selected = f.app.selected;
|
|
let epochs = (
|
|
f.app.canvas_epoch,
|
|
f.app.camera_epoch,
|
|
f.app.allocation_drag.epoch,
|
|
);
|
|
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
|
f.app.status = "Current owner feedback".into();
|
|
f.app.allocation_drag.arrange_next = true;
|
|
send(
|
|
&mut f.app,
|
|
Event::ProcessReorder(stale, Destination::After(f.nodes[2])),
|
|
);
|
|
assert!(
|
|
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
|
"{stale_by}"
|
|
);
|
|
assert_eq!(f.app.multiselect_snapshot(), selection);
|
|
assert_eq!(f.app.selected, selected);
|
|
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
|
assert_eq!(
|
|
(
|
|
f.app.canvas_epoch,
|
|
f.app.camera_epoch,
|
|
f.app.allocation_drag.epoch
|
|
),
|
|
epochs
|
|
);
|
|
assert_eq!(
|
|
(f.app.document.can_undo(), f.app.document.can_redo()),
|
|
history
|
|
);
|
|
assert_eq!(f.app.status, "Current owner feedback");
|
|
assert!(f.app.allocation_drag.arrange_next);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() {
|
|
for pending in [
|
|
"properties",
|
|
"invalid properties",
|
|
"source",
|
|
"form",
|
|
"tool",
|
|
"busy",
|
|
] {
|
|
let mut f = fixture(Layer::Oa);
|
|
pair(&mut f);
|
|
match pending {
|
|
"properties" => {
|
|
let _ = f.app.update(Message::Field(
|
|
"/name".into(),
|
|
"Unapplied contribution".into(),
|
|
));
|
|
}
|
|
"invalid properties" => {
|
|
let _ = f
|
|
.app
|
|
.update(Message::Field("/alias".into(), "invalid alias!".into()));
|
|
}
|
|
"source" => {
|
|
let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text());
|
|
let _ = f
|
|
.app
|
|
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
|
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
|
text_editor::Edit::Paste(Arc::new(text)),
|
|
)));
|
|
}
|
|
"form" => {
|
|
let context = f.app.topology_context().unwrap();
|
|
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
|
context,
|
|
tool: topology::Tool::Work,
|
|
control: ControlKind::Choice,
|
|
insertion: topology::Insertion::default(),
|
|
}));
|
|
let _ = f.app.update(Message::Behavior(behavior::Event::Topology(
|
|
topology::Event::Name("Unsubmitted form".into()),
|
|
)));
|
|
assert!(f.app.behavior.has_pending());
|
|
}
|
|
"tool" => f.app.behavior.topology.choose(CanvasTool::Connect),
|
|
_ => f.app.busy = true,
|
|
}
|
|
if pending == "source" {
|
|
assert!(f.app.source_dirty());
|
|
}
|
|
if pending.contains("properties") {
|
|
assert!(f.app.draft_dirty());
|
|
}
|
|
let selection = f.app.multiselect_snapshot().unwrap();
|
|
let before = f.app.document.snapshot();
|
|
let source = f.app.source_workspace.bundle();
|
|
let draft = f
|
|
.app
|
|
.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
|
let form = f.app.behavior.recovery_snapshot();
|
|
for event in [
|
|
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
|
Event::Clear(selection.context()),
|
|
Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace),
|
|
] {
|
|
send(&mut f.app, event);
|
|
assert!(
|
|
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
|
"{pending}"
|
|
);
|
|
assert_eq!(f.app.source_workspace.bundle(), source);
|
|
assert_eq!(
|
|
f.app
|
|
.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
|
draft
|
|
);
|
|
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
|
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
|
assert!(!f.app.document.can_undo());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() {
|
|
let mut f = fixture(Layer::Oa);
|
|
pair(&mut f);
|
|
marker(&mut f.app, f.owner, f.nodes[0]);
|
|
let context = f.app.topology_context().unwrap();
|
|
let before = f.app.document.snapshot();
|
|
let selection = f.app.multiselect_snapshot().unwrap();
|
|
let pending = f.app.pending_diagram_reveal.clone();
|
|
let camera = f.app.behavior.topology.camera_boundary();
|
|
let epochs = (
|
|
f.app.canvas_epoch,
|
|
f.app.camera_epoch,
|
|
f.app.allocation_drag.epoch,
|
|
);
|
|
f.app.status = "Group owns input".into();
|
|
for event in [
|
|
CanvasEvent::Select(context, f.nodes[2]),
|
|
CanvasEvent::SelectQualified(
|
|
context,
|
|
OccurrenceEndpoint {
|
|
references: vec![],
|
|
occurrence: f.nodes[2],
|
|
},
|
|
),
|
|
CanvasEvent::Remove(context, f.nodes[0]),
|
|
CanvasEvent::Reorder {
|
|
context,
|
|
occurrence: f.nodes[0],
|
|
before: f.nodes[4],
|
|
},
|
|
CanvasEvent::Reconnect {
|
|
context,
|
|
link: f.link,
|
|
end: topology::End::Target,
|
|
target: f.nodes[4],
|
|
},
|
|
] {
|
|
let _ = f.app.update(Message::TopologyCanvas(event));
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
|
assert_eq!(f.app.pending_diagram_reveal, pending);
|
|
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
|
assert_eq!(
|
|
(
|
|
f.app.canvas_epoch,
|
|
f.app.camera_epoch,
|
|
f.app.allocation_drag.epoch
|
|
),
|
|
epochs
|
|
);
|
|
assert_eq!(f.app.status, "Group owns input");
|
|
assert!(!f.app.delete_pending);
|
|
assert!(!f.app.document.can_undo());
|
|
}
|
|
}
|
|
|
|
fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) {
|
|
let mut f = fixture(Layer::Oa);
|
|
let mut project = f.app.project().clone();
|
|
let sequence = project
|
|
.make_element(Kind::Process, Layer::Oa, "Reusable checks", None)
|
|
.unwrap();
|
|
let sequence_id = sequence.id;
|
|
project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap();
|
|
// Collapsed reuse cards still show four preview entries. Keep the tested
|
|
// leaf beyond that bound so collapsing really removes its exact target.
|
|
for index in 0..4 {
|
|
add(
|
|
&mut project,
|
|
sequence_id,
|
|
&format!("Preview prefix {index}"),
|
|
OccurrenceBody::Action {
|
|
action: Some(f.work),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
}
|
|
let leaf = add(
|
|
&mut project,
|
|
sequence_id,
|
|
"Shared check",
|
|
OccurrenceBody::Action {
|
|
action: Some(f.work),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let first = add(
|
|
&mut project,
|
|
f.owner,
|
|
"First call",
|
|
OccurrenceBody::SequenceReference {
|
|
sequence: Some(sequence_id),
|
|
},
|
|
);
|
|
let second = add(
|
|
&mut project,
|
|
f.owner,
|
|
"Second call",
|
|
OccurrenceBody::SequenceReference {
|
|
sequence: Some(sequence_id),
|
|
},
|
|
);
|
|
f.app = open(project, f.owner);
|
|
let p = f.app.project().clone();
|
|
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
|
assert!(f.app.behavior.topology.expand(&p, f.owner, second));
|
|
(f, sequence_id, first, second, leaf)
|
|
}
|
|
|
|
#[test]
|
|
fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() {
|
|
let (mut f, _, first, second, leaf) = qualified_fixture();
|
|
let p = f.app.project().clone();
|
|
let endpoint = OccurrenceEndpoint {
|
|
references: vec![first],
|
|
occurrence: leaf,
|
|
};
|
|
let target = Target::ProcessOccurrence(endpoint.clone());
|
|
assert!(f.app.multiselect_target_visible(&target));
|
|
choose(&mut f.app, target.clone(), Choice::Replace);
|
|
assert_eq!(
|
|
f.app.selected, leaf,
|
|
"Inspect exact leaf, not the first caller or shared work"
|
|
);
|
|
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
|
assert_eq!(
|
|
f.app
|
|
.behavior
|
|
.topology
|
|
.selection_address()
|
|
.unwrap()
|
|
.endpoint,
|
|
endpoint
|
|
);
|
|
assert!(f.app.draft.is_none());
|
|
let before = f.app.document.snapshot();
|
|
for message in [
|
|
Message::Field("/name".into(), "Forbidden definition rename".into()),
|
|
Message::Apply,
|
|
Message::Delete,
|
|
] {
|
|
let _ = f.app.update(message);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert!(f.app.draft.is_none());
|
|
assert!(!f.app.delete_pending);
|
|
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
|
}
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
|
let mixed = f.app.multiselect_snapshot().unwrap();
|
|
send(
|
|
&mut f.app,
|
|
Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])),
|
|
);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed);
|
|
choose(&mut f.app, target.clone(), Choice::Replace);
|
|
let current = f.app.multiselect_context().unwrap();
|
|
send(&mut f.app, Event::Clear(current));
|
|
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
|
assert_eq!(f.app.selected, f.owner);
|
|
assert!(f.app.behavior.topology.selection_address().is_none());
|
|
choose(&mut f.app, target, Choice::Replace);
|
|
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
|
f.app.reconcile_multiselect();
|
|
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
|
assert_eq!(f.app.selected, f.owner);
|
|
assert!(f.app.behavior.topology.selection_address().is_none());
|
|
assert!(f.app.process_preview_primary().is_none());
|
|
assert!(
|
|
f.app
|
|
.multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![second],
|
|
occurrence: leaf
|
|
}))
|
|
);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
}
|
|
|
|
fn menu(app: &mut App, target: Target, label: &str) -> Message {
|
|
use crate::context_menu::Entry;
|
|
use crate::model_menu;
|
|
let object = match &target {
|
|
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
|
_ => panic!("This fixture opens contribution menus"),
|
|
};
|
|
let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.));
|
|
request.process = app.topology_context().map(Box::new);
|
|
request.selection_target = Some(Box::new(target));
|
|
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
|
|
let opened = app
|
|
.model_menu
|
|
.as_ref()
|
|
.expect("Captured Process menu must survive the open epoch change");
|
|
assert!(opened.valid(app));
|
|
assert_eq!(
|
|
opened.request.process.as_deref().copied(),
|
|
app.topology_context()
|
|
);
|
|
model_menu::popup(app)
|
|
.unwrap()
|
|
.entries
|
|
.into_iter()
|
|
.find_map(|entry| match entry {
|
|
Entry::Action {
|
|
label: name,
|
|
message: Some(message),
|
|
..
|
|
} if name == label => Some(message),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_else(|| panic!("Missing enabled menu action: {label}"))
|
|
}
|
|
|
|
#[test]
|
|
fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() {
|
|
let mut f = fixture(Layer::Oa);
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
|
let before = f.app.document.snapshot();
|
|
let old_context = f.app.topology_context().unwrap();
|
|
let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection");
|
|
assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch);
|
|
assert_eq!(f.app.selected, f.nodes[0]);
|
|
let _ = f.app.update(action);
|
|
let selection = f.app.multiselect_snapshot().unwrap();
|
|
assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]);
|
|
assert_eq!(selection.primary(), Some(&local(f.nodes[4])));
|
|
assert_eq!(f.app.selected, f.nodes[4]);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert!(!f.app.document.can_undo());
|
|
}
|
|
|
|
#[test]
|
|
fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() {
|
|
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
|
let before = f.app.document.snapshot();
|
|
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![first],
|
|
occurrence: leaf,
|
|
});
|
|
let action = menu(&mut f.app, target, "Open referenced definition");
|
|
assert_eq!(f.app.selected, f.nodes[0]);
|
|
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
|
let _ = f.app.update(action);
|
|
assert_eq!(f.app.behavior.active_owner, Some(definition));
|
|
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
|
let context = f.app.topology_context().unwrap();
|
|
let _ = f
|
|
.app
|
|
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
|
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
|
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert!(!f.app.document.can_undo());
|
|
}
|
|
|
|
#[test]
|
|
fn process_qualified_definition_back_restores_exact_invocation_readonly_subject_and_camera() {
|
|
for open_nonprimary in [false, true] {
|
|
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
|
// The shared fixture has already expanded both fifth-tile invocations.
|
|
let context = f.app.topology_context().unwrap();
|
|
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
|
context,
|
|
Some(0.75),
|
|
)));
|
|
let first_target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![first],
|
|
occurrence: leaf,
|
|
});
|
|
let second_target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![second],
|
|
occurrence: leaf,
|
|
});
|
|
if open_nonprimary {
|
|
// Keep the requested second path outside the current selection so
|
|
// its ordinary definition action is available in its context menu.
|
|
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
|
choose(&mut f.app, first_target.clone(), Choice::Add);
|
|
} else {
|
|
choose(&mut f.app, first_target.clone(), Choice::Replace);
|
|
choose(&mut f.app, second_target.clone(), Choice::Add);
|
|
}
|
|
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().primary(),
|
|
Some(if open_nonprimary {
|
|
&first_target
|
|
} else {
|
|
&second_target
|
|
})
|
|
);
|
|
assert_eq!(f.app.selected, leaf);
|
|
assert!(f.app.draft.is_none());
|
|
let original = f.app.document.snapshot();
|
|
let source = f.app.document.bundle().clone();
|
|
let camera = f.app.behavior.topology.camera_boundary();
|
|
let target = second_target;
|
|
let action = if open_nonprimary {
|
|
menu(&mut f.app, target.clone(), "Open referenced definition")
|
|
} else {
|
|
let Target::ProcessOccurrence(endpoint) = &target else {
|
|
unreachable!()
|
|
};
|
|
Message::TopologyCanvas(CanvasEvent::OpenQualified(
|
|
f.app.topology_context().unwrap(),
|
|
endpoint.clone(),
|
|
))
|
|
};
|
|
let _ = f.app.update(action);
|
|
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
|
assert_eq!(f.app.selected, leaf);
|
|
assert!(f.app.process_preview_primary().is_none());
|
|
assert!(f.app.draft.is_some(), "Definition view is editable");
|
|
let context = f.app.topology_context().unwrap();
|
|
let _ = f
|
|
.app
|
|
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
|
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
|
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().targets(),
|
|
std::slice::from_ref(&target)
|
|
);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().primary(),
|
|
Some(&target)
|
|
);
|
|
assert_eq!(
|
|
f.app.selected, leaf,
|
|
"Back must not inspect the outer reference"
|
|
);
|
|
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
|
let Target::ProcessOccurrence(endpoint) = &target else {
|
|
unreachable!()
|
|
};
|
|
let address = f.app.behavior.topology.selection_address().unwrap();
|
|
assert_eq!(address.owner, f.owner);
|
|
assert_eq!(&address.endpoint, endpoint);
|
|
assert!(
|
|
f.app.multiselect_target_visible(&target),
|
|
"Caller expansions survive Back"
|
|
);
|
|
assert!(
|
|
f.app.draft.is_none(),
|
|
"Returned qualified inspector stays read-only"
|
|
);
|
|
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
|
let _ = f.app.update(Message::Field(
|
|
"/name".into(),
|
|
"Must not edit the outer reference or definition".into(),
|
|
));
|
|
assert!(f.app.draft.is_none());
|
|
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
|
assert!(Arc::ptr_eq(&original, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.document.bundle(), &source);
|
|
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
|
source_matches(&f.app);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() {
|
|
for catalog in [false, true] {
|
|
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
|
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![first],
|
|
occurrence: leaf,
|
|
});
|
|
choose(&mut f.app, target.clone(), Choice::Replace);
|
|
assert_eq!(f.app.selected, leaf);
|
|
assert!(f.app.draft.is_none());
|
|
let before = f.app.document.snapshot();
|
|
if catalog {
|
|
let _ = f.app.update(Message::Page(Page::Catalog));
|
|
assert_eq!(f.app.page, Page::Catalog);
|
|
} else {
|
|
let action = menu(&mut f.app, target, "Open referenced definition");
|
|
let _ = f.app.update(action);
|
|
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
|
}
|
|
assert_eq!(f.app.selected, leaf);
|
|
assert!(f.app.process_preview_primary().is_none());
|
|
assert!(
|
|
f.app.draft.is_some(),
|
|
"Leaving a read-only presentation restores normal property editing"
|
|
);
|
|
let _ = f.app.update(Message::Field(
|
|
"/name".into(),
|
|
"Definition edit stays unapplied".into(),
|
|
));
|
|
assert!(f.app.draft_dirty());
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert!(!f.app.document.can_undo());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_explicit_object_navigation_leaves_qualified_use_even_for_same_leaf_id() {
|
|
for edit_properties in [false, true] {
|
|
for defining_leaf in [false, true] {
|
|
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
|
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![first],
|
|
occurrence: leaf,
|
|
});
|
|
choose(&mut f.app, qualified.clone(), Choice::Replace);
|
|
assert_eq!(f.app.selected, leaf);
|
|
assert_eq!(f.app.process_preview_primary(), Some(&qualified));
|
|
assert!(f.app.draft.is_none());
|
|
let before = f.app.document.snapshot();
|
|
let source = f.app.document.bundle().clone();
|
|
let camera = f.app.behavior.topology.camera_boundary();
|
|
let target = if defining_leaf { leaf } else { first };
|
|
let expected_owner = if defining_leaf { definition } else { f.owner };
|
|
let message = if edit_properties {
|
|
Message::EditProperties(target)
|
|
} else {
|
|
Message::Select(target)
|
|
};
|
|
let _ = f.app.update(message);
|
|
assert_eq!(f.app.selected, target);
|
|
assert_eq!(f.app.topology_context().unwrap().owner, expected_owner);
|
|
assert_eq!(f.app.behavior.active_owner, Some(expected_owner));
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().targets(),
|
|
&[local(target)]
|
|
);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().primary(),
|
|
Some(&local(target))
|
|
);
|
|
assert!(f.app.behavior.topology.selection_address().is_none());
|
|
assert!(f.app.process_preview_primary().is_none());
|
|
assert!(
|
|
f.app.draft.is_some(),
|
|
"Explicit model-object navigation restores its editor"
|
|
);
|
|
assert!(!f.app.draft_dirty());
|
|
if !defining_leaf {
|
|
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
|
}
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.document.bundle(), &source);
|
|
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
|
source_matches(&f.app);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_open_local_reference_after_qualified_inspection_returns_local_reference() {
|
|
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
|
choose(
|
|
&mut f.app,
|
|
Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![first],
|
|
occurrence: leaf,
|
|
}),
|
|
Choice::Replace,
|
|
);
|
|
let before = f.app.document.snapshot();
|
|
let source = f.app.document.bundle().clone();
|
|
let camera = f.app.behavior.topology.camera_boundary();
|
|
let context = f.app.topology_context().unwrap();
|
|
let _ = f
|
|
.app
|
|
.update(Message::TopologyCanvas(CanvasEvent::Open(context, first)));
|
|
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
|
assert_eq!(f.app.selected, definition);
|
|
assert!(f.app.behavior.topology.selection_address().is_none());
|
|
let context = f.app.topology_context().unwrap();
|
|
let _ = f
|
|
.app
|
|
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
|
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
|
assert_eq!(f.app.selected, first);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().targets(),
|
|
&[local(first)]
|
|
);
|
|
assert!(f.app.behavior.topology.selection_address().is_none());
|
|
assert!(f.app.process_preview_primary().is_none());
|
|
assert!(f.app.draft.is_some());
|
|
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
assert_eq!(f.app.document.bundle(), &source);
|
|
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
|
source_matches(&f.app);
|
|
}
|
|
|
|
#[test]
|
|
fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() {
|
|
use crate::diagram_usage as usage;
|
|
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
|
let mut project = f.app.project().clone();
|
|
let next = add(
|
|
&mut project,
|
|
definition,
|
|
"Following check",
|
|
OccurrenceBody::Action {
|
|
action: Some(f.work),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next);
|
|
f.app = open(project, f.owner);
|
|
let project = f.app.project().clone();
|
|
assert!(f.app.behavior.topology.expand(&project, f.owner, first));
|
|
assert!(f.app.behavior.topology.expand(&project, f.owner, second));
|
|
let before = f.app.document.snapshot();
|
|
for call in [first, second] {
|
|
let target = Target::ProcessPreviewLink {
|
|
relation,
|
|
source: OccurrenceEndpoint {
|
|
references: vec![call],
|
|
occurrence: leaf,
|
|
},
|
|
target: OccurrenceEndpoint {
|
|
references: vec![call],
|
|
occurrence: next,
|
|
},
|
|
};
|
|
let context = usage::Context {
|
|
document: f.app.canvas_document,
|
|
revision: f.app.document.revision(),
|
|
subject: relation,
|
|
};
|
|
let _ = f
|
|
.app
|
|
.update(Message::DiagramUsage(usage::Event::Open(context)));
|
|
let token = f.app.diagram_usages.as_ref().unwrap().token;
|
|
let rows = usage::collect(
|
|
f.app.project(),
|
|
relation,
|
|
&f.app.behavior.topology,
|
|
&f.app.behavior.scenario,
|
|
);
|
|
let row = rows
|
|
.iter()
|
|
.find(|row| row.surface == Surface::Process(f.owner) && row.target == target)
|
|
.unwrap()
|
|
.clone();
|
|
let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion {
|
|
token,
|
|
context,
|
|
result: Ok(rows),
|
|
}));
|
|
let _ = f
|
|
.app
|
|
.update(Message::DiagramUsage(usage::Event::Choose(token, row)));
|
|
assert_eq!(f.app.selected, relation);
|
|
assert_eq!(
|
|
f.app.multiselect_snapshot().unwrap().primary(),
|
|
Some(&target)
|
|
);
|
|
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
|
assert!(f.app.draft.is_none());
|
|
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
|
}
|
|
}
|