Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology/layout_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

1222 lines
49 KiB
Rust

use super::*;
#[tokio::test]
async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::keyboard::{Event as KeyEvent, Modifiers};
let mut f = super::super::direct_tests::fixture(Layer::Oa);
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let at = |app: &App, id| {
let graph = Graph::new(app.project(), f.owner, id);
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
bounds.position()
+ app.behavior.topology.pan
+ iced::Vector::new(rect.x + 60., rect.y + 20.)
* app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.))
};
let shift = |held| {
iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held {
Modifiers::SHIFT
} else {
Modifiers::empty()
}))
};
let first = at(&app, f.nodes[2]);
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(first))
.await;
mounted.click(&mut app, first).await;
let second = at(&app, f.nodes[0]);
mounted.click(&mut app, second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
2,
"Shift before canvas focus reaches both clicks"
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[shift(false)], mouse::Cursor::Available(second))
.await;
app.busy = false;
mounted.settle(&mut app).await;
let third = at(&app, f.nodes[1]);
mounted.click(&mut app, third).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
1,
"Shift released during busy is not resurrected"
);
app.busy = true;
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(second))
.await;
app.busy = false;
mounted.settle(&mut app).await;
mounted.click(&mut app, second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
2,
"Shift held through busy survives child lifetime"
);
mounted
.batch(
&mut app,
&[iced::Event::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut app, first).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
1,
"real window loss clears held modifiers"
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert!(!app.document.can_undo());
let pan = app.behavior.topology.pan;
let source = app.document.bundle().clone();
let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.);
let end = blank + iced::Vector::new(-25., -20.);
mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(blank),
)
.await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(app.behavior.topology.pan, pan);
assert_eq!(app.document.bundle(), &source);
// Each batch rebuilds the production tree and applies all App messages.
// Selection Clear must not retire the camera pan started by its press.
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: end,
})],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
app.behavior.topology.pan,
pan + iced::Vector::new(-25., -20.)
);
assert_eq!(app.document.bundle(), &source);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(end),
)
.await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(
app.behavior.topology.pan,
pan + iced::Vector::new(-25., -20.)
);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera()
{
use crate::behavior::canvas_workspace::tests::Mounted;
for layer in [Layer::Oa, Layer::Sa] {
let mut f = super::super::direct_tests::fixture(layer);
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let graph = Graph::new(app.project(), f.owner, f.owner);
let scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let at = |id, after| {
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
bounds.position()
+ iced::Vector::new(
rect.x + 60.,
rect.y + if after { rect.height - 15. } else { 20. },
) * scale
};
let points = [
at(f.nodes[2], false),
at(f.nodes[0], false),
at(f.nodes[1], true),
];
drop(graph);
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT),
)],
mouse::Cursor::Available(points[0]),
)
.await;
mounted.click(&mut app, points[0]).await;
mounted.click(&mut app, points[1]).await;
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()),
)],
mouse::Cursor::Available(points[1]),
)
.await;
let snapshot = app.multiselect_snapshot().unwrap();
let source = app.document.bundle().clone();
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
// Reuse the existing opt-in raw WGPU evidence convention. These are
// actual production-tree pixels, not a reconstructed preview image.
let record_feedback = |stage: &str, mounted: &mut Mounted, app: &App, cursor: Point| {
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
return;
};
let path = std::path::PathBuf::from(path);
std::fs::create_dir_all(&path).unwrap();
let name = format!("process-{layer:?}-{stage}");
let pixels = mounted.pixels_at(app, mouse::Cursor::Available(cursor));
let width = app.window_size.width as u32;
let height = app.window_size.height as u32;
assert_eq!(pixels.len(), width as usize * height as usize * 4);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(format!("{name}.rgba")))
.unwrap();
std::io::Write::write_all(&mut file, &pixels).unwrap();
let metadata = serde_json::json!({
"width": width, "height": height, "format": "RGBA8",
"stage": stage, "layer": format!("{layer:?}"),
"canvas": [bounds.x, bounds.y, bounds.width, bounds.height],
"cursor": [cursor.x, cursor.y], "scale": scale,
"camera": [app.behavior.topology.pan.x, app.behavior.topology.pan.y],
"selectionCount": snapshot.targets().len(),
"selection": format!("{snapshot:?}"),
"source": app.document.bundle(),
});
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(format!("{name}.json")))
.unwrap();
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap())
.unwrap();
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert!(!app.document.can_undo());
};
record_feedback("before-press", &mut mounted, &app, points[1]);
let pressed = mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved {
position: points[1],
}),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(points[1]),
)
.await;
assert!(
!pressed
.iter()
.any(|message| matches!(message, Message::DiagramSelection(_)))
);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
let moved = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: points[2],
})],
mouse::Cursor::Available(points[2]),
)
.await;
assert!(
!moved
.iter()
.any(|message| matches!(message, Message::DiagramSelection(_)))
);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
record_feedback("after-motion", &mut mounted, &app, points[2]);
let messages = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(points[2]),
)
.await;
assert!(
messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _))
)),
"{messages:?}"
);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&vec![f.nodes[1], f.nodes[0], f.nodes[2]]
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let changed = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &changed);
}
}
#[tokio::test]
async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() {
use crate::behavior::canvas_workspace::{self, tests::Mounted};
use iced::Vector;
use iced::advanced::widget;
let initial = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(initial.p);
let _ = app.update(Message::Select(initial.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let mut mounted = Mounted::new(&mut app).await;
for layer in [Layer::Oa, Layer::Sa] {
for size in [Size::new(1100., 700.), Size::new(1440., 900.)] {
let f = super::super::direct_tests::fixture(layer);
app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = size;
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let scope = canvas_workspace::tests::scope(&app);
for removed in ["Canvas", "Panels"] {
assert!(!geometry.containers.contains_key(&scope.control_id(removed)));
}
assert!(
!canvas_workspace::tests::focused(&mut mounted, &app, "viewport"),
"direct editing needs no activation or initial keyboard focus grab"
);
for scale in [0.5, 1., 2.] {
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale),
)));
let geometry = mounted.geometry(&app);
let bounds = geometry.bounds("process-viewport");
assert!(bounds.height > 350.);
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("process-canvas"))
);
let graph = Graph::new(app.project(), f.owner, f.owner);
let edge = graph.edges.iter().find(|edge| edge.id == f.link).unwrap();
let segment = edge
.points
.windows(2)
.max_by(|a, b| a[0].distance(a[1]).total_cmp(&b[0].distance(b[1])))
.unwrap();
let world = Point::new(
(segment[0].x + segment[1].x) / 2.,
(segment[0].y + segment[1].y) / 2.,
);
let desired = Vector::new(85., 130.);
let next_pan = desired - Vector::new(world.x, world.y) * scale;
let delta = next_pan - app.behavior.topology.pan;
let start = bounds.position() + Vector::new(130., 70.);
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
mounted.pan(&mut app, start, start + delta).await;
let diff = app.behavior.topology.pan - next_pan;
assert!(diff.x.hypot(diff.y) < 0.01);
let tool = mounted.geometry(&app).bounds("behavior-tool:OR").center();
mounted.click(&mut app, tool).await;
let messages = mounted.click(&mut app, bounds.position() + desired).await;
assert!(
messages.iter().any(
|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Begin {
tool: Tool::Control, control: ControlKind::Choice, insertion, ..
}) if insertion.link == Some(f.link) && insertion.before.is_none())
),
"{layer:?} {scale}: {messages:?}"
);
let invocation = app.behavior.topology.draft.as_ref().unwrap().invocation;
let _ = app.update(event(Event::Name(" raw choice 雨 ".into())));
let raw = app.behavior.recovery_snapshot().unwrap();
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
assert_eq!(shown.bounds("process-viewport"), bounds);
let actions = shown.bounds("process-actions");
let form = shown.scrolls[&widget::Id::new("process-form")].0;
assert!(actions.y >= bounds.y && actions.y + actions.height <= form.y);
assert!(form.y + form.height <= bounds.y + bounds.height && form.height > 250.);
let pan = app.behavior.topology.pan;
mounted
.batch(
&mut app,
&[canvas_workspace::tests::key(
iced::keyboard::key::Named::ArrowRight,
)],
mouse::Cursor::Available(bounds.position() + desired),
)
.await;
assert_eq!(app.behavior.topology.pan, pan);
mounted.scroll(&app, "process-form", 500.);
assert_eq!(mounted.geometry(&app).bounds("process-actions"), actions);
for shelf in [Shelf::Selection, Shelf::Selection] {
let _ = app.update(event(Event::Shelf(shelf)));
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
app.behavior.topology.draft.as_ref().unwrap().invocation,
invocation
);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
let _ = app.update(event(Event::Cancel));
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
app.behavior.topology.shelf = None;
}
}
}
}
#[tokio::test]
async fn production_process_canvas_focus_wheel_fit_and_stale_camera_are_presentation_only() {
use crate::behavior::canvas_workspace::{
Event as Camera,
tests::{Mounted, key},
};
use iced::Vector;
let f = super::super::direct_tests::fixture(Layer::Sa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + Vector::new(90., 120.);
let accepted = app.document.snapshot();
let bundle = app.document.bundle().clone();
mounted
.pan(&mut app, point, point + Vector::new(-20., 25.))
.await;
let pan = app.behavior.topology.pan;
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::ArrowRight)],
mouse::Cursor::Available(point),
)
.await;
assert_eq!(app.behavior.topology.pan, pan + Vector::new(-60., 0.));
let local = point - bounds.position();
let pan = app.behavior.topology.pan;
let old_scale = app.behavior.topology.zoom.unwrap_or(1.);
let before_world = (local - pan) / old_scale;
mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}),
],
mouse::Cursor::Available(point),
)
.await;
let after_world = (local - app.behavior.topology.pan) / app.behavior.topology.zoom.unwrap();
let diff = after_world - before_world;
assert!(diff.x.hypot(diff.y) < 0.001);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None)));
let fitted = (app.behavior.topology.pan, app.behavior.topology.zoom);
let _ = app.update(event(Event::Shelf(Shelf::Selection)));
mounted.settle(&mut app).await;
app.window_size = Size::new(1440., 900.);
mounted.settle(&mut app).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
fitted,
"Fit is a one-time camera action"
);
for mismatch in 0..3 {
let mut stale = app.topology_context().unwrap();
match mismatch {
0 => stale.document = Id::new_v4(),
1 => stale.owner = Id::new_v4(),
_ => stale.revision += 1,
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
stale,
app.camera_epoch,
Camera::Pan(Vector::new(400., 400.)),
)));
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
fitted
);
}
app.hosted.visible = true;
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
Camera::Pan(Vector::new(30., 40.)),
)));
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
fitted
);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
for boundary in ["Fit", "OR", "Details", "ZoomIn", "Bounds"] {
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + iced::Vector::new(95., 90.);
let lifetime = app.camera_epoch;
let pan = app.behavior.topology.pan;
let first = point + iced::Vector::new(13., 9.);
let last = first + iced::Vector::new(17., -3.);
let emitted = mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: first }),
iced::Event::Mouse(mouse::Event::CursorMoved { position: last }),
],
mouse::Cursor::Available(last),
)
.await;
assert_eq!(app.behavior.topology.pan, pan + (last - point));
assert_eq!(
app.camera_epoch, lifetime,
"own ordered deltas retain their lifetime"
);
let retained = camera_delta(&emitted);
if boundary == "Bounds" {
let size = Size::new(app.window_size.width + 90., app.window_size.height + 35.);
let task = app.update(Message::WindowResized(size));
mounted.perform(&mut app, task).await;
} else {
let id = scope(&app).control_id(boundary);
let at = mounted.geometry(&app).containers[&id].center();
let clicked = mounted.click(&mut app, at).await;
assert!(
focused(&mut mounted, &app, boundary),
"same-batch pointer activation focuses the resulting live control without F6"
);
if boundary == "Fit" {
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
assert!(
focused(&mut mounted, &app, "ZoomOut"),
"Tab after the real press/release batch continues from Fit"
);
assert!(clicked.iter().any(|m| matches!(
intent_action(m),
Message::TopologyCanvas(CanvasEvent::Zoom(_, None))
)));
assert_eq!(
app.behavior.topology.pan,
iced::Vector::ZERO,
"Fit emitted after this same batch's old pan must still execute"
);
}
}
mounted.settle(&mut app).await;
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} ends the old input lifetime"
);
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let bounds = mounted.geometry(&app).bounds("process-viewport");
let released = release_middle(
&mut mounted,
&mut app,
bounds.position() + iced::Vector::new(130., 145.),
)
.await;
assert!(!released.iter().any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(_, _, crate::behavior::canvas_workspace::Event::Pan(delta))) if *delta != iced::Vector::ZERO)),
"{boundary} must cancel the retained widget drag");
let task = app.update(retained);
mounted.perform(&mut app, task).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera,
"{boundary} must reject its pre-boundary App delta"
);
let current_lifetime = app.camera_epoch;
let task = app.update(toolbar);
mounted.perform(&mut app, task).await;
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} rejects the retained toolbar lifetime"
);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera,
"{boundary} retained Fit cannot alter the camera"
);
}
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_keyboard_enters_activates_draws_focus_and_returns_to_inspector() {
use crate::behavior::canvas_workspace::tests::*;
use iced::keyboard::key::Named;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(!focused(&mut mounted, &app, "viewport"));
let operations = mounted.operations;
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(
mounted.operations > operations,
"entry executes the returned App widget Task"
);
assert!(focused(&mut mounted, &app, "viewport"));
let mut backwards = key(Named::Tab);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) =
&mut backwards
{
*modifiers = iced::keyboard::Modifiers::SHIFT;
}
mounted
.batch(&mut app, &[backwards], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "ActualSize"));
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "viewport"));
assert_eq!(
mounted
.geometry(&app)
.focused
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1
);
tab_to(&mut mounted, &mut app, "OR").await;
let chosen = mounted
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(chosen.iter().any(|m| matches!(
intent_action(m),
Message::TopologyCanvas(CanvasEvent::Choose(..))
)));
assert!(
focused(&mut mounted, &app, "OR"),
"tool activation retains its keyboard target"
);
// The selected palette tool must still have a visibly distinct focus ring.
let tool_on = mounted.pixels(&app);
let geometry = mounted.geometry(&app);
let mut tool_bounds = geometry.containers[&scope(&app).control_id("OR")];
let offset = geometry.scrolls[&scope(&app).control_id("palette-scroll")].1;
tool_bounds.x -= offset.x;
tool_bounds.y -= offset.y;
mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let tool_off = mounted.pixels(&app);
assert_drawn_focus(&tool_off, &tool_on, app.window_size, tool_bounds);
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
// Focus alone changes the visible border of the real production Fit control.
let before = mounted.pixels(&app);
tab_to(&mut mounted, &mut app, "Fit").await;
let id = scope(&app).control_id("Fit");
let bounds = mounted.geometry(&app).containers[&id];
let after = mounted.pixels(&app);
assert_drawn_focus(&before, &after, app.window_size, bounds);
let fitted = mounted
.batch(&mut app, &[key(Named::Space)], mouse::Cursor::Unavailable)
.await;
assert!(fitted.iter().any(|m| matches!(
intent_action(m),
Message::TopologyCanvas(CanvasEvent::Zoom(_, None))
)));
assert_eq!(app.behavior.topology.pan, iced::Vector::ZERO);
assert!(focused(&mut mounted, &app, "Fit"));
tab_to(&mut mounted, &mut app, "viewport").await;
assert!(focused(&mut mounted, &app, "viewport"));
mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!focused(&mut mounted, &app, "viewport"));
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(_, _, focused)| *focused),
"ordinary Tab resumes the production Inspector's own entry policy"
);
assert!(!focused(&mut mounted, &app, "viewport"));
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "viewport"));
assert_eq!(
mounted
.geometry(&app)
.focused
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1,
"explicit canvas entry releases Inspector keyboard ownership"
);
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(!focused(&mut mounted, &app, "viewport"));
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
// Typing after exit belongs to Inspector. Replaying the prior clean tool
// action must retain those raw fields, never implicitly Apply them.
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
mounted
.batch(&mut app, &[character("x")], mouse::Cursor::Unavailable)
.await;
assert!(app.draft_dirty());
let dirty_raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert_ne!(dirty_raw, raw);
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
for message in chosen {
let task = app.update(message);
mounted.perform(&mut app, task).await;
}
let emitted = mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(
!emitted
.iter()
.any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
dirty_raw
);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_caller_return_rejects_its_previous_camera_lifetime() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut project = f.p;
let target = project
.add_element(Kind::Process, Layer::Oa, "Reusable check", None)
.unwrap();
let reference = occurrence(
&project,
f.owner,
"Local use",
OccurrenceBody::SequenceReference {
sequence: Some(target),
},
);
let use_id = reference.id;
project = execute_batch(&project, &[Command::CreateElement(reference)]).unwrap();
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let source = app.document.bundle().clone();
let snapshot = app.document.snapshot();
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + iced::Vector::new(80., 100.);
let emitted = mounted
.pan(&mut app, point, point + iced::Vector::new(35., -25.))
.await;
let retained = camera_delta(&emitted);
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
let task = app.update(Message::TopologyCanvas(CanvasEvent::Open(
app.topology_context().unwrap(),
use_id,
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.topology_context().unwrap().owner, target);
let target_bounds = mounted.geometry(&app).bounds("process-viewport");
let point = target_bounds.position() + iced::Vector::new(70., 90.);
mounted
.pan(&mut app, point, point + iced::Vector::new(-50., 20.))
.await;
let task = app.update(Message::TopologyCanvas(CanvasEvent::Back(
app.topology_context().unwrap(),
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert_eq!(app.selected, use_id);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let task = app.update(retained);
mounted.perform(&mut app, task).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let lifetime = app.camera_epoch;
let task = app.update(toolbar);
mounted.perform(&mut app, task).await;
assert_eq!(
app.camera_epoch, lifetime,
"A to B and Back cannot revive toolbar intent"
);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
mounted
.pan(&mut app, point, point + iced::Vector::new(12., -8.))
.await;
assert_eq!(
app.behavior.topology.pan,
camera.0 + iced::Vector::new(12., -8.)
);
assert_eq!(app.document.bundle(), &source);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_review_owners() {
use crate::behavior::canvas_workspace::tests::*;
use iced::keyboard::key::Named;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut project = f.p;
let target = project
.add_element(Kind::FunctionalChain, Layer::Sa, "Counterpart", None)
.unwrap();
let trace = Id::new_v4();
project.traces.insert(
trace,
Trace {
id: trace,
alias: "canvas_focus_trace".into(),
name: "Review camera ownership".into(),
description: String::new(),
oa: f.owner,
sa: target,
kind: TraceKind::Manual,
baseline: None,
},
);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let source = app.document.bundle().clone();
let snapshot = app.document.snapshot();
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await;
assert!(
mounted
.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.is_empty(),
"a second F6 cannot enter while the first focus operation is pending"
);
// Native Iced completes the Chain synchronously. Complete it before a new
// keyboard request, retaining the same Apply to challenge its ownership
// guard again after it has already observed a valid owner.
mounted.operate(&app, &mut *retained_apply);
assert!(focused(&mut mounted, &app, "viewport"));
let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable);
assert_eq!(entry.len(), 1);
let delayed = app.update(entry[0].clone());
let draft = app.draft.as_ref().unwrap();
let parent_id =
crate::forms::reference_chooser::trigger::field_id(draft.reference_generation(), "/parent");
let parent_bounds = mounted
.geometry(&app)
.focused
.iter()
.find(|(id, _, _)| id.as_ref() == Some(&parent_id))
.expect("actual Inspector reference trigger is mounted")
.1;
let request = draft.reference_open(app.project(), "/parent", parent_bounds);
let point =
mounted.geometry(&app).bounds("process-viewport").position() + iced::Vector::new(80., 100.);
press_middle(&mut mounted, &mut app, point).await;
let moved = point + iced::Vector::new(12., 8.);
let emitted = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: moved,
})],
mouse::Cursor::Available(moved),
)
.await;
let retained = camera_delta(&emitted);
let root_inputs = mounted.geometry(&app).text_inputs;
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(&mut app, task).await;
assert!(
app.reference_input_active(),
"the real App chooser must open"
);
let popup_focus = mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
assert!(
popup_focus
.iter()
.any(|(id, bounds, focused)| id.is_none() && *focused && !root_inputs.contains(bounds)),
"the newly mounted nested chooser search, absent from root inputs, must be focused"
);
mounted.perform(&mut app, delayed).await;
mounted.operate(&app, &mut *retained_apply);
assert_eq!(
mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
popup_focus,
"both delayed Collect and deliberately retained Apply respect the actual nested overlay owner"
);
assert!(!focused(&mut mounted, &app, "viewport"));
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
for key_name in [Named::F6, Named::Tab, Named::ArrowRight] {
let emitted = mounted
.batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable)
.await;
assert!(
!emitted
.iter()
.any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
);
}
// Tab may dismiss the chooser to its owning field; Escape closes it if still open.
if app.reference_input_active() {
mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
}
assert!(!app.reference_input_active());
let returned_focus = mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
mounted.operate(&app, &mut *retained_apply);
assert_eq!(
mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
returned_focus,
"the same owner after chooser Back has a different lifetime; retained Apply must stay inert"
);
release_middle(&mut mounted, &mut app, point + iced::Vector::new(50., 35.)).await;
let task = app.update(retained);
mounted.perform(&mut app, task).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera,
"a disabled-and-reenabled canvas cannot resume its pre-chooser pan"
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await;
mounted.operate(&app, &mut *retained_apply);
assert!(focused(&mut mounted, &app, "viewport"));
let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable);
let delayed = app.update(
entry
.into_iter()
.find(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
.unwrap(),
);
let task = app.update(Message::StartSemanticReview(trace));
mounted.perform(&mut app, task).await;
let facet = FacetSelector::Header(HeaderFacet::Parent);
for row in [
crate::semantic_review::RowEvent::Select(facet.clone()),
crate::semantic_review::RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
] {
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("actual Review dialog opens");
};
let message = state.address_event(row);
let task = app.update(Message::Workflow(crate::workflow::Event::semantic(message)));
mounted.perform(&mut app, task).await;
}
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review remains open");
};
let review = state.recovery_snapshot().unwrap();
let review_id = crate::forms::reference_chooser::trigger::field_id(
state
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused)
);
let scrolls = mounted.geometry(&app).scrolls;
mounted.perform(&mut app, delayed).await;
mounted.operate(&app, &mut *retained_apply);
assert_eq!(
mounted.geometry(&app).scrolls,
scrolls,
"a retained Apply must not scroll the new owner"
);
for key_name in [Named::F6, Named::ArrowRight] {
let emitted = mounted
.batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable)
.await;
assert!(
!emitted
.iter()
.any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
);
}
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused)
);
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review retains ownership");
};
assert_eq!(state.recovery_snapshot().unwrap(), review);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert_eq!(app.document.bundle(), &source);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_form_replacement_resets_scroll_without_a_closed_render() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::advanced::widget;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 520.);
let mut mounted = Mounted::new(&mut app).await;
let source = app.document.bundle().clone();
let snapshot = app.document.snapshot();
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + iced::Vector::new(65., 80.);
mounted
.pan(&mut app, point, point + iced::Vector::new(-15., 24.))
.await;
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let task = app.update(event(Event::Start(f.owner, Tool::Branch, None)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
let session = app.behavior.topology.draft.as_ref().unwrap().invocation;
mounted.scroll(&app, "process-form", 700.);
let offset = mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1;
assert!(
offset.y > 0.,
"real production Branch fields must overflow this viewport"
);
let task = app.update(event(Event::Name("Preserve this same session".into())));
mounted.perform(&mut app, task).await;
assert_eq!(
mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1,
offset
);
assert_eq!(
app.behavior.topology.draft.as_ref().unwrap().invocation,
session
);
let task = app.update(event(Event::Cancel));
mounted.perform(&mut app, task).await;
let task = app.update(event(Event::Start(f.owner, Tool::Branch, None)));
mounted.perform(&mut app, task).await;
assert_ne!(
app.behavior.topology.draft.as_ref().unwrap().invocation,
session
);
// No closed-state App::view was rendered between Cancel and Start.
let geometry = mounted.geometry(&app);
assert_eq!(
geometry.scrolls[&widget::Id::new("process-form")].1,
iced::Vector::ZERO
);
assert_eq!(geometry.bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
assert_eq!(app.document.bundle(), &source);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
retained_toolbar_boundaries(&mut mounted, &mut app, "OR").await;
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 420.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
overflow_last_tool(&mut mounted, &mut app, "->").await;
assert_eq!(app.behavior.topology.tool, CanvasTool::Connect);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}