Files
hcie-rust-v3.05/hcie-egui-app/crates/hcie-gui-egui/tests/dock_behavior.rs
T
2026-07-09 02:59:53 +03:00

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));
}