317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
|
|
//! Dock / float behavior tests (pure-Rust, no `egui::Context`).
|
||
|
|
//!
|
||
|
|
//! These tests exercise the regression-prone state-machine logic of
|
||
|
|
//! `DockController` and `dock_engine::PanelTree`: deferred-queue frame timing,
|
||
|
|
//! float source-leaf restore, EmptyCenter insertion, document/tools cannot
|
||
|
|
//! float, persistence round-trip, and document index shifting. They run in CI
|
||
|
|
//! without a GPU. Render / floating-window visual behavior is covered by the
|
||
|
|
//! `--screenshot-panel` baseline pipeline instead.
|
||
|
|
//!
|
||
|
|
//! See AGENTS.md "Dock / Float Regression Protection" for the protected
|
||
|
|
//! invariants each test guards.
|
||
|
|
|
||
|
|
use hcie_gui_egui::app::dock::HciePane;
|
||
|
|
use hcie_gui_egui::app::dock_controller::DockController;
|
||
|
|
use hcie_gui_egui::app::dock_engine::{default_tree, PaneRole, PanelTree};
|
||
|
|
|
||
|
|
fn rect() -> [f32; 4] {
|
||
|
|
[100.0, 100.0, 320.0, 480.0]
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Tree / default layout ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tree_default_has_document_in_center_leaf() {
|
||
|
|
let tree = default_tree();
|
||
|
|
let editor = tree
|
||
|
|
.find_pane_with_role(PaneRole::Editor)
|
||
|
|
.expect("default tree has an editor leaf");
|
||
|
|
let (tabs, _, _) = tree.leaf(editor).expect("editor leaf exists");
|
||
|
|
assert!(
|
||
|
|
tabs.iter().any(|t| matches!(t.id, HciePane::Document(0))),
|
||
|
|
"default editor leaf must hold Document(0)"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tree_default_has_expected_panes() {
|
||
|
|
let tree = default_tree();
|
||
|
|
for pane in [
|
||
|
|
HciePane::Brushes,
|
||
|
|
HciePane::Filters,
|
||
|
|
HciePane::ColorBox,
|
||
|
|
HciePane::History,
|
||
|
|
HciePane::AiAssistant,
|
||
|
|
HciePane::Layers,
|
||
|
|
HciePane::Properties,
|
||
|
|
HciePane::Document(0),
|
||
|
|
] {
|
||
|
|
assert!(tree.has_tab(&pane), "default tree missing {:?}", pane);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tree_persist_roundtrip_topology_identical() {
|
||
|
|
let tree = default_tree();
|
||
|
|
let persisted = tree.persist();
|
||
|
|
let restored = PanelTree::from_persisted(persisted).expect("round-trip");
|
||
|
|
assert_eq!(
|
||
|
|
restored.root, tree.root,
|
||
|
|
"topology must be identical after round-trip"
|
||
|
|
);
|
||
|
|
assert_eq!(restored.next_pane_id, tree.next_pane_id);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tree_from_persisted_rejects_wrong_version() {
|
||
|
|
let mut persisted = default_tree().persist();
|
||
|
|
persisted.version = 999;
|
||
|
|
assert!(PanelTree::from_persisted(persisted).is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Controller: active tab / first-frame invariant ─────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn new_controller_active_tab_is_document_zero() {
|
||
|
|
let ctrl = DockController::new();
|
||
|
|
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
||
|
|
assert_eq!(ctrl.last_active_doc(), Some(0));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Float / dock ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn float_then_dock_restores_source_leaf() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
let pane = HciePane::Layers;
|
||
|
|
assert!(ctrl.is_pinned(&pane));
|
||
|
|
assert!(!ctrl.is_floating(&pane));
|
||
|
|
|
||
|
|
assert!(ctrl.float_pane(&pane, rect()));
|
||
|
|
assert!(ctrl.is_floating(&pane));
|
||
|
|
assert!(!ctrl.is_pinned(&pane));
|
||
|
|
assert!(!ctrl.has_tab(&pane));
|
||
|
|
|
||
|
|
assert!(ctrl.dock_pane(&pane));
|
||
|
|
assert!(!ctrl.is_floating(&pane));
|
||
|
|
assert!(ctrl.is_pinned(&pane));
|
||
|
|
assert!(ctrl.has_tab(&pane));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn float_twice_no_duplicate_floating_entry() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
let pane = HciePane::Layers;
|
||
|
|
assert!(ctrl.float_pane(&pane, rect()));
|
||
|
|
assert!(!ctrl.float_pane(&pane, rect()));
|
||
|
|
assert_eq!(ctrl.floating_count(), 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn document_and_tools_cannot_float() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
assert!(!ctrl.float_pane(&HciePane::Document(0), rect()));
|
||
|
|
assert!(!ctrl.float_pane(&HciePane::Tools, rect()));
|
||
|
|
assert_eq!(ctrl.floating_count(), 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn queue_float_rejects_document_and_tools() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.queue_float(HciePane::Document(0));
|
||
|
|
ctrl.queue_float(HciePane::Tools);
|
||
|
|
// Queue lengths: [close, float, focus, collapse]
|
||
|
|
let [_, float, _, _] = ctrl.queue_lengths();
|
||
|
|
assert_eq!(float, 0, "Document/Tools must not be enqueued for float");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Close ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn close_removes_from_pinned_and_floating() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
let pane = HciePane::Layers;
|
||
|
|
assert!(ctrl.float_pane(&pane, rect()));
|
||
|
|
assert!(ctrl.close_tab(&pane));
|
||
|
|
assert!(!ctrl.is_floating(&pane));
|
||
|
|
assert!(!ctrl.is_pinned(&pane));
|
||
|
|
assert!(!ctrl.has_tab(&pane));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn close_docked_pane_removes_from_tree_and_pinned() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
let pane = HciePane::Layers;
|
||
|
|
assert!(ctrl.has_tab(&pane));
|
||
|
|
assert!(ctrl.close_tab(&pane));
|
||
|
|
assert!(!ctrl.has_tab(&pane));
|
||
|
|
assert!(!ctrl.is_pinned(&pane));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Document push / remove / shift ────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn push_document_sets_active_tab_to_document() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.push_document(1);
|
||
|
|
assert_eq!(*ctrl.active_tab(), HciePane::Document(1));
|
||
|
|
assert_eq!(ctrl.last_active_doc(), Some(1));
|
||
|
|
assert!(ctrl.has_tab(&HciePane::Document(1)));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn push_document_removes_empty_center() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
// Simulate all documents closed -> EmptyCenter inserted.
|
||
|
|
ctrl.remove_document(0);
|
||
|
|
ctrl.drain_queues();
|
||
|
|
assert!(ctrl.empty_center_present());
|
||
|
|
ctrl.push_document(0);
|
||
|
|
assert!(
|
||
|
|
!ctrl.empty_center_present(),
|
||
|
|
"EmptyCenter must be removed once a document exists"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn remove_document_shifts_indices_in_tabs() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.push_document(1);
|
||
|
|
ctrl.push_document(2);
|
||
|
|
assert!(ctrl.has_tab(&HciePane::Document(2)));
|
||
|
|
|
||
|
|
// Remove doc index 1 -> doc 2 should become doc 1.
|
||
|
|
ctrl.remove_document(1);
|
||
|
|
assert!(ctrl.has_tab(&HciePane::Document(1)));
|
||
|
|
assert!(!ctrl.has_tab(&HciePane::Document(2)));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn remove_last_document_updates_active_to_empty_center() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.remove_document(0);
|
||
|
|
ctrl.drain_queues();
|
||
|
|
assert_eq!(*ctrl.active_tab(), HciePane::EmptyCenter);
|
||
|
|
assert_eq!(ctrl.last_active_doc(), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── EmptyCenter placeholder ────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn emptycenter_inserted_when_no_documents() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
assert!(!ctrl.empty_center_present());
|
||
|
|
ctrl.remove_document(0);
|
||
|
|
let outcome = ctrl.drain_queues();
|
||
|
|
assert!(outcome.empty_center_inserted);
|
||
|
|
assert!(ctrl.empty_center_present());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── drain_queues frame-timing invariant ───────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn queue_close_float_focus_drained_in_render_order() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.queue_close(HciePane::Layers);
|
||
|
|
ctrl.queue_float(HciePane::Properties);
|
||
|
|
ctrl.queue_focus(HciePane::ColorBox);
|
||
|
|
ctrl.queue_collapse(HciePane::History);
|
||
|
|
|
||
|
|
let outcome = ctrl.drain_queues();
|
||
|
|
assert_eq!(outcome.closed, 1);
|
||
|
|
assert_eq!(outcome.floated, 1);
|
||
|
|
// Properties was floated then ColorBox focus-returned to dock.
|
||
|
|
// Note: queue_focus docks a currently-floating panel; ColorBox isn't
|
||
|
|
// floating, so focus is a no-op here (returns false).
|
||
|
|
assert_eq!(outcome.focused, 0);
|
||
|
|
assert_eq!(outcome.collapsed, 1);
|
||
|
|
|
||
|
|
// All queues must be empty after drain.
|
||
|
|
assert_eq!(ctrl.queue_lengths(), [0, 0, 0, 0]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn drain_queues_is_idempotent_when_empty() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
let outcome = ctrl.drain_queues();
|
||
|
|
assert_eq!(outcome, Default::default());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn queued_float_processed_before_focus_so_focus_can_dock() {
|
||
|
|
// Float then focus-dock the same panel in one drain: float first makes it
|
||
|
|
// floating, then focus docks it back. Net: not floating.
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
let pane = HciePane::Layers;
|
||
|
|
ctrl.queue_float(pane.clone());
|
||
|
|
ctrl.queue_focus(pane.clone());
|
||
|
|
let outcome = ctrl.drain_queues();
|
||
|
|
assert_eq!(outcome.floated, 1);
|
||
|
|
assert_eq!(outcome.focused, 1);
|
||
|
|
assert!(!ctrl.is_floating(&pane));
|
||
|
|
assert!(ctrl.has_tab(&pane));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Persistence ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn persistence_roundtrip_preserves_state() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.push_document(1);
|
||
|
|
ctrl.float_pane(&HciePane::Layers, rect());
|
||
|
|
ctrl.close_tab(&HciePane::Filters);
|
||
|
|
|
||
|
|
let json = ctrl.persist();
|
||
|
|
let mut restored = DockController::new();
|
||
|
|
assert!(restored.restore(&json));
|
||
|
|
assert!(restored.has_tab(&HciePane::Document(1)));
|
||
|
|
assert!(restored.is_floating(&HciePane::Layers));
|
||
|
|
assert!(!restored.has_tab(&HciePane::Filters));
|
||
|
|
assert_eq!(*restored.active_tab(), HciePane::Document(1));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn persistence_invalid_json_falls_back_to_default() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.push_document(1);
|
||
|
|
assert!(!ctrl.restore("not valid json {"));
|
||
|
|
// Fallback resets to default.
|
||
|
|
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
||
|
|
assert!(!ctrl.is_floating(&HciePane::Layers));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn persistence_legacy_version_falls_back_to_default() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.push_document(2);
|
||
|
|
// Hand-craft a snapshot with a wrong version number.
|
||
|
|
let bad = serde_json::json!({
|
||
|
|
"version": 999,
|
||
|
|
"tree": default_tree().persist(),
|
||
|
|
"floating": [],
|
||
|
|
"pinned": [],
|
||
|
|
"active_tab": HciePane::Document(0),
|
||
|
|
"last_active_doc": 0,
|
||
|
|
})
|
||
|
|
.to_string();
|
||
|
|
assert!(!ctrl.restore(&bad));
|
||
|
|
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── ensure_pane_present (screenshot injector) ─────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ensure_pane_present_inserts_missing_pane() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
ctrl.close_tab(&HciePane::Layers);
|
||
|
|
assert!(!ctrl.has_tab(&HciePane::Layers));
|
||
|
|
assert!(ctrl.ensure_pane_present(&HciePane::Layers));
|
||
|
|
assert!(ctrl.has_tab(&HciePane::Layers));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ensure_pane_present_noop_when_already_present() {
|
||
|
|
let mut ctrl = DockController::new();
|
||
|
|
assert!(!ctrl.ensure_pane_present(&HciePane::Layers));
|
||
|
|
}
|