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<W> (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.
This commit is contained in:
LGUG2Z committed 2026-11-05 15:03:26 -08:00
1 parent a68668840d
commit d20432f087
2 files changed
+792

No files matched your search

+485
View File
@@ -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<u32>,
cursor: Option<(i32, i32)>,
/// operations that fail, by the text the log would have for them (see `fails`)
failing: HashSet<String>,
/// 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<Pass>,
adopt_widths: bool,
scrolling_insertion_idx: Option<usize>,
/// what `scrolling_layouts` hides
scrolling_hidden: Vec<bool>,
known_names: HashMap<usize, String>,
/// how much `adjust_layout` shrinks a tile
adjust: i32,
}
thread_local! {
static LOG: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
static SETTINGS: RefCell<Settings> = 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<String> {
LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
}
fn push(line: String) {
LOG.with(|log| log.borrow_mut().push(line));
}
fn settings<T>(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<u32>) {
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<usize>) {
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<String> {
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<String> {
Some(format!("title{}", self.id))
}
fn exe(&self) -> Option<String> {
self.exe_name()
}
fn class(&self) -> Option<String> {
Some("class".to_string())
}
fn path(&self) -> Option<String> {
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<Self>) -> 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<Self>) -> u8 {
workspace.state.spot + 100
}
fn foreground_window_id() -> Option<u32> {
settings(|s| s.foreground)
}
fn cursor_position() -> Option<(i32, i32)> {
settings(|s| s.cursor)
}
fn should_act_window(
_: &str,
_: &str,
_: &str,
_: &str,
_: &[MatchingRule],
) -> Option<MatchingRule> {
None
}
fn should_act_window_individual(
_: &str,
_: &str,
_: &str,
_: &str,
_: &IdWithIdentifier,
) -> bool {
false
}
fn scrolling_idx_for_direction(_: &Workspace<Self>, _: OperationDirection) -> Option<usize> {
None
}
fn scrolling_insertion_idx(_: &Workspace<Self>) -> Option<usize> {
settings(|s| s.scrolling_insertion_idx)
}
fn note_scrolling_departure(_: &Workspace<Self>, idx: usize) {
push(format!("note_scrolling_departure {idx}"));
}
fn after_floating_removed(_: &mut Workspace<Self>) {
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<Self>) -> Option<Vec<Option<Rect>>> {
push("before_hide_containers".to_string());
None
}
fn begin_update(_: &mut Workspace<Self>) -> eyre::Result<Flow> {
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<Self>, 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<Self>) -> bool {
push("adopt_widths_before_layout".to_string());
settings(|s| s.adopt_widths)
}
fn adopt_actual_widths(
_: &mut Workspace<Self>,
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<Self>,
adjusted_work_area: &Rect,
container_padding: i32,
) -> (Vec<Rect>, Vec<bool>) {
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<StubWindow>,
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<StubWindow>,
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<Self>,
pass: usize,
around_window: i32,
viewport_width: i32,
) -> eyre::Result<Pass> {
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<StubWindow>) -> String {
let ids: Vec<String> = 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<S: Serializer>(id: &u32, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u32(*id)
}
fn deserialize_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
u32::deserialize(deserializer)
}
fn known_workspace_name(_: &Monitor<Self>, idx: usize) -> Option<String> {
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<StubWindow> {
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<StubPlatform> {
let mut workspace = Workspace::<StubPlatform>::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<StubPlatform>) -> Vec<Vec<u32>> {
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,
}
}
@@ -0,0 +1,307 @@
//! Characterization of `Container<W>`: 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<StubWindow>) -> Vec<u32> {
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::<StubWindow>::default();
let b = Container::<StubWindow>::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::<StubWindow>::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::<StubWindow>::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::<StubWindow>::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::<StubWindow>::default();
assert!(container.restore().is_ok());
assert!(log().is_empty());
}
#[test]
fn a_container_can_be_locked_and_unlocked() {
let mut container = Container::<StubWindow>::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 }
})
);
}