Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology/direct_tests.rs
T
sorokin.oleg.edu b15345123a Bound Process payload sizes and capture rendered order feedback
Box the optional Process menu context and changed order command without changing identity or command semantics. Add opt-in actual WGPU readbacks to the split-event OA/SA reorder evaluator. Strict all-target lint, format and affected desktop paths pass; native acceptance follows.
2026-09-23 05:47:08 -07:00

1485 lines
50 KiB
Rust

use super::*;
use crate::document::Document;
use oa_core::source_project::{ProjectLimits, SourceBundle};
pub(crate) struct Fixture {
pub p: Project,
pub owner: Id,
pub work: [Id; 3],
pub nodes: [Id; 3],
pub exchange: Id,
pub link: Id,
}
pub(crate) fn fixture(layer: Layer) -> Fixture {
let mut p = Project::empty();
let owner = p
.make_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Respond safely",
None,
)
.unwrap();
let owner_id = owner.id;
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
let mut work = Vec::new();
let mut nodes = Vec::new();
for name in ["Assess", "Respond", "Recover"] {
let e = p
.make_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
name,
None,
)
.unwrap();
let id = e.id;
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
work.push(id);
let e = occurrence(
&p,
owner_id,
name,
OccurrenceBody::Action {
action: Some(id),
lifeline: None,
},
);
let id = e.id;
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
nodes.push(id);
}
let kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
p = execute_batch(
&p,
&[Command::Connect(
kind,
"Assessment".into(),
work[0],
work[1],
)],
)
.unwrap();
let exchange = p.relations.values().find(|r| r.kind() == kind).unwrap().id;
let Command::CreateRelation(mut r) = link(&p, nodes[0], nodes[1], "approved") else {
unreachable!()
};
r.alias = "assessed_path".into();
r.description = "Reviewed safety rationale".into();
let link_id = r.id;
p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap();
Fixture {
p,
owner: owner_id,
work: work.try_into().unwrap(),
nodes: nodes.try_into().unwrap(),
exchange,
link: link_id,
}
}
pub(crate) fn context(owner: Id) -> Context {
Context {
document: Id::new_v4(),
owner,
revision: 0,
epoch: 0,
}
}
fn command(f: &Fixture, tool: Tool, target: Option<Id>, insertion: Insertion) -> Command {
let mut state = State::default();
state
.begin(&f.p, context(f.owner), tool, ControlKind::Choice, insertion)
.unwrap();
if let Some(id) = target {
state
.update(
&f.p,
Event::Target(Choice {
id,
label: "Source".into(),
}),
)
.unwrap();
}
state.prepare_pending(&f.p).unwrap()
}
fn created(p: &Project, after: &Project, owner: Id) -> Id {
after
.children(owner)
.find(|e| !p.contains(e.id))
.unwrap()
.id
}
#[test]
fn direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let original = f.p.clone();
let add = command(&f, Tool::Work, Some(f.work[0]), Insertion::default());
let result = execute_batch(&f.p, &[add]).unwrap();
let repeated = created(&f.p, &result, f.owner);
assert_ne!(repeated, f.nodes[0]);
assert_eq!(result.elements[&f.work[0]], f.p.elements[&f.work[0]]);
assert!(
matches!(result.elements[&repeated].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Action{action:Some(id),..},..})if *id==f.work[0])
);
f.p = result;
let plan = command(
&f,
Tool::Exchange,
Some(f.exchange),
Insertion {
link: Some(f.link),
before: None,
},
);
let serialized = serde_json::to_string(&plan).unwrap();
let api: Command = serde_json::from_str(&serialized).unwrap();
assert_eq!(api, plan);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
doc.execute(plan).unwrap();
let inserted = created(&f.p, doc.project(), f.owner);
let r = &doc.project().relations[&f.link];
let expected = &f.p.relations[&f.link];
assert_eq!(r.id, expected.id);
assert_eq!(r.alias, expected.alias);
assert_eq!(r.description, expected.description);
assert_eq!(r.source(), expected.source());
assert_eq!(r.target(), inserted);
assert!(matches!(&r.body,RelationBody::SequenceLink{guard,..}if guard=="approved"));
assert!(
sequence_links(doc.project(), f.owner)
.iter()
.any(|r| r.source() == inserted && r.target() == f.nodes[1])
);
let steps = doc.project().elements[&f.owner].steps().unwrap();
assert_eq!(&steps[..3], &[f.nodes[0], inserted, f.nodes[1]]);
assert_eq!(
doc.project().relations[&f.exchange],
original.relations[&f.exchange]
);
assert_eq!(doc.project(), &execute_batch(&f.p, &[api]).unwrap());
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), doc.project());
}
}
#[test]
fn direct_process_specific_parallel_link_and_incompatible_adjacency() {
let mut f = fixture(Layer::Oa);
let Command::CreateRelation(mut second) = link(&f.p, f.nodes[0], f.nodes[1], "urgent") else {
unreachable!()
};
second.alias = "urgent_path".into();
let second_id = second.id;
f.p = execute_batch(&f.p, &[Command::CreateRelation(second)]).unwrap();
let graph = Graph::new(&f.p, f.owner, f.owner);
for edge in &graph.edges {
let clicked = send(
&graph,
&mut Input::default(),
mouse::Event::ButtonPressed(mouse::Button::Left),
edge.label.center(),
Rectangle::new(Point::ORIGIN, graph.size),
);
assert!(
matches!(clicked,Some(Message::Select(id))if id==edge.id),
"Each guarded parallel path must be individually selectable"
);
}
let plan = command(
&f,
Tool::Control,
None,
Insertion {
link: Some(f.link),
before: None,
},
);
let after = execute_batch(&f.p, &[plan]).unwrap();
assert_eq!(after.relations[&second_id], f.p.relations[&second_id]);
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Exchange,
ControlKind::Choice,
Insertion {
link: Some(f.link),
before: None,
},
)
.unwrap();
let wrong = execute_batch(
&f.p,
&[Command::Connect(
RelationKind::Interaction,
"Reverse".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let wrong_id = wrong
.relations
.values()
.find(|r| r.name == "Reverse")
.unwrap()
.id;
state
.update(
&wrong,
Event::Target(Choice {
id: wrong_id,
label: "Wrong".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&wrong).is_err());
assert!(state.draft.is_some());
assert!(direct::reconnect(&f.p, f.owner, f.link, direct::End::Target, f.nodes[0]).is_err());
}
#[test]
fn direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics() {
let f = fixture(Layer::Oa);
for kind in [
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
] {
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Control,
kind,
Insertion::default(),
)
.unwrap();
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
let id = created(&f.p, &result, f.owner);
assert!(
matches!(result.elements[&id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Control{kind:k},..})if *k==kind)
);
assert!(
validation::validate(&result)
.iter()
.any(|d| d.element == Some(id)
&& d.code == "control_cardinality"
&& d.severity == validation::Severity::Warning)
);
}
for kind in [ControlKind::Choice, ControlKind::Fork] {
let mut state = State::default();
state
.update(&f.p, Event::Start(f.owner, Tool::Branch, Some(f.nodes[0])))
.unwrap();
for event in [
Event::Target(Choice {
id: f.work[0],
label: "A".into(),
}),
Event::Alternate(Choice {
id: f.work[2],
label: "B".into(),
}),
Event::Control(ControlChoice(kind)),
Event::Guard("ready".into()),
] {
state.update(&f.p, event).unwrap();
}
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
assert!(
matches!(&result.relations[&f.link].body,RelationBody::SequenceLink{guard,..}if guard=="approved")
);
let branch_guards: Vec<_> = sequence_links(&result, f.owner)
.into_iter()
.filter(|r| !f.p.relations.contains_key(&r.id))
.filter_map(|r| {
if let RelationBody::SequenceLink { guard, .. } = &r.body {
Some(guard.as_str())
} else {
None
}
})
.collect();
assert_eq!(
branch_guards.contains(&"ready"),
kind == ControlKind::Choice
);
assert_eq!(branch_guards.contains(&"else"), kind == ControlKind::Choice);
}
}
#[test]
fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources() {
let f = fixture(Layer::Oa);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
doc.execute(
direct::reconnect(
doc.project(),
f.owner,
f.link,
direct::End::Target,
f.nodes[2],
)
.unwrap(),
)
.unwrap();
assert_eq!(doc.project().relations[&f.link].target(), f.nodes[2]);
assert_eq!(doc.project().relations[&f.link].alias, "assessed_path");
let reconnected = doc.bundle().clone();
doc.execute(Command::Delete(f.nodes[0])).unwrap();
assert!(doc.project().contains(f.work[0]));
assert!(doc.project().contains(f.exchange));
assert!(doc.project().contains(f.nodes[1]));
assert!(!doc.project().contains(f.link));
doc.undo();
assert_eq!(doc.bundle(), &reconnected);
doc.undo();
assert_eq!(doc.bundle(), &original);
let group_reorder::Plan::Change(command) =
direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()
else {
panic!("changed order");
};
let reordered = execute_batch(&f.p, &[*command]).unwrap();
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
assert_eq!(reordered.relations, f.p.relations);
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
}
#[test]
fn direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected() {
let mut f = fixture(Layer::Oa);
let source =
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
.unwrap();
let source_id = source.id;
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
for i in 0..8 {
let e = occurrence(
&f.p,
source_id,
&format!("Shared step {i}"),
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let shared = f.p.clone();
let mut references = Vec::new();
for _ in 0..2 {
let plan = command(&f, Tool::Reuse, Some(source_id), Insertion::default());
let result = execute_batch(&f.p, &[plan]).unwrap();
references.push(created(&f.p, &result, f.owner));
f.p = result;
}
let graph = Graph::new(&f.p, f.owner, references[0]);
let node = graph.nodes.iter().find(|n| n.id == references[0]).unwrap();
assert_eq!(node.preview.len(), 4);
assert_eq!(node.preview_total, 8);
let mut input = Input::default();
let point = Point::new(node.rect.x + 20., node.rect.y + 140.);
let clicked = send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
Rectangle::new(Point::ORIGIN, graph.size),
);
assert!(
matches!(clicked,Some(Message::Select(id))if id==references[0]),
"preview click must select the local reference"
);
let expanded = Graph::expanded(
&f.p,
f.owner,
references[0],
&BTreeSet::from([references[0]]),
);
assert_eq!(
expanded
.nodes
.iter()
.find(|n| n.id == references[0])
.unwrap()
.preview
.len(),
8
);
assert_eq!(
expanded
.nodes
.iter()
.find(|n| n.id == references[1])
.unwrap()
.preview
.len(),
4
);
for e in shared.children(source_id) {
assert_eq!(f.p.elements[&e.id], *e);
}
assert!(
!targets(&f.p, source_id, Tool::Reuse)
.iter()
.any(|c| c.id == f.owner)
);
let inside = f.p.elements[&source_id].steps().unwrap()[0];
assert!(!validation::can_connect(
&f.p,
RelationKind::SequenceLink,
references[0],
inside
));
}
#[test]
fn direct_process_nested_source_reopen_and_api_command_use_one_transaction() {
let bundle=SourceBundle{entry:"project.arcadia".into(),files:[
("project.arcadia".into(),"arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; include \"oa/process.arcadia\"; }\n".into()),
("oa/work.arcadia".into(),"// Shared work\noa_activity assess \"Assess\" {}\n".into()),
("oa/process.arcadia".into(),"// Contextual process\noa_process respond \"Respond\" {}\n".into()),
].into()};
let mut doc = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
let owner = doc
.project()
.elements
.values()
.find(|e| e.alias == "respond")
.unwrap()
.id;
let work = doc
.project()
.elements
.values()
.find(|e| e.alias == "assess")
.unwrap()
.id;
let mut state = State::default();
state
.begin(
doc.project(),
context(owner),
Tool::Work,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: work,
label: "Assess".into(),
}),
)
.unwrap();
let plan = state.prepare_pending(doc.project()).unwrap();
let expected = execute_batch(
doc.project(),
&[serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap()],
)
.unwrap();
doc.execute(plan).unwrap();
let id = doc.project().elements[&owner].steps().unwrap()[0];
assert_eq!(
doc.compiled().index.declarations[&id].path,
"oa/process.arcadia"
);
assert_eq!(
doc.bundle().files["oa/work.arcadia"],
original.files["oa/work.arcadia"]
);
assert_eq!(doc.project(), &expected);
let after = doc.bundle().clone();
doc.undo();
assert_eq!(doc.bundle(), &original);
doc.redo();
assert_eq!(doc.bundle(), &after);
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
}
fn graph(f: &Fixture, tool: CanvasTool) -> Graph<'_> {
let mut graph = Graph::new(&f.p, f.owner, f.owner);
graph.interaction = Some((context(f.owner), tool, true));
graph
}
fn send(
graph: &Graph<'_>,
input: &mut Input,
event: mouse::Event,
point: Point,
bounds: Rectangle,
) -> Option<Message> {
canvas::Program::update(
graph,
input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point),
)
.and_then(|a| a.into_inner().0)
}
fn position(graph: &Graph<'_>, id: Id) -> Point {
graph
.nodes
.iter()
.find(|n| n.id == id)
.unwrap()
.rect
.center()
}
#[test]
fn direct_process_production_click_click_drag_insert_and_camera_coordinates() {
let f = fixture(Layer::Oa);
for drag in [false, true] {
let mut graph = graph(&f, CanvasTool::Connect);
graph.camera_pan = iced::Vector::new(35., 90.);
let bounds = Rectangle::new(
Point::new(10., 20.),
Size::new(graph.size.width + 70., graph.size.height + 180.),
);
let screen =
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) + graph.camera_pan;
let a = screen(f.nodes[0]);
let b = screen(f.nodes[2]);
assert!(bounds.contains(a) && bounds.contains(b));
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
if !drag {
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
a,
bounds
)
.is_none()
);
}
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
if !drag {
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
bounds,
);
}
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Connect{source,target,..}))if source==f.nodes[0]&&target==f.nodes[2])
);
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds
)
.is_none()
);
}
let graph = graph(&f, CanvasTool::Add(Tool::Exchange, ControlKind::Choice));
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
let e = &graph.edges[0];
let point = Point::new((e.points[0].x + e.points[1].x) / 2., e.points[0].y);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),point,bounds),Some(Message::TopologyCanvas(CanvasEvent::Begin{insertion:Insertion{link:Some(id),..},..}))if id==f.link)
);
}
#[test]
fn direct_process_production_stale_focus_scroll_bounds_and_pending_cancel() {
let f = fixture(Layer::Oa);
for change in 0..8 {
let mut graph = graph(&f, CanvasTool::Connect);
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
let a = position(&graph, f.nodes[0]);
let b = position(&graph, f.nodes[2]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
let mut end_bounds = bounds;
match change {
0 => graph.interaction.as_mut().unwrap().0.epoch += 1,
1 => graph.interaction.as_mut().unwrap().2 = false,
2 => end_bounds.y -= 40.,
3 => {
canvas::Program::update(
&graph,
&mut input,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds,
mouse::Cursor::Available(b),
);
}
4 => {
send(
&graph,
&mut input,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
b,
bounds,
);
}
5 => graph.interaction.as_mut().unwrap().0.document = Id::new_v4(),
6 => graph.interaction.as_mut().unwrap().1 = CanvasTool::Select,
_ => {
canvas::Program::update(
&graph,
&mut input,
&canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
bounds,
mouse::Cursor::Available(b),
);
}
}
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
end_bounds
)
.is_none(),
"change {change}"
);
}
}
#[test]
fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
let f = fixture(Layer::Oa);
let mut state = State::default();
state.enter(Id::new_v4(), f.owner);
state.viewport(Size::new(500., 400.));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
state.zoom(None);
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
state.zoom(Some(0.1));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1);
state.enter(Id::new_v4(), f.owner);
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
let mut all = Vec::new();
for page in 0..500usize.div_ceil(24) {
let range = view::choice_page(500, page);
assert!(range.len() <= 24);
all.extend(range);
}
assert_eq!(all, (0..500).collect::<Vec<_>>());
assert_eq!(view::choice_page(0, 90), 0..0);
}
#[test]
fn direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity() {
let f = fixture(Layer::Oa);
let mut graph = graph(&f, CanvasTool::Select);
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
graph.selected = f.link;
let mut input = Input::default();
let a = *graph
.edges
.iter()
.find(|e| e.id == f.link)
.unwrap()
.points
.last()
.unwrap();
let b = position(&graph, f.nodes[2]);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reconnect{link,end:direct::End::Target,target,..}))if link==f.link&&target==f.nodes[2])
);
graph.selected = f.nodes[2];
let n = graph.nodes.iter().find(|n| n.id == f.nodes[2]).unwrap();
let a = Point::new(n.rect.x + n.rect.width - 20., n.rect.y + 18.);
let b = position(&graph, f.nodes[0]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reorder{occurrence,before,..}))if occurrence==f.nodes[2]&&before==f.nodes[0])
);
}
#[test]
fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() {
let f = fixture(Layer::Oa);
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Work,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
&f.p,
Event::Target(Choice {
id: f.work[0],
label: "Keep".into(),
}),
)
.unwrap();
let mut choices: Vec<_> = (0..500)
.map(|i| Choice {
id: Id::new_v4(),
label: format!("Activity {i}"),
})
.collect();
choices.push(Choice {
id: f.work[0],
label: "Selected source".into(),
});
fn nodes(tree: &iced::advanced::widget::Tree) -> usize {
1 + tree.children.iter().map(nodes).sum::<usize>()
}
for page in 0..21 {
state.update(&f.p, Event::Page(0, page)).unwrap();
let d = state.draft.as_ref().unwrap();
let view = view::picker(choices.clone(), d, 0, d.target, "Choose", Event::Target);
assert!(nodes(&iced::advanced::widget::Tree::new(view.as_widget())) < 40);
}
state
.update(&f.p, Event::Search(0, "Activity 499".into()))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0]));
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
}
pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) {
let mut f = fixture(layer);
let mut commands = f.nodes.into_iter().map(Command::Delete).collect::<Vec<_>>();
commands.push(Command::Connect(
f.p.relations[&f.exchange].kind(),
"Response".into(),
f.work[1],
f.work[2],
));
f.p = execute_batch(&f.p, &commands).unwrap();
let next =
f.p.relations
.values()
.find(|r| r.name == "Response")
.unwrap()
.id;
assert!(f.p.elements[&f.owner].steps().unwrap().is_empty());
(f, next)
}
#[test]
fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
let kind = f.p.relations[&f.exchange].kind();
f.p = execute_batch(
&f.p,
&[Command::Connect(
kind,
"Recovery".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let final_exchange =
f.p.relations
.values()
.find(|r| r.name == "Recovery")
.unwrap()
.id;
for exchange in [f.exchange, next] {
let e = occurrence(
&f.p,
f.owner,
"Ordered contribution",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let before = f.p.elements[&f.owner].steps().unwrap().clone();
assert_eq!(legacy_order(&f.p, f.owner), before);
assert!(sequence_links(&f.p, f.owner).is_empty());
let mut state = State::default();
state.append_exchange(&f.p, f.owner).unwrap();
let draft = state.draft.as_ref().unwrap();
assert!(draft.fixed);
assert_eq!(draft.before, before.last().copied());
assert_eq!(draft.after, None);
assert!(!compatible_source(&f.p, draft, f.exchange));
assert!(compatible_source(&f.p, draft, final_exchange));
state
.update(
&f.p,
Event::Target(Choice {
id: f.exchange,
label: "Incompatible".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&f.p).is_err());
assert!(state.draft.is_some());
state
.update(
&f.p,
Event::Target(Choice {
id: final_exchange,
label: "Recovery".into(),
}),
)
.unwrap();
let command = state.prepare_pending(&f.p).unwrap();
let Command::Batch(commands) = &command else {
panic!("one ordinary batch")
};
assert_eq!(
commands.len(),
4,
"materialize old path + occurrence + append link + order"
);
let serialized = serde_json::to_value(command).unwrap();
let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap();
let after = result.elements[&f.owner].steps().unwrap();
assert_eq!(&after[..2], &before);
assert_eq!(after.len(), 3);
let links = sequence_links(&result, f.owner);
assert_eq!(links.len(), 2);
for pair in after.windows(2) {
assert_eq!(
links
.iter()
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
.count(),
1
);
}
assert!(uses_explicit_topology(&result, f.owner));
for (id, relation) in &f.p.relations {
assert_eq!(result.relations[id], *relation);
}
}
}
fn assert_interactive_owner_view(p: &Project, owner: Id) {
use iced::advanced::widget::Tree;
fn contains(tree: &Tree, tag: iced::advanced::widget::tree::Tag) -> bool {
tree.tag == tag || tree.children.iter().any(|child| contains(child, tag))
}
let mut app = App::with_project(p.clone());
app.layer = p.elements[&owner].layer();
app.page = crate::app::Page::Behavior;
app.selected = owner;
app.behavior.active_owner = Some(owner);
let graph: Element<'_, Message> = canvas::Canvas::new(Graph::new(p, owner, owner)).into();
let tag = Tree::new(graph.as_widget()).tag;
let view = view::view(&app, owner, Column::new());
assert!(
contains(&Tree::new(view.as_widget()), tag),
"every path uses the interactive graph"
);
}
#[test]
fn direct_process_three_ordered_exchanges_preserve_outer_paths_on_insertion() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
f.p = execute_batch(
&f.p,
&[Command::Connect(
f.p.relations[&f.exchange].kind(),
"Return for reassessment".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let last =
f.p.relations
.values()
.find(|r| r.name == "Return for reassessment")
.unwrap()
.id;
for exchange in [f.exchange, next, last] {
let node = occurrence(
&f.p,
f.owner,
"Ordered contribution",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
}
for index in [0, 2] {
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
let old_order = legacy_order(doc.project(), f.owner);
let graph = Graph::new(doc.project(), f.owner, f.owner);
assert_eq!(graph.ordered_paths.len(), 2);
assert!(graph.edges.is_empty());
let mut state = State::default();
state
.begin(
doc.project(),
context(f.owner),
Tool::Work,
ControlKind::Choice,
Insertion {
link: None,
before: Some(old_order[index]),
},
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: f.work[index],
label: "Shared activity".into(),
}),
)
.unwrap();
doc.execute(state.prepare_pending(doc.project()).unwrap())
.unwrap();
let added = created(&f.p, doc.project(), f.owner);
let mut expected = old_order;
expected.insert(index, added);
assert_eq!(doc.project().elements[&f.owner].steps().unwrap(), &expected);
let links = sequence_links(doc.project(), f.owner);
assert_eq!(links.len(), 3);
for pair in expected.windows(2) {
assert_eq!(
links
.iter()
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
.count(),
1
);
}
for (id, relation) in &f.p.relations {
assert_eq!(&doc.project().relations[id], relation);
}
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert_eq!(doc.project(), &f.p);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
}
}
}
#[test]
fn direct_process_incompatible_imported_order_rejects_conversion_without_losing_draft() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, _) = empty_exchange_fixture(layer);
// Repeated exchange uses have different occurrences, but this legacy path
// has an endpoint gap. Converting its implied adjacency must reject.
for _ in 0..2 {
let node = occurrence(
&f.p,
f.owner,
"Repeated exchange",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
}
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
assert_interactive_owner_view(doc.project(), f.owner);
assert_eq!(
Graph::new(doc.project(), f.owner, f.owner)
.ordered_paths
.len(),
1
);
let mut state = State::default();
state
.begin(
doc.project(),
context(f.owner),
Tool::Control,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
doc.project(),
Event::Name("Retain this proposed decision".into()),
)
.unwrap();
assert!(state.prepare_pending(doc.project()).is_err());
assert_eq!(
state.draft.as_ref().unwrap().name,
"Retain this proposed decision"
);
assert_eq!(doc.bundle(), &before);
assert_eq!(doc.project(), &f.p);
assert!(!doc.undo(), "rejection must not create history");
state.update(doc.project(), Event::Cancel).unwrap();
assert!(state.draft.is_none());
assert_eq!(doc.bundle(), &before);
}
}
#[test]
fn direct_process_empty_first_and_second_exchange_keep_the_same_interactive_renderer() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = empty_exchange_fixture(layer);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
for (index, target) in [f.exchange, next].into_iter().enumerate() {
assert_interactive_owner_view(doc.project(), f.owner);
let before = doc.bundle().clone();
let mut graph = Graph::new(doc.project(), f.owner, f.owner);
let c = context(f.owner);
graph.interaction = Some((
c,
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
true,
));
let mut input = Input::default();
let bounds = Rectangle::with_size(graph.size);
let point = Point::new(graph.size.width - 10., graph.size.height - 10.);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
point,
bounds,
) else {
panic!("canvas retains direct exchange insertion");
};
let mut state = State::default();
state
.begin(
doc.project(),
c,
Tool::Exchange,
ControlKind::Choice,
insertion,
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: target,
label: "Exchange".into(),
}),
)
.unwrap();
doc.execute(state.prepare_pending(doc.project()).unwrap())
.unwrap();
assert_eq!(
doc.project().elements[&f.owner].steps().unwrap().len(),
index + 1
);
assert_eq!(sequence_links(doc.project(), f.owner).len(), index);
assert_interactive_owner_view(doc.project(), f.owner);
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
}
}
}
#[test]
fn direct_process_imported_order_is_projected_without_mutation_and_first_canvas_edit_is_atomic() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
for exchange in [f.exchange, next] {
let node = occurrence(
&f.p,
f.owner,
"Imported exchange",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
}
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
let order = legacy_order(doc.project(), f.owner);
assert_eq!(order.len(), 2);
assert_interactive_owner_view(doc.project(), f.owner);
let mut graph = Graph::new(doc.project(), f.owner, order[0]);
assert!(
graph.edges.is_empty(),
"projection never invents relation identities"
);
assert_eq!(graph.ordered_paths.len(), 1);
assert!(position(&graph, order[0]).x < position(&graph, order[1]).x);
let points = &graph.ordered_paths[0].points;
let point = Point::new((points[0].x + points[1].x) / 2., points[0].y);
let c = context(f.owner);
graph.interaction = Some((c, CanvasTool::Add(Tool::Work, ControlKind::Choice), true));
let mut input = Input::default();
let bounds = Rectangle::with_size(graph.size);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
point,
bounds,
) else {
panic!("ordered arrow supports direct insertion");
};
assert_eq!(
insertion,
Insertion {
link: None,
before: Some(order[1])
}
);
assert_eq!(doc.bundle(), &before);
let mut state = State::default();
state
.begin(doc.project(), c, Tool::Work, ControlKind::Choice, insertion)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: f.work[1],
label: "Respond".into(),
}),
)
.unwrap();
doc.execute(state.prepare_pending(doc.project()).unwrap())
.unwrap();
let inserted = created(&f.p, doc.project(), f.owner);
assert_eq!(
doc.project().elements[&f.owner].steps().unwrap(),
&[order[0], inserted, order[1]]
);
let links = sequence_links(doc.project(), f.owner);
assert_eq!(links.len(), 2);
for (a, b) in [(order[0], inserted), (inserted, order[1])] {
assert!(
links
.iter()
.any(|link| link.source() == a && link.target() == b)
);
}
assert!(
Graph::new(doc.project(), f.owner, inserted)
.ordered_paths
.is_empty()
);
for id in f.work {
assert_eq!(doc.project().elements[&id], f.p.elements[&id]);
}
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert_eq!(doc.project(), &f.p);
}
}
#[test]
fn direct_process_palette_preserves_fit_and_manual_camera() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
let mut state = State::default();
let document = Id::new_v4();
state.enter(document, f.owner);
state.viewport(Size::new(500., 300.));
state.camera(super::super::canvas_workspace::Event::Pan(
iced::Vector::new(35., 90.),
));
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.));
for camera in [None, Some(0.75), Some(1.5)] {
state.zoom(camera);
let size = Size::new(690., 250.);
let before = state.scale(size, 500.);
for tool in [
CanvasTool::Add(Tool::Work, ControlKind::Choice),
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
CanvasTool::Add(Tool::Control, ControlKind::Choice),
CanvasTool::Add(Tool::Control, ControlKind::Fork),
CanvasTool::Connect,
CanvasTool::Select,
] {
state.choose(tool);
assert_eq!(
state.scale(size, 500.),
before,
"{layer:?} {camera:?} {tool:?}"
);
assert_eq!(state.fit, camera.is_none());
assert_eq!(state.pan, iced::Vector::new(35., 90.));
assert_eq!(state.details_offset(), iced::Vector::new(0., 120.));
state.enter(document, f.owner);
assert_eq!(state.scale(size, 500.), before);
}
}
state.enter(Id::new_v4(), f.owner);
assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.);
assert_eq!(state.pan, iced::Vector::ZERO);
assert_eq!(state.details_offset(), iced::Vector::ZERO);
}
}
#[test]
fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for scale in [0.15, 0.6898551, 1.5] {
let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice));
graph.scale = scale;
graph.camera_pan = iced::Vector::new(11., 19.);
let bounds = Rectangle::new(
Point::new(40., 70.),
Size::new(
graph.size.width * scale + 22.,
graph.size.height * scale + 38.,
),
);
let screen = |world: Point| {
Point::new(
world.x * scale + bounds.x + 11.,
world.y * scale + bounds.y + 19.,
)
};
let edge = &graph.edges[0];
let point = screen(Point::new(
(edge.points[0].x + edge.points[1].x) / 2.,
edge.points[0].y,
));
assert!(bounds.contains(point));
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: point },
point,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
assert!(
matches!(
send(&graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds),
Some(Message::TopologyCanvas(CanvasEvent::Begin {
tool: Tool::Control, control: ControlKind::Choice,
insertion: Insertion { link: Some(id), .. }, ..
})) if id == f.link
),
"{layer:?} control at {scale}"
);
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
for drag in [false, true] {
let mut input = Input::default();
let a = screen(position(&graph, f.nodes[0]));
let b = screen(position(&graph, f.nodes[2]));
assert!(bounds.contains(a) && bounds.contains(b));
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
if !drag {
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
a,
bounds,
);
}
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
if !drag {
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
bounds,
);
}
assert!(
matches!(
send(&graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds),
Some(Message::TopologyCanvas(CanvasEvent::Connect { source, target, .. }))
if source == f.nodes[0] && target == f.nodes[2]
),
"{layer:?} link at {scale}, drag={drag}"
);
}
}
}
}