From d20432f0873c2be66e4eae4575f45556a42f736b Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sat, 31 Oct 2026 15:02:46 -0700 Subject: [PATCH] test(core): add a recording stub platform and pin Container behaviour Adds crates/komorebi-core/tests/common (StubWindow, StubPlatform): a Clone-but-not-Copy window and a platform whose window operations and every WorkspacePlatform and MonitorPlatform hook append a line to an ordered log, with failure injection per operation (fail("hide 3"), fail("place_container")), scripted end_update passes and a per-workspace hide spot kept in the platform state the way macOS keeps its corner. The log and settings are thread-local and every test starts with fresh(). tests/container_behaviour.rs pins Container (24 tests): add/remove/focus ordering, the focus index after removal at each position (it is idx-1 whatever was focused, and dangles after an out-of-range removal), empty-container cases, the order of hide_window calls (from the back, a failing window is skipped), load_focused_window stopping at the first failure, the omitted window, restore, locking and the JSON shape. Verified: cargo test -p komorebi-core --test container_behaviour, 24 passed (nix shell gcc, own target dir). No non-test source changed. --- crates/komorebi-core/tests/common/mod.rs | 485 ++++++++++++++++++ .../tests/container_behaviour.rs | 307 +++++++++++ 2 files changed, 792 insertions(+) create mode 100644 crates/komorebi-core/tests/common/mod.rs create mode 100644 crates/komorebi-core/tests/container_behaviour.rs diff --git a/crates/komorebi-core/tests/common/mod.rs b/crates/komorebi-core/tests/common/mod.rs new file mode 100644 index 00000000..b8482c62 --- /dev/null +++ b/crates/komorebi-core/tests/common/mod.rs @@ -0,0 +1,485 @@ +//! A recording stub platform for the characterization tests of `komorebi-core`. +//! +//! `StubWindow` and `StubPlatform` log every window operation and every hook call, in order, as a +//! line of text, so that a test says exactly which calls a shared function makes, with which +//! arguments. What the stub answers (the focus, the pointer, whether a call fails, what +//! `end_update` asks for) is set per test with the functions at the bottom of this file. +//! +//! The log and the settings are thread-local, and every test starts with [`fresh`], so tests do +//! not see each other whatever the harness does with threads. + +#![allow(dead_code)] + +use color_eyre::eyre; +use komorebi_config::matching::IdWithIdentifier; +use komorebi_config::matching::MatchingRule; +use komorebi_core::Container; +use komorebi_core::ContainerWindow; +use komorebi_core::Flow; +use komorebi_core::Monitor; +use komorebi_core::MonitorPlatform; +use komorebi_core::NoExtra; +use komorebi_core::Pass; +use komorebi_core::Placement; +use komorebi_core::Workspace; +use komorebi_core::WorkspacePlatform; +use komorebi_core::WorkspaceWindow; +use komorebi_layouts::OperationDirection; +use komorebi_layouts::Rect; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; +use std::cell::RefCell; +use std::collections::HashMap; +use std::collections::HashSet; + +/// A window that is `Clone` but not `Copy`, as the macOS window is. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StubWindow { + pub id: u32, +} + +/// The platform state the stub keeps in a workspace: the spot it hides windows at, which is how +/// macOS keeps the corner of its monitor. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StubState { + pub spot: u8, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StubPlatform; + +/// What the stub answers and what it makes fail; reset by [`fresh`]. +#[derive(Default)] +struct Settings { + foreground: Option, + cursor: Option<(i32, i32)>, + /// operations that fail, by the text the log would have for them (see `fails`) + failing: HashSet, + /// what `begin_update` does: 0 runs, 1 skips + skip_update: bool, + /// what the successive calls of `end_update` answer; `Done` once it is empty + passes: Vec, + adopt_widths: bool, + scrolling_insertion_idx: Option, + /// what `scrolling_layouts` hides + scrolling_hidden: Vec, + known_names: HashMap, + /// how much `adjust_layout` shrinks a tile + adjust: i32, +} + +thread_local! { + static LOG: RefCell> = const { RefCell::new(Vec::new()) }; + static SETTINGS: RefCell = RefCell::new(Settings::default()); +} + +/// Starts a test: empties the log and sets the stub back to its defaults (the focus is on no +/// window, nothing fails, `end_update` is done after one pass). +pub fn fresh() { + LOG.with(|log| log.borrow_mut().clear()); + SETTINGS.with(|s| *s.borrow_mut() = Settings::default()); +} + +/// Takes the log, leaving it empty. +pub fn log() -> Vec { + LOG.with(|log| std::mem::take(&mut *log.borrow_mut())) +} + +fn push(line: String) { + LOG.with(|log| log.borrow_mut().push(line)); +} + +fn settings(f: impl FnOnce(&mut Settings) -> T) -> T { + SETTINGS.with(|s| f(&mut s.borrow_mut())) +} + +/// Makes the operation fail whenever it is called: the line the log has for it, without the +/// arguments that follow the name, e.g. `fail("hide 3")` (a window operation names the window) or +/// `fail("place_container")` (a hook). +pub fn fail(operation: &str) { + settings(|s| s.failing.insert(operation.to_string())); +} + +pub fn set_foreground(id: Option) { + settings(|s| s.foreground = id); +} + +pub fn set_cursor(point: Option<(i32, i32)>) { + settings(|s| s.cursor = point); +} + +pub fn skip_updates() { + settings(|s| s.skip_update = true); +} + +/// What the successive calls of `end_update` answer. +pub fn script_passes(passes: &[Pass]) { + settings(|s| s.passes = passes.iter().rev().copied().collect()); +} + +pub fn adopt_widths(yes: bool) { + settings(|s| s.adopt_widths = yes); +} + +pub fn set_scrolling_insertion_idx(idx: Option) { + settings(|s| s.scrolling_insertion_idx = idx); +} + +pub fn set_scrolling_hidden(hidden: &[bool]) { + settings(|s| s.scrolling_hidden = hidden.to_vec()); +} + +pub fn set_known_workspace_name(idx: usize, name: &str) { + settings(|s| s.known_names.insert(idx, name.to_string())); +} + +pub fn set_adjust(by: i32) { + settings(|s| s.adjust = by); +} + +fn fails(operation: &str) -> bool { + settings(|s| s.failing.contains(operation)) +} + +fn op(line: String, key: String) -> eyre::Result<()> { + push(line); + if fails(&key) { + Err(eyre::eyre!("{key} failed")) + } else { + Ok(()) + } +} + +impl ContainerWindow for StubWindow { + type Id = u32; + type HideSpot = u8; + + fn id(&self) -> u32 { + self.id + } + + fn exe_name(&self) -> Option { + Some(format!("exe{}", self.id)) + } + + fn hide_window(&self, spot: u8) -> eyre::Result<()> { + op( + format!("hide {} @{spot}", self.id), + format!("hide {}", self.id), + ) + } + + fn restore_window(&self) -> eyre::Result<()> { + op( + format!("restore {}", self.id), + format!("restore {}", self.id), + ) + } + + fn hide_stacked(&self, spot: u8) -> eyre::Result<()> { + op( + format!("hide_stacked {} @{spot}", self.id), + format!("hide_stacked {}", self.id), + ) + } + + fn restore_stacked(&self) -> eyre::Result<()> { + op( + format!("restore_stacked {}", self.id), + format!("restore_stacked {}", self.id), + ) + } +} + +impl WorkspaceWindow for StubWindow { + fn title(&self) -> Option { + Some(format!("title{}", self.id)) + } + + fn exe(&self) -> Option { + self.exe_name() + } + + fn class(&self) -> Option { + Some("class".to_string()) + } + + fn path(&self) -> Option { + None + } +} + +pub fn rect_text(rect: &Rect) -> String { + format!( + "[{} {} {} {}]", + rect.left, rect.top, rect.right, rect.bottom + ) +} + +impl WorkspacePlatform for StubPlatform { + type Window = StubWindow; + type State = StubState; + + fn hide_spot(workspace: &Workspace) -> u8 { + workspace.state.spot + } + + /// a display spot is the spot plus 100, so that a test sees which of the two was passed + fn hide_spot_on_display(workspace: &Workspace) -> u8 { + workspace.state.spot + 100 + } + + fn foreground_window_id() -> Option { + settings(|s| s.foreground) + } + + fn cursor_position() -> Option<(i32, i32)> { + settings(|s| s.cursor) + } + + fn should_act_window( + _: &str, + _: &str, + _: &str, + _: &str, + _: &[MatchingRule], + ) -> Option { + None + } + + fn should_act_window_individual( + _: &str, + _: &str, + _: &str, + _: &str, + _: &IdWithIdentifier, + ) -> bool { + false + } + + fn scrolling_idx_for_direction(_: &Workspace, _: OperationDirection) -> Option { + None + } + + fn scrolling_insertion_idx(_: &Workspace) -> Option { + settings(|s| s.scrolling_insertion_idx) + } + + fn note_scrolling_departure(_: &Workspace, idx: usize) { + push(format!("note_scrolling_departure {idx}")); + } + + fn after_floating_removed(_: &mut Workspace) { + push("after_floating_removed".to_string()); + } + + fn unmaximize_window(window: &StubWindow) -> eyre::Result<()> { + op( + format!("unmaximize {}", window.id), + format!("unmaximize {}", window.id), + ) + } + + fn before_hide_containers(_: &mut Workspace) -> Option>> { + push("before_hide_containers".to_string()); + None + } + + fn begin_update(_: &mut Workspace) -> eyre::Result { + push("begin_update".to_string()); + if fails("begin_update") { + return Err(eyre::eyre!("begin_update failed")); + } + if settings(|s| s.skip_update) { + return Ok(Flow::Skip); + } + Ok(Flow::Run(Some(Box::new(DropLogger)))) + } + + fn containers_pruned(_: &mut Workspace, focused_before: usize) { + push(format!("containers_pruned focused_before={focused_before}")); + } + + fn place_monocle(window: &mut StubWindow, rect: &Rect) -> eyre::Result<()> { + op( + format!("place_monocle {} {}", window.id, rect_text(rect)), + "place_monocle".to_string(), + ) + } + + fn maximize_in_layout(window: &mut StubWindow) -> eyre::Result<()> { + op( + format!("maximize_in_layout {}", window.id), + "maximize_in_layout".to_string(), + ) + } + + fn adopt_widths_before_layout(_: &Workspace) -> bool { + push("adopt_widths_before_layout".to_string()); + settings(|s| s.adopt_widths) + } + + fn adopt_actual_widths( + _: &mut Workspace, + around_window: i32, + viewport_width: i32, + ) -> bool { + push(format!( + "adopt_actual_widths around={around_window} viewport={viewport_width}" + )); + false + } + + fn scrolling_layouts( + workspace: &mut Workspace, + adjusted_work_area: &Rect, + container_padding: i32, + ) -> (Vec, Vec) { + push(format!( + "scrolling_layouts {} padding={container_padding}", + rect_text(adjusted_work_area) + )); + let count = workspace.containers().len() as i32; + let width = adjusted_work_area.right / count.max(1); + let layouts = (0..count) + .map(|i| Rect { + left: adjusted_work_area.left + i * width, + top: adjusted_work_area.top, + right: width, + bottom: adjusted_work_area.bottom, + }) + .collect(); + (layouts, settings(|s| s.scrolling_hidden.clone())) + } + + fn adjust_layout( + container: &mut Container, + layout: &mut Rect, + placement: &Placement, + ) { + push(format!( + "adjust_layout {} {} scrolling={} maximized={} resize_empty={}", + window_ids(container), + rect_text(layout), + placement.is_scrolling, + placement.managed_maximized_window, + placement.resize_dimensions_is_empty, + )); + let by = settings(|s| s.adjust); + layout.add_padding(by); + } + + fn place_container( + container: &mut Container, + layout: &Rect, + hidden: bool, + placement: &Placement, + ) -> eyre::Result<()> { + op( + format!( + "place_container {} {} hidden={hidden} padding={} border={}+{}", + window_ids(container), + rect_text(layout), + placement.container_padding, + placement.border_offset, + placement.border_width, + ), + "place_container".to_string(), + ) + } + + fn end_update( + _: &mut Workspace, + pass: usize, + around_window: i32, + viewport_width: i32, + ) -> eyre::Result { + push(format!( + "end_update pass={pass} around={around_window} viewport={viewport_width}" + )); + if fails("end_update") { + return Err(eyre::eyre!("end_update failed")); + } + Ok(settings(|s| s.passes.pop()).unwrap_or(Pass::Done)) + } +} + +/// The guard `begin_update` hands out, which logs when the update is over. +struct DropLogger; + +impl Drop for DropLogger { + fn drop(&mut self) { + push("guard dropped".to_string()); + } +} + +fn window_ids(container: &Container) -> String { + let ids: Vec = container + .windows() + .iter() + .map(|w| w.id.to_string()) + .collect(); + format!("({})", ids.join(",")) +} + +impl MonitorPlatform for StubPlatform { + type Id = u32; + type SerialNumber = String; + type Extra = NoExtra; + #[cfg(feature = "schemars")] + type IdSchema = u32; + + fn serialize_id(id: &u32, serializer: S) -> Result { + serializer.serialize_u32(*id) + } + + fn deserialize_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + u32::deserialize(deserializer) + } + + fn known_workspace_name(_: &Monitor, idx: usize) -> Option { + settings(|s| s.known_names.get(&idx).cloned()) + } +} + +/// A container of the given window ids, the last one in front. +pub fn container_of(ids: &[u32]) -> Container { + let mut container = Container::default(); + for id in ids { + container.add_window(StubWindow { id: *id }, 0).unwrap(); + } + container +} + +/// A workspace with one container per window id, in order, the last one focused; the log is +/// emptied afterwards. +pub fn workspace_of(ids: &[u32]) -> Workspace { + let mut workspace = Workspace::::default(); + for id in ids { + workspace + .new_container_for_window(StubWindow { id: *id }) + .unwrap(); + } + log(); + workspace +} + +/// The window ids of every container, in order. +pub fn layout_of(workspace: &Workspace) -> Vec> { + workspace + .containers() + .iter() + .map(|c| c.windows().iter().map(|w| w.id).collect()) + .collect() +} + +pub fn work_area(right: i32, bottom: i32) -> Rect { + Rect { + left: 0, + top: 0, + right, + bottom, + } +} diff --git a/crates/komorebi-core/tests/container_behaviour.rs b/crates/komorebi-core/tests/container_behaviour.rs new file mode 100644 index 00000000..9a0f56eb --- /dev/null +++ b/crates/komorebi-core/tests/container_behaviour.rs @@ -0,0 +1,307 @@ +//! Characterization of `Container`: the ring of windows, its focus index and the order of the +//! hide and restore calls it makes on its windows. + +mod common; + +use common::*; +use komorebi_core::Container; +use komorebi_core::Lockable; + +fn ids(container: &Container) -> Vec { + container.windows().iter().map(|w| w.id).collect() +} + +#[test] +fn a_new_container_is_empty_unlocked_and_has_an_id_of_its_own() { + fresh(); + let a = Container::::default(); + let b = Container::::default(); + + assert!(a.windows().is_empty()); + assert!(!a.locked); + assert_eq!(a.focused_window_idx(), 0); + assert!(a.focused_window().is_none()); + assert_ne!(a.id, b.id); + assert!(!a.is_preselect()); +} + +#[test] +fn a_preselect_container_is_empty_and_named_preselect() { + let container = Container::::preselect(); + + assert_eq!(container.id, "PRESELECT"); + assert!(container.windows().is_empty()); + assert!(container.is_preselect()); +} + +#[test] +fn adding_windows_appends_and_focuses_the_newest() { + fresh(); + let container = container_of(&[1, 2, 3]); + + assert_eq!(ids(&container), [1, 2, 3]); + assert_eq!(container.focused_window_idx(), 2); + assert_eq!(container.focused_window().map(|w| w.id), Some(3)); +} + +#[test] +fn adding_a_window_hides_every_other_window_at_the_spot_in_stack_order() { + fresh(); + let mut container = container_of(&[1, 2]); + log(); + + container.add_window(StubWindow { id: 3 }, 5).unwrap(); + + assert_eq!(log(), ["hide 1 @5", "hide 2 @5"]); +} + +#[test] +fn adding_the_first_window_hides_nothing() { + fresh(); + let mut container = Container::default(); + + container.add_window(StubWindow { id: 1 }, 5).unwrap(); + + assert!(log().is_empty()); +} + +#[test] +fn a_window_that_cannot_be_hidden_makes_add_window_fail_but_it_is_already_in() { + fresh(); + let mut container = container_of(&[1, 2]); + fail("hide 1"); + log(); + + let result = container.add_window(StubWindow { id: 3 }, 5); + + assert!(result.is_err()); + // the window was pushed and focused before the hiding began, and the hiding stopped at the + // first failure, so window 2 was not hidden + assert_eq!(ids(&container), [1, 2, 3]); + assert_eq!(container.focused_window_idx(), 2); + assert_eq!(log(), ["hide 1 @5"]); +} + +#[test] +fn removing_a_window_focuses_the_one_before_its_index_whatever_was_focused() { + fresh(); + // (removed index, the focus index afterwards) for a container of four windows with the last + // one focused + for (removed, focused_after) in [(0, 0), (1, 0), (2, 1), (3, 2)] { + let mut container = container_of(&[10, 11, 12, 13]); + + let window = container.remove_window_by_idx(removed).unwrap(); + + assert_eq!(window.id, 10 + removed as u32); + assert_eq!(container.windows().len(), 3); + assert_eq!( + container.focused_window_idx(), + focused_after, + "after removing index {removed}" + ); + } +} + +#[test] +fn removing_a_window_does_not_touch_the_windows_that_are_left() { + fresh(); + let mut container = container_of(&[1, 2, 3]); + log(); + + container.remove_window_by_idx(1); + + assert!(log().is_empty()); + assert_eq!(ids(&container), [1, 3]); +} + +#[test] +fn removing_the_last_window_leaves_an_empty_container_focused_at_zero() { + fresh(); + let mut container = container_of(&[1]); + + let window = container.remove_focused_window(); + + assert_eq!(window.map(|w| w.id), Some(1)); + assert!(container.windows().is_empty()); + assert_eq!(container.focused_window_idx(), 0); + assert!(container.focused_window().is_none()); +} + +#[test] +fn removing_from_an_empty_container_gives_nothing_and_keeps_focus_zero() { + fresh(); + let mut container = Container::::default(); + + assert!(container.remove_focused_window().is_none()); + assert!(container.remove_window_by_idx(0).is_none()); + assert_eq!(container.focused_window_idx(), 0); +} + +#[test] +fn removing_past_the_end_removes_nothing_and_leaves_the_focus_dangling() { + fresh(); + let mut container = container_of(&[1, 2]); + + assert!(container.remove_window_by_idx(5).is_none()); + + // the focus moved to index 4 although there are two windows: the ring does not clamp it + assert_eq!(container.windows().len(), 2); + assert_eq!(container.focused_window_idx(), 4); + assert!(container.focused_window().is_none()); +} + +#[test] +fn removing_the_focused_window_of_a_stack_focuses_the_one_before_it() { + fresh(); + let mut container = container_of(&[1, 2, 3]); + container.focus_window(1); + + container.remove_focused_window(); + + assert_eq!(ids(&container), [1, 3]); + assert_eq!(container.focused_window_idx(), 0); +} + +#[test] +fn focusing_a_window_only_moves_the_index_and_makes_no_window_call() { + fresh(); + let mut container = container_of(&[1, 2, 3]); + log(); + + container.focus_window(0); + + assert_eq!(container.focused_window_idx(), 0); + assert!(log().is_empty()); +} + +#[test] +fn windows_are_found_by_id_and_by_executable() { + fresh(); + let container = container_of(&[4, 5, 6]); + + assert_eq!(container.idx_for_window(6), Some(2)); + assert!(container.contains_window(4)); + assert!(!container.contains_window(7)); + assert_eq!(container.idx_from_exe("exe5"), Some(1)); + assert_eq!(container.idx_from_exe("exe9"), None); +} + +#[test] +fn loading_the_focused_window_walks_the_windows_in_order_and_stops_at_a_failure() { + fresh(); + let mut container = container_of(&[1, 2, 3]); + container.focus_window(1); + fail("hide_stacked 1"); + log(); + + let result = container.load_focused_window(3); + + assert!(result.is_err()); + // window 1 failed first, so neither the focused window 2 nor window 3 was reached + assert_eq!(log(), ["hide_stacked 1 @3"]); +} + +#[test] +fn loading_the_focused_window_of_a_single_window_container_restores_it_the_stacked_way() { + fresh(); + let mut container = container_of(&[1]); + + container.load_focused_window(3).unwrap(); + + assert_eq!(log(), ["restore_stacked 1"]); +} + +#[test] +fn loading_an_empty_container_calls_nothing() { + fresh(); + let mut container = Container::::default(); + + container.load_focused_window(3).unwrap(); + + assert!(log().is_empty()); +} + +#[test] +fn hiding_goes_from_the_back_and_carries_on_after_a_window_that_fails() { + fresh(); + let container = container_of(&[1, 2, 3]); + fail("hide 3"); + log(); + + let result = container.hide(None, 9); + + assert!(result.is_ok()); + assert_eq!(log(), ["hide 3 @9", "hide 2 @9", "hide 1 @9"]); +} + +#[test] +fn hiding_with_an_omitted_window_spares_only_that_window() { + fresh(); + let container = container_of(&[1, 2, 3]); + log(); + + container.hide(Some(2), 9).unwrap(); + + assert_eq!(log(), ["hide 3 @9", "hide 1 @9"]); +} + +#[test] +fn hiding_with_an_omitted_window_that_is_not_in_the_container_hides_them_all() { + fresh(); + let container = container_of(&[1, 2]); + log(); + + container.hide(Some(42), 9).unwrap(); + + assert_eq!(log(), ["hide 2 @9", "hide 1 @9"]); +} + +#[test] +fn restoring_shows_only_the_focused_window_and_reports_a_failure() { + fresh(); + let mut container = container_of(&[1, 2]); + container.focus_window(0); + log(); + + container.restore().unwrap(); + assert_eq!(log(), ["restore 1"]); + + fail("restore 1"); + assert!(container.restore().is_err()); +} + +#[test] +fn restoring_an_empty_container_is_not_an_error() { + fresh(); + let container = Container::::default(); + + assert!(container.restore().is_ok()); + assert!(log().is_empty()); +} + +#[test] +fn a_container_can_be_locked_and_unlocked() { + let mut container = Container::::default(); + + container.set_locked(true); + assert!(container.locked()); + container.set_locked(false); + assert!(!container.locked()); +} + +#[test] +fn a_container_with_windows_is_written_as_a_ring_with_its_focus_index() { + fresh(); + let mut container = container_of(&[1, 2]); + container.id = "c".to_string(); + container.focus_window(0); + + assert_eq!( + serde_json::to_value(&container).unwrap(), + serde_json::json!({ + "id": "c", + "locked": false, + "windows": { "elements": [{ "id": 1 }, { "id": 2 }], "focused": 0 } + }) + ); +}