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