Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
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.
1194 lines
40 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|