Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology/direct_tests.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00

1194 lines
40 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 reordered = execute_batch(
&f.p,
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
)
.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 + graph.scroll_offset),
)
.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_scroll_coordinates() {
let f = fixture(Layer::Oa);
for drag in [false, true] {
let mut graph = graph(&f, CanvasTool::Connect);
graph.scroll_offset = iced::Vector::new(35., 90.);
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
let screen =
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
let a = screen(f.nodes[0]);
let b = screen(f.nodes[2]);
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.5);
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);
}
}
}
#[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.scrolled(scenario::ScrollArea::Canvas, 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.scroll_offset(), iced::Vector::new(35., 210.));
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.scroll_offset(), iced::Vector::ZERO);
}
}
#[test]
fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() {
use iced::advanced::widget::{Tree, tree};
use std::rc::Rc;
fn path_to(tree: &Tree, tag: tree::Tag) -> Option<Vec<usize>> {
if tree.tag == tag {
return Some(Vec::new());
}
tree.children.iter().enumerate().find_map(|(index, child)| {
let mut path = path_to(child, tag)?;
path.insert(0, index);
Some(path)
})
}
fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree {
for &index in path {
tree = &mut tree.children[index];
}
tree
}
for layer in [Layer::Oa, Layer::Sa] {
for size in [Size::new(1440., 900.), Size::new(1100., 700.)] {
for tool in [Tool::Control, Tool::Branch, Tool::Connect] {
let f = fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.nodes[1]));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = size;
let original = app.document.snapshot();
let graph: Element<'_, Message> =
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
let canvas_tag = Tree::new(graph.as_widget()).tag;
drop(graph);
let scroll: Element<'_, Message> = scrollable(text("")).into();
let scroll_tag = Tree::new(scroll.as_widget()).tag;
let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget());
let mut scroll_path = path_to(&tree, canvas_tag).unwrap();
scroll_path.pop();
assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag);
// Scrollable::diff treats its local state as opaque. A retained marker
// proves the real Iced diff preserves the state that owns the offsets.
// No layout or input operation runs on this test-only marker state.
let marker = Rc::new(());
at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker));
let _ = app.update(Message::Behavior(super::super::Event::Topology(
Event::Start(f.owner, tool, Some(f.nodes[1])),
)));
assert!(app.behavior.has_pending());
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
assert_eq!(
Rc::strong_count(&marker),
2,
"form open: {layer:?} {size:?} {tool:?}"
);
let mut current_path = path_to(&tree, canvas_tag).unwrap();
current_path.pop();
assert_eq!(current_path, scroll_path);
assert!(Rc::ptr_eq(
at(&mut tree, &scroll_path).state.downcast_ref::<Rc<()>>(),
&marker,
));
let _ = app.update(Message::Behavior(super::super::Event::Topology(
Event::Cancel,
)));
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
assert_eq!(
Rc::strong_count(&marker),
2,
"form cancel: {layer:?} {size:?} {tool:?}"
);
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original));
}
}
}
}
#[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.scroll_offset = iced::Vector::new(11., 19.);
let bounds = Rectangle::new(
Point::new(40., 70.),
Size::new(graph.size.width * scale, graph.size.height * scale),
);
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,
));
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]));
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}"
);
}
}
}
}