Files
cadcraft/crates/ui-egui/tests/docking_workflows.rs

362 lines
18 KiB
Rust

//! Panel moves use the real app shell, retain commands, and survive a complete UI-state reload.
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel};
use egui::{Event, Modifiers, PointerButton, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, CadApp> {
let mut app = CadApp::new(Session::default(), Services::default());
app.run("rectang", json!({"p1":[30,30],"p2":[170,110]})).unwrap();
app.run("circle", json!({"center":[100,70],"radius":26})).unwrap();
app.run("line", json!({"points":[[30,30],[170,110]]})).unwrap();
app.run("view.set", json!({"center":[100,70],"height":220})).unwrap();
if let Some(state) = state {
app.ui = state;
}
app.ui.in_window_menu = false;
app.integrated_titlebar = true;
let mut harness = Harness::builder().with_size(vec2(width, 760.0)).with_pixels_per_point(scale).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
harness.run_steps(5);
harness
}
fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) {
harness.event(Event::PointerMoved(from));
harness.run_steps(2);
harness.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
harness.run_steps(2);
for step in 1..=8 {
harness.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
harness.run_steps(1);
}
harness.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
harness.run_steps(4);
}
#[test]
fn real_panel_drag_float_close_reopen_and_full_workspace_reload() {
let mut harness = harness(None, 1280.0, 1.0);
let source = harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").rect().center();
let target = harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").rect().center();
drag(&mut harness, source, target);
let location = harness.state().ui.docking.layout.location(&Panel::Layers).unwrap();
assert_eq!(location.anchor, Some(Panel::ToolSets));
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
harness.run_steps(3);
harness.get_by_label("Float panel").click();
harness.run_steps(5);
let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect;
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
harness.run_steps(3);
harness.get_by_label("Close panel").click();
harness.run_steps(4);
assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers));
harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
harness.run_steps(3);
harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
harness.run_steps(4);
assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect);
let saved = serde_json::to_string(&harness.state().ui).unwrap();
let expected = serde_json::to_value(&harness.state().ui.docking).unwrap();
let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0);
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
reloaded.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center"})).unwrap();
reloaded.run_steps(4);
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").click();
reloaded.run_steps(3);
reloaded.get_by_label("Line").click();
reloaded.run_steps(3);
assert_eq!(reloaded.state().session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
}
#[test]
fn invalid_requests_leave_document_and_panel_layout_unchanged() {
let mut harness = harness(None, 1000.0, 1.0);
let before = serde_json::to_value(&harness.state().ui.docking).unwrap();
let drawing = harness.state().session.doc().unwrap().clone();
for params in [
json!({"operation":"float", "panel":"canvas", "rect":[100,100,400,300]}),
json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}),
json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}),
json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}),
] {
assert!(harness.state_mut().run("ui.dock", params).is_err());
assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before);
assert_eq!(harness.state().session.doc().unwrap(), &drawing);
}
}
#[test]
fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() {
for width in [900.0, 1280.0] {
for scale in [1.0, 2.0] {
let mut harness = harness(None, width, scale);
for state in ["default", "floating", "redocked"] {
if state == "floating" {
harness.state_mut().run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[160,100,300,380]})).unwrap();
} else if state == "redocked" {
harness
.state_mut()
.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"center"}))
.unwrap();
}
harness.run_steps(5);
harness.state().ui.docking.validate().unwrap();
let content = harness.state().ui.docking.layout.panels();
assert_eq!(content.len(), 4);
assert!(content.contains(&&Panel::Canvas));
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
std::fs::create_dir_all(&directory).unwrap();
harness.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{state}.png")).unwrap();
}
}
}
}
}
#[test]
fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() {
use craft_ui::{docking::Action, layout::SplitSize};
let mut h = harness(None, 1280.0, 1.0);
h.state_mut()
.run(
"ui.dock",
json!({"action":serde_json::to_value(Action::<Panel>::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(280.0) }).unwrap()}),
)
.unwrap();
h.state_mut().run("ui.dock", json!({"operation":"setStackOpen","panel":"layers","open":false})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"resizeStack","panel":"layers","height":160.0})).unwrap();
assert_eq!(h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack, Some((false, Some(160.0))));
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap();
h.run_steps(4);
let panels = h.state().ui.docking.layout.panels();
assert!(panels.iter().position(|p| **p == Panel::Properties).unwrap() < panels.iter().position(|p| **p == Panel::Layers).unwrap());
assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap().size, Some(SplitSize::FixedFirst(280.0)));
}
#[test]
fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() {
let mut h = harness(None, 1280.0, 1.0);
let drawing = h.state().session.doc().unwrap().clone();
let center = h
.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels")
.into_iter()
.find(|node| node.rect().width() > node.rect().height())
.unwrap()
.rect()
.center();
drag(&mut h, center, center + vec2(0.0, -45.0));
let height = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap();
h.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels")
.into_iter()
.find(|node| node.rect().width() > node.rect().height())
.unwrap()
.focus();
h.run_steps(2);
h.key_press(egui::Key::ArrowDown);
h.run_steps(4);
let after = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap();
assert!(after > height, "keyboard resize must reach the same panel command handler");
assert_eq!(h.state().session.doc().unwrap(), &drawing);
}
#[test]
fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() {
use craft_ui::docking::{Action, Placement};
for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] {
let mut workspace = cadcraft_ui_egui::docking::Workspace::default();
let rect = [50.0, 60.0, 320.0, 400.0];
workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap();
workspace
.apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } })
.unwrap();
let expected = workspace.layout.floating[0].panels.clone();
for panel in order {
workspace.set_visible(panel, false).unwrap();
}
let saved = serde_json::to_string(&workspace).unwrap();
workspace = serde_json::from_str(&saved).unwrap();
workspace.reveal_panel(Panel::Properties).unwrap();
workspace.reveal_panel(Panel::Layers).unwrap();
assert_eq!(workspace.layout.floating.len(), 1);
assert_eq!(workspace.layout.floating[0].panels, expected);
assert_eq!(workspace.layout.floating[0].rect, rect);
}
}
#[test]
fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
let mut harness = harness(None, 1000.0, 1.0);
harness.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
harness.state_mut().cmdline("PROPERTIES");
assert!(harness.state().ui.docking.layout.contains(&Panel::Properties));
harness.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
harness.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
harness.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap();
harness.state_mut().run("ui.panel.properties", json!({})).unwrap();
harness.run_steps(3);
let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap();
assert_eq!(location.anchor, Some(Panel::Layers));
let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
}
#[test]
fn middle_press_over_floating_panel_does_not_pan_drawing() {
let mut harness = harness(None, 1280.0, 1.0);
harness.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
harness.run_steps(4);
let before = harness.state().session.state().unwrap().view();
let at = egui::pos2(450.0, 330.0);
harness.event(Event::PointerMoved(at));
harness.event(Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE });
harness.run_steps(2);
harness.event(Event::PointerMoved(at + egui::vec2(40.0, 25.0)));
harness.run_steps(3);
let after = harness.state().session.state().unwrap().view();
assert_eq!(after.center, before.center);
assert_eq!(after.height, before.height);
harness.event(Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE });
harness.run_steps(2);
}
fn type_command(h: &mut Harness<'_, CadApp>, command: &str) {
h.ctx.memory_mut(|memory| {
if let Some(id) = memory.focused() {
memory.surrender_focus(id);
}
});
h.event(Event::Text(command.into()));
h.run_steps(1);
assert_eq!(h.state().cmd.buffer, command);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert!(h.state().cmd.buffer.is_empty());
}
#[test]
fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspace() {
let mut h = harness(None, 1280.0, 1.0);
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
h.run_steps(3);
let drawing = h.state().session.doc().unwrap().clone();
let tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap();
type_command(&mut h, "PROPERTIES");
assert!(h.state().ui.docking.layout.contains(&Panel::Properties));
assert!(h.state().ui.docking.layout.contains(&Panel::Layers));
let restored_tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(restored_tools.anchor, tools.anchor);
assert_eq!(restored_tools.placement, tools.placement);
assert_eq!(restored_tools.size, tools.size);
assert_eq!(restored_tools.floating, tools.floating);
assert_eq!(restored_tools.stack, tools.stack);
assert!(restored_tools.sibling.contains(&Panel::Properties));
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap();
h.run_steps(3);
type_command(&mut h, "PROPERTIES");
let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
assert_eq!(h.state().session.doc().unwrap(), &drawing);
}
#[test]
fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() {
let mut h = harness(None, 1280.0, 1.0);
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,200,300,350]})).unwrap();
h.run_steps(3);
let drawing = h.state().session.doc().unwrap().clone();
type_command(&mut h, "ui.dock.reset");
let expected = cadcraft_ui_egui::docking::Workspace::default();
assert_eq!(h.state().ui.docking.layout, expected.layout);
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
assert_eq!(h.state().session.doc().unwrap(), &drawing);
}
#[test]
fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
use craft_ui::docking::Action;
use craft_ui::layout::SplitSize;
let mut h = harness(None, 1280.0, 1.0);
// Give the old root divider a size different from Reset's 220px ToolSets pane.
h.state_mut()
.run(
"ui.dock",
json!({"action":Action::<Panel>::ResizeSplit {
path: Vec::new(), size: SplitSize::FixedFirst(280.0),
}}),
)
.unwrap();
h.run_steps(3);
let drawing = h.state().session.doc().unwrap().clone();
let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center();
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
// Canvas is visited before the ancestor ResizeHandle. The command runs before
// that ancestor queues this frame's ResizeSplit against the old immutable tree.
assert_eq!(h.ctx.dragged_id(), Some(splitter), "actual divider owns the pointer");
assert!(h.ctx.input(|input| input.pointer.primary_down()));
// A ResizeHandle takes keyboard focus on press. Release only that keyboard focus
// so the real Canvas command line can submit while the pointer remains captured.
h.ctx.memory_mut(|memory| memory.surrender_focus(splitter));
assert!(!h.ctx.egui_wants_keyboard_input());
let to = from + vec2(36.0, 0.0);
h.event(Event::PointerMoved(to));
h.event(Event::Text("ui.dock.reset".into()));
h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
let expected = cadcraft_ui_egui::docking::Workspace::default();
assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command");
assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree");
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
// Keep the physical button down: replacement must terminate the old divider gesture,
// rather than letting its stable root ID continue resizing the new workspace.
let later = to + vec2(24.0, 0.0);
h.event(Event::PointerMoved(later));
h.run_steps(2);
assert_eq!(h.state().ui.docking.layout, expected.layout, "continued old drag must leave the replacement unchanged");
h.event(Event::PointerButton { pos: later, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(h.state().ui.docking.layout, expected.layout);
assert_eq!(h.state().session.doc().unwrap(), &drawing);
}
#[test]
fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() {
let mut h = harness(None, 1280.0, 1.0);
h.get_by_label("Collapse Tool Sets").click();
h.run_steps(4);
assert!(!h.state().ui.show_toolsets);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").is_none());
h.get_by_label("Expand Tool Sets").click();
h.run_steps(4);
assert!(h.state().ui.show_toolsets);
assert!(h.query_by_label("Expand Tool Sets").is_none());
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").click();
h.run_steps(3);
h.get_by_label("Line").click();
h.run_steps(3);
assert_eq!(h.state().session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
h.state_mut().session.cancel();
// Window > Tool Sets (Cmd+3) stays the keyboard route back.
h.get_by_label("Collapse Tool Sets").click();
h.run_steps(4);
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::Num3);
h.run_steps(4);
assert!(h.state().ui.show_toolsets);
}