effectcraft/crates/ui-egui/tests/shared_docking_ui.rs

404 lines
20 KiB
Rust

//! Existing EffectCraft workspaces use shared transactions without changing their saved schema.
use craft_ui::docking::{Action, Placement, Zone};
use effectcraft_engine::Session;
use effectcraft_ui_egui::{
EffectcraftApp,
dock::{self, DockNode, Layout, PanelKind, SplitSize, StackEntry},
};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
#[test]
fn shared_move_preserves_accordion_metadata_and_unrelated_active_tabs() {
let mut layout = Layout {
root: DockNode::Split {
vertical: false,
size: SplitSize::FixedB(300.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Composition, PanelKind::Layer], active: 1 }),
b: Box::new(DockNode::Stack {
entries: vec![
StackEntry::new(PanelKind::Preview, false, Some(73.0)),
StackEntry::new(PanelKind::Properties, true, Some(211.0)),
StackEntry::new(PanelKind::Align, false, None),
],
}),
},
floating: vec![],
};
layout.apply(Action::Close { panel: PanelKind::Project }).unwrap();
assert!(layout.root.is_visible(PanelKind::Composition));
layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Align, placement: Placement::Tab { before: None } }).unwrap();
let DockNode::Split { b, size, .. } = &layout.root else { panic!("split was lost") };
assert_eq!(*size, SplitSize::FixedB(300.0));
let DockNode::Stack { entries } = b.as_ref() else { panic!("stack was lost") };
// Upstream now makes a tab drop join the anchor's accordion frame. Its height belongs
// to the destination frame; the unrelated Properties frame keeps its exact metadata.
let joined = entries.iter().find(|entry| entry.contains(PanelKind::Preview)).unwrap();
assert_eq!(joined.panels, vec![PanelKind::Align, PanelKind::Preview]);
assert_eq!(joined.shown(), Some(PanelKind::Preview));
assert!(joined.open);
assert_eq!(joined.height, None);
assert_eq!(entries.iter().find(|entry| entry.contains(PanelKind::Properties)).unwrap(), &StackEntry::new(PanelKind::Properties, true, Some(211.0)));
let before = layout.clone();
assert!(layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Audio, placement: Placement::Split(Zone::Left) }).is_err());
assert_eq!(layout, before);
assert!(layout.apply(Action::Float { panel: PanelKind::Preview, rect: [0.0, 0.0, f32::INFINITY, 200.0] }).is_err());
assert_eq!(layout, before);
}
#[test]
fn every_builtin_workspace_and_dynamic_identity_roundtrips() {
for name in dock::WORKSPACES {
let mut layout = Layout { root: dock::workspace(name), floating: dock::workspace_floating(name) };
let before = serde_json::to_value(&layout).unwrap();
// A structural no-op still crosses both adapters and shared validation. The complete
// persisted shape must remain byte-equivalent, including accordion state and heights.
let action = match &layout.root {
DockNode::Split { size, .. } => Action::ResizeSplit {
path: vec![],
size: match *size {
SplitSize::Ratio(v) => craft_ui::layout::SplitSize::Ratio(v),
SplitSize::FixedA(v) => craft_ui::layout::SplitSize::FixedFirst(v),
SplitSize::FixedB(v) => craft_ui::layout::SplitSize::FixedSecond(v),
},
},
DockNode::Tabs { panels, .. } => Action::Move { panel: panels[0], anchor: panels[1], placement: Placement::Tab { before: Some(panels[0]) } },
DockNode::Stack { entries } => Action::SetStackOpen { panel: entries[0].shown().unwrap(), open: entries[0].open },
};
layout.apply(action).unwrap();
assert_eq!(before, serde_json::to_value(&layout).unwrap());
let back: Layout = serde_json::from_value(before).unwrap();
assert_eq!(layout, back);
}
let mut layout = Layout {
root: DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], active: 1 },
floating: vec![],
};
layout.apply(Action::Float { panel: PanelKind::ScriptPanel(77), rect: [20.0, 30.0, 400.0, 300.0] }).unwrap();
layout.apply(Action::Move { panel: PanelKind::ScriptPanel(77), anchor: PanelKind::Viewer(42), placement: Placement::Tab { before: None } }).unwrap();
assert!(layout.floating.is_empty());
assert!(layout.contains(PanelKind::Viewer(42)));
assert!(layout.root.is_visible(PanelKind::ScriptPanel(77)));
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let element = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3]))
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
h.step();
for fraction in [0.05, 0.25, 0.5, 0.75, 1.0] {
h.event(Event::PointerMoved(from.lerp(to, fraction)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
h.step();
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
#[test]
fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Docking composition", "width": 320, "height": 180, "duration": 4})).unwrap();
session.execute("layer.newSolid", json!({"name": "Teal plate", "width": 320, "height": 180, "color": "#245b68"})).unwrap();
session.execute("layer.newSolid", json!({"name": "Coral tile", "width": 110, "height": 70, "color": "#ee7859"})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(400.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::Audio], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Layer], active: 0 }),
};
app.ui.dock = DockNode::Split {
vertical: true,
size: SplitSize::Ratio(0.62),
a: Box::new(app.ui.dock),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }),
};
let snapshots = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut builder = Harness::builder().with_size(vec2(1200.0, 760.0));
if snapshots.is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_| app);
h.run_steps(4);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while h.state().frames.urgent_pending() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!h.state().frames.urgent_pending());
h.run_steps(3);
let project = h.state().session.project.clone();
let times = h.state().session.state.times.clone();
let mut action_log = vec![action_state(&h, "start")];
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Composition").center();
drag(&mut h, from, to, Modifiers::NONE);
assert!(h.state().ui.dock.is_visible(PanelKind::Info));
assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Composition));
action_log.push(action_state(&h, "merged"));
let from = rect(&h, "panel.tab.Info").center();
let before = rect(&h, "panel.tab.Composition");
drag(&mut h, from, pos2(before.left() + 2.0, before.center().y), Modifiers::NONE);
assert_eq!(tabs_containing(&h.state().ui.dock, PanelKind::Info).first(), Some(&PanelKind::Info));
action_log.push(action_state(&h, "reordered"));
let from = rect(&h, "panel.tab.Info").center();
drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND);
assert_eq!(h.state().ui.floating.len(), 1);
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button);
action_log.push(action_state(&h, "floated"));
let original = h.state().ui.floating[0].rect;
let grip = rect(&h, "panel.float.0.resize").center();
drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE);
assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0);
assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0);
action_log.push(action_state(&h, "resized"));
let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
let before_keys = h.state().ui.floating[0].rect;
for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] {
for pressed in [true, false] {
h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
}
assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0);
assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0);
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
action_log.push(action_state(&h, "keyboard-resized"));
let resized = h.state().ui.floating[0].rect;
let header = rect(&h, "panel.float.0.move");
let from = pos2(header.right() - 24.0, header.center().y);
drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE);
assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0);
assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0);
action_log.push(action_state(&h, "moved"));
if let Some(directory) = &snapshots {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("effectcraft-floating.png")).unwrap();
}
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Audio").center();
drag(&mut h, from, to, Modifiers::NONE);
assert!(h.state().ui.floating.is_empty());
action_log.push(action_state(&h, "redocked"));
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
assert_eq!(times, h.state().session.state.times);
assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.layer.")));
h.state_mut().session.execute("view.exposure", json!({"stops": 2.0})).unwrap();
h.run_steps(3);
let pos = rect(&h, "viewer.resetExposure").center();
h.event(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.state.viewer.exposure, 0.0);
action_log.push(action_state(&h, "viewer-reset-exposure"));
if let Some(directory) = &snapshots {
std::fs::write(directory.join("effectcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap();
}
if let Some(directory) = &snapshots {
h.render().unwrap().save(directory.join("effectcraft-redocked.png")).unwrap();
}
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool {
match root {
DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second),
DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second),
DockNode::Stack { .. } => false,
}
}
#[test]
fn current_and_named_floating_layouts_survive_restart() {
use effectcraft_engine::config::MemoryConfig;
use std::sync::Arc;
let store = Arc::new(MemoryConfig::default());
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
assert!(app.edit_layout(|layout| layout.apply(Action::Float { panel: PanelKind::Project, rect: [45.0, 65.0, 430.0, 310.0] }).is_ok()));
let ctx = egui::Context::default();
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Shared float"})).unwrap();
let saved = app.ui.floating.clone();
assert!(app.edit_layout(|layout| layout.apply(Action::MoveFloating { panel: PanelKind::Project, rect: [75.0, 95.0, 480.0, 360.0] }).is_ok()));
let current = app.ui.floating.clone();
eframe::App::on_exit(&mut app);
let mut restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
assert_eq!(restarted.ui.floating, current);
restarted.set_workspace("Default");
restarted.set_workspace("Shared float");
assert_eq!(restarted.ui.floating, saved);
}
fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec<PanelKind> {
match root {
DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(),
DockNode::Split { a, b, .. } => {
let first = tabs_containing(a, panel);
if first.is_empty() { tabs_containing(b, panel) } else { first }
}
_ => vec![],
}
}
#[test]
fn float_command_rejects_malformed_rectangles_atomically() {
let mut app = EffectcraftApp::new(Session::default());
let before = app.ui.dock.clone();
let ctx = egui::Context::default();
for rect in [json!([1, 2, 300]), json!([1, 2, 300, 200, 7]), json!([1, 2, 1e100, 200]), json!("rectangle")] {
assert!(effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": rect})).is_err());
assert_eq!(app.ui.dock, before);
assert!(app.ui.floating.is_empty());
}
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 };
assert!(!app.edit_layout(|layout| layout.apply(Action::Float { panel: PanelKind::Project, rect: [1.0, 2.0, 300.0, 200.0] }).is_ok()));
}
fn action_state(h: &Harness<'_, EffectcraftApp>, action: &str) -> serde_json::Value {
json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused})
}
#[test]
fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(300.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }),
};
let ctx = egui::Context::default();
for action in [
Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) },
Action::Move { panel: PanelKind::Info, anchor: PanelKind::Project, placement: Placement::Tab { before: Some(PanelKind::Project) } },
Action::Float { panel: PanelKind::Info, rect: [20.0, 40.0, 300.0, 200.0] },
Action::MoveFloating { panel: PanelKind::Info, rect: [45.0, 65.0, 330.0, 240.0] },
] {
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap();
}
assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]);
let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") };
assert_eq!(*size, SplitSize::FixedA(360.0));
let before = json!({"dock": app.ui.dock, "floating": app.ui.floating});
assert!(
effectcraft_ui_egui::menus::invoke(
&mut app,
&ctx,
"window.dockAction",
json!({"action": {
"ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}}
}})
)
.is_err()
);
assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before);
}
#[test]
fn catalog_close_keeps_dynamic_panel_identity() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], active: 1 };
let ctx = egui::Context::default();
for panel in [PanelKind::Viewer(42), PanelKind::ScriptPanel(77)] {
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": Action::Close { panel }})).unwrap();
assert!(!app.ui.dock.contains(panel));
}
assert!(app.ui.dock.contains(PanelKind::Composition));
}
#[test]
fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries() {
let mut session = Session::default();
session.execute("comp.new", json!({"width": 160, "height": 90, "duration": 1})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
let mut h = Harness::builder().with_size(vec2(1000.0, 720.0)).build_eframe(move |_| app);
h.run_steps(4);
h.state_mut().ui.dock = DockNode::Stack {
entries: vec![
StackEntry::new(PanelKind::Preview, true, Some(100.0)),
StackEntry::new(PanelKind::Properties, true, None),
StackEntry::new(PanelKind::Align, false, Some(77.0)),
],
};
h.run_steps(3);
let project = h.state().session.project.clone();
let id = egui::Id::new(("dock-gutter", "s0".to_string()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
for pressed in [true, false] {
h.event(Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
assert_eq!(entries[0].height, Some(108.0));
let from = rect(&h, "dock.gutter.s0").center();
drag(&mut h, from, from + vec2(0.0, 16.0), Modifiers::NONE);
let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
assert_eq!(entries[0].height, Some(124.0));
assert_eq!(entries[1].height, None);
assert_eq!(entries[2], StackEntry::new(PanelKind::Align, false, Some(77.0)));
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
#[test]
fn legacy_close_repairs_selection_but_transactions_remain_strict() {
use effectcraft_ui_egui::dock::Floating;
let mut root = DockNode::Tabs { panels: vec![PanelKind::Properties, PanelKind::Align, PanelKind::EffectsPresets], active: usize::MAX };
root.close(PanelKind::History);
assert!(root.is_visible(PanelKind::EffectsPresets));
let mut layout = Layout {
root: DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 },
floating: vec![Floating {
panels: vec![PanelKind::Properties, PanelKind::Align, PanelKind::EffectsPresets],
active: usize::MAX,
rect: [0.0, 0.0, 300.0, 200.0],
}],
};
let before = layout.clone();
assert!(layout.apply(Action::Close { panel: PanelKind::Properties }).is_err());
assert_eq!(layout, before);
assert!(layout.unfloat(PanelKind::Properties));
assert_eq!(layout.floating[0].panels.get(layout.floating[0].active), Some(&PanelKind::EffectsPresets));
layout.floating[0].rect = [0.0; 4];
let before = layout.clone();
assert!(!layout.unfloat(PanelKind::Align));
assert_eq!(layout, before);
}
#[test]
fn failed_close_preserves_viewer_lifecycle_and_maximize_state() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42)], active: usize::MAX };
app.ui.maximized = Some(PanelKind::Viewer(42));
app.ui.viewers.insert(42, None);
app.ui.active_viewer = 42;
app.ui.locked_tabs.insert(PanelKind::Viewer(42).id());
let before = app.ui.clone();
app.close_panel(PanelKind::Viewer(42));
assert_eq!(app.ui.dock, before.dock);
assert_eq!(app.ui.maximized, before.maximized);
assert_eq!(app.ui.viewers, before.viewers);
assert_eq!(app.ui.active_viewer, before.active_viewer);
assert_eq!(app.ui.locked_tabs, before.locked_tabs);
}