888 lines
36 KiB
Rust
888 lines
36 KiB
Rust
use super::*;
|
|
|
|
#[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;
|
|
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::Tools, 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", "Panels", "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, "Canvas").await;
|
|
mounted
|
|
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
|
.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;
|
|
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;
|
|
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());
|
|
}
|