From 49ddf66c2f77d6dab8bdb84de7345b9c3a28f9df Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Fri, 31 Jan 2025 17:55:15 +0300 Subject: layout: Move tests to separate file This way changing just the tests won't rebuild the main library. --- src/layout/tests.rs | 3163 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3163 insertions(+) create mode 100644 src/layout/tests.rs (limited to 'src/layout/tests.rs') diff --git a/src/layout/tests.rs b/src/layout/tests.rs new file mode 100644 index 00000000..47484817 --- /dev/null +++ b/src/layout/tests.rs @@ -0,0 +1,3163 @@ +use std::cell::Cell; + +use niri_config::{FloatOrInt, OutputName, WorkspaceName, WorkspaceReference}; +use proptest::prelude::*; +use proptest_derive::Arbitrary; +use smithay::output::{Mode, PhysicalProperties, Subpixel}; +use smithay::utils::Rectangle; + +use super::*; + +impl Default for Layout { + fn default() -> Self { + Self::with_options(Clock::with_time(Duration::ZERO), Default::default()) + } +} + +#[derive(Debug)] +struct TestWindowInner { + id: usize, + parent_id: Cell>, + bbox: Cell>, + initial_bbox: Rectangle, + requested_size: Cell>>, + min_size: Size, + max_size: Size, + pending_fullscreen: Cell, + pending_activated: Cell, +} + +#[derive(Debug, Clone)] +struct TestWindow(Rc); + +#[derive(Debug, Clone, Copy, Arbitrary)] +struct TestWindowParams { + #[proptest(strategy = "1..=5usize")] + id: usize, + #[proptest(strategy = "arbitrary_parent_id()")] + parent_id: Option, + is_floating: bool, + #[proptest(strategy = "arbitrary_bbox()")] + bbox: Rectangle, + #[proptest(strategy = "arbitrary_min_max_size()")] + min_max_size: (Size, Size), +} + +impl TestWindowParams { + pub fn new(id: usize) -> Self { + Self { + id, + parent_id: None, + is_floating: false, + bbox: Rectangle::from_size(Size::from((100, 200))), + min_max_size: Default::default(), + } + } +} + +impl TestWindow { + fn new(params: TestWindowParams) -> Self { + Self(Rc::new(TestWindowInner { + id: params.id, + parent_id: Cell::new(params.parent_id), + bbox: Cell::new(params.bbox), + initial_bbox: params.bbox, + requested_size: Cell::new(None), + min_size: params.min_max_size.0, + max_size: params.min_max_size.1, + pending_fullscreen: Cell::new(false), + pending_activated: Cell::new(false), + })) + } + + fn communicate(&self) -> bool { + if let Some(size) = self.0.requested_size.get() { + assert!(size.w >= 0); + assert!(size.h >= 0); + + let mut new_bbox = self.0.initial_bbox; + if size.w != 0 { + new_bbox.size.w = size.w; + } + if size.h != 0 { + new_bbox.size.h = size.h; + } + + if self.0.bbox.get() != new_bbox { + self.0.bbox.set(new_bbox); + return true; + } + } + + false + } +} + +impl LayoutElement for TestWindow { + type Id = usize; + + fn id(&self) -> &Self::Id { + &self.0.id + } + + fn size(&self) -> Size { + self.0.bbox.get().size + } + + fn buf_loc(&self) -> Point { + (0, 0).into() + } + + fn is_in_input_region(&self, _point: Point) -> bool { + false + } + + fn render( + &self, + _renderer: &mut R, + _location: Point, + _scale: Scale, + _alpha: f32, + _target: RenderTarget, + ) -> SplitElements> { + SplitElements::default() + } + + fn request_size( + &mut self, + size: Size, + _animate: bool, + _transaction: Option, + ) { + self.0.requested_size.set(Some(size)); + self.0.pending_fullscreen.set(false); + } + + fn request_fullscreen(&mut self, _size: Size) { + self.0.pending_fullscreen.set(true); + } + + fn min_size(&self) -> Size { + self.0.min_size + } + + fn max_size(&self) -> Size { + self.0.max_size + } + + fn is_wl_surface(&self, _wl_surface: &WlSurface) -> bool { + false + } + + fn set_preferred_scale_transform(&self, _scale: output::Scale, _transform: Transform) {} + + fn has_ssd(&self) -> bool { + false + } + + fn output_enter(&self, _output: &Output) {} + + fn output_leave(&self, _output: &Output) {} + + fn set_offscreen_element_id(&self, _id: Option) {} + + fn set_activated(&mut self, active: bool) { + self.0.pending_activated.set(active); + } + + fn set_bounds(&self, _bounds: Size) {} + + fn is_ignoring_opacity_window_rule(&self) -> bool { + false + } + + fn configure_intent(&self) -> ConfigureIntent { + ConfigureIntent::CanSend + } + + fn send_pending_configure(&mut self) {} + + fn set_active_in_column(&mut self, _active: bool) {} + + fn set_floating(&mut self, _floating: bool) {} + + fn is_fullscreen(&self) -> bool { + false + } + + fn is_pending_fullscreen(&self) -> bool { + self.0.pending_fullscreen.get() + } + + fn requested_size(&self) -> Option> { + self.0.requested_size.get() + } + + fn is_child_of(&self, parent: &Self) -> bool { + self.0.parent_id.get() == Some(parent.0.id) + } + + fn refresh(&self) {} + + fn rules(&self) -> &ResolvedWindowRules { + static EMPTY: ResolvedWindowRules = ResolvedWindowRules::empty(); + &EMPTY + } + + fn animation_snapshot(&self) -> Option<&LayoutElementRenderSnapshot> { + None + } + + fn take_animation_snapshot(&mut self) -> Option { + None + } + + fn set_interactive_resize(&mut self, _data: Option) {} + + fn cancel_interactive_resize(&mut self) {} + + fn on_commit(&mut self, _serial: Serial) {} + + fn interactive_resize_data(&self) -> Option { + None + } +} + +fn arbitrary_bbox() -> impl Strategy> { + any::<(i16, i16, u16, u16)>().prop_map(|(x, y, w, h)| { + let loc: Point = Point::from((x.into(), y.into())); + let size: Size = Size::from((w.max(1).into(), h.max(1).into())); + Rectangle::new(loc, size) + }) +} + +fn arbitrary_size_change() -> impl Strategy { + prop_oneof![ + (0..).prop_map(SizeChange::SetFixed), + (0f64..).prop_map(SizeChange::SetProportion), + any::().prop_map(SizeChange::AdjustFixed), + any::().prop_map(SizeChange::AdjustProportion), + // Interactive resize can have negative values here. + Just(SizeChange::SetFixed(-100)), + ] +} + +fn arbitrary_position_change() -> impl Strategy { + prop_oneof![ + (-1000f64..1000f64).prop_map(PositionChange::SetFixed), + (-1000f64..1000f64).prop_map(PositionChange::AdjustFixed), + any::().prop_map(PositionChange::SetFixed), + any::().prop_map(PositionChange::AdjustFixed), + ] +} + +fn arbitrary_min_max() -> impl Strategy { + prop_oneof![ + Just((0, 0)), + (1..65536).prop_map(|n| (n, n)), + (1..65536).prop_map(|min| (min, 0)), + (1..).prop_map(|max| (0, max)), + (1..65536, 1..).prop_map(|(min, max): (i32, i32)| (min, max.max(min))), + ] +} + +fn arbitrary_min_max_size() -> impl Strategy, Size)> { + prop_oneof![ + 5 => (arbitrary_min_max(), arbitrary_min_max()).prop_map( + |((min_w, max_w), (min_h, max_h))| { + let min_size = Size::from((min_w, min_h)); + let max_size = Size::from((max_w, max_h)); + (min_size, max_size) + }, + ), + 1 => arbitrary_min_max().prop_map(|(w, h)| { + let size = Size::from((w, h)); + (size, size) + }), + ] +} + +fn arbitrary_view_offset_gesture_delta() -> impl Strategy { + prop_oneof![(-10f64..10f64), (-50000f64..50000f64),] +} + +fn arbitrary_resize_edge() -> impl Strategy { + prop_oneof![ + Just(ResizeEdge::RIGHT), + Just(ResizeEdge::BOTTOM), + Just(ResizeEdge::LEFT), + Just(ResizeEdge::TOP), + Just(ResizeEdge::BOTTOM_RIGHT), + Just(ResizeEdge::BOTTOM_LEFT), + Just(ResizeEdge::TOP_RIGHT), + Just(ResizeEdge::TOP_LEFT), + Just(ResizeEdge::empty()), + ] +} + +fn arbitrary_scale() -> impl Strategy { + prop_oneof![Just(1.), Just(1.5), Just(2.),] +} + +fn arbitrary_msec_delta() -> impl Strategy { + prop_oneof![ + 1 => Just(-1000), + 2 => Just(-10), + 1 => Just(0), + 2 => Just(10), + 6 => Just(1000), + ] +} + +fn arbitrary_parent_id() -> impl Strategy> { + prop_oneof![ + 5 => Just(None), + 1 => prop::option::of(1..=5usize), + ] +} + +fn arbitrary_scroll_direction() -> impl Strategy { + prop_oneof![Just(ScrollDirection::Left), Just(ScrollDirection::Right)] +} + +#[derive(Debug, Clone, Copy, Arbitrary)] +enum Op { + AddOutput(#[proptest(strategy = "1..=5usize")] usize), + AddScaledOutput { + #[proptest(strategy = "1..=5usize")] + id: usize, + #[proptest(strategy = "arbitrary_scale()")] + scale: f64, + }, + RemoveOutput(#[proptest(strategy = "1..=5usize")] usize), + FocusOutput(#[proptest(strategy = "1..=5usize")] usize), + AddNamedWorkspace { + #[proptest(strategy = "1..=5usize")] + ws_name: usize, + #[proptest(strategy = "prop::option::of(1..=5usize)")] + output_name: Option, + }, + UnnameWorkspace { + #[proptest(strategy = "1..=5usize")] + ws_name: usize, + }, + AddWindow { + params: TestWindowParams, + }, + AddWindowNextTo { + params: TestWindowParams, + #[proptest(strategy = "1..=5usize")] + next_to_id: usize, + }, + AddWindowToNamedWorkspace { + params: TestWindowParams, + #[proptest(strategy = "1..=5usize")] + ws_name: usize, + }, + CloseWindow(#[proptest(strategy = "1..=5usize")] usize), + FullscreenWindow(#[proptest(strategy = "1..=5usize")] usize), + SetFullscreenWindow { + #[proptest(strategy = "1..=5usize")] + window: usize, + is_fullscreen: bool, + }, + FocusColumnLeft, + FocusColumnRight, + FocusColumnFirst, + FocusColumnLast, + FocusColumnRightOrFirst, + FocusColumnLeftOrLast, + FocusWindowOrMonitorUp(#[proptest(strategy = "1..=2u8")] u8), + FocusWindowOrMonitorDown(#[proptest(strategy = "1..=2u8")] u8), + FocusColumnOrMonitorLeft(#[proptest(strategy = "1..=2u8")] u8), + FocusColumnOrMonitorRight(#[proptest(strategy = "1..=2u8")] u8), + FocusWindowDown, + FocusWindowUp, + FocusWindowDownOrColumnLeft, + FocusWindowDownOrColumnRight, + FocusWindowUpOrColumnLeft, + FocusWindowUpOrColumnRight, + FocusWindowOrWorkspaceDown, + FocusWindowOrWorkspaceUp, + FocusWindow(#[proptest(strategy = "1..=5usize")] usize), + MoveColumnLeft, + MoveColumnRight, + MoveColumnToFirst, + MoveColumnToLast, + MoveColumnLeftOrToMonitorLeft(#[proptest(strategy = "1..=2u8")] u8), + MoveColumnRightOrToMonitorRight(#[proptest(strategy = "1..=2u8")] u8), + MoveWindowDown, + MoveWindowUp, + MoveWindowDownOrToWorkspaceDown, + MoveWindowUpOrToWorkspaceUp, + ConsumeOrExpelWindowLeft { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + ConsumeOrExpelWindowRight { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + ConsumeWindowIntoColumn, + ExpelWindowFromColumn, + SwapWindowInDirection(#[proptest(strategy = "arbitrary_scroll_direction()")] ScrollDirection), + CenterColumn, + CenterWindow { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + FocusWorkspaceDown, + FocusWorkspaceUp, + FocusWorkspace(#[proptest(strategy = "0..=4usize")] usize), + FocusWorkspaceAutoBackAndForth(#[proptest(strategy = "0..=4usize")] usize), + FocusWorkspacePrevious, + MoveWindowToWorkspaceDown, + MoveWindowToWorkspaceUp, + MoveWindowToWorkspace { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + window_id: Option, + #[proptest(strategy = "0..=4usize")] + workspace_idx: usize, + }, + MoveColumnToWorkspaceDown, + MoveColumnToWorkspaceUp, + MoveColumnToWorkspace(#[proptest(strategy = "0..=4usize")] usize), + MoveWorkspaceDown, + MoveWorkspaceUp, + MoveWorkspaceToIndex { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + ws_name: Option, + #[proptest(strategy = "0..=4usize")] + target_idx: usize, + }, + MoveWorkspaceToMonitor { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + ws_name: Option, + #[proptest(strategy = "0..=5usize")] + output_id: usize, + }, + SetWorkspaceName { + #[proptest(strategy = "1..=5usize")] + new_ws_name: usize, + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + ws_name: Option, + }, + UnsetWorkspaceName { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + ws_name: Option, + }, + MoveWindowToOutput { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + window_id: Option, + #[proptest(strategy = "1..=5usize")] + output_id: usize, + #[proptest(strategy = "proptest::option::of(0..=4usize)")] + target_ws_idx: Option, + }, + MoveColumnToOutput(#[proptest(strategy = "1..=5usize")] usize), + SwitchPresetColumnWidth, + SwitchPresetWindowWidth { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + SwitchPresetWindowHeight { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + MaximizeColumn, + SetColumnWidth(#[proptest(strategy = "arbitrary_size_change()")] SizeChange), + SetWindowWidth { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + #[proptest(strategy = "arbitrary_size_change()")] + change: SizeChange, + }, + SetWindowHeight { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + #[proptest(strategy = "arbitrary_size_change()")] + change: SizeChange, + }, + ResetWindowHeight { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + ToggleWindowFloating { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + }, + SetWindowFloating { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + floating: bool, + }, + FocusFloating, + FocusTiling, + SwitchFocusFloatingTiling, + MoveFloatingWindow { + #[proptest(strategy = "proptest::option::of(1..=5usize)")] + id: Option, + #[proptest(strategy = "arbitrary_position_change()")] + x: PositionChange, + #[proptest(strategy = "arbitrary_position_change()")] + y: PositionChange, + animate: bool, + }, + SetParent { + #[proptest(strategy = "1..=5usize")] + id: usize, + #[proptest(strategy = "prop::option::of(1..=5usize)")] + new_parent_id: Option, + }, + Communicate(#[proptest(strategy = "1..=5usize")] usize), + Refresh { + is_active: bool, + }, + AdvanceAnimations { + #[proptest(strategy = "arbitrary_msec_delta()")] + msec_delta: i32, + }, + MoveWorkspaceToOutput(#[proptest(strategy = "1..=5usize")] usize), + ViewOffsetGestureBegin { + #[proptest(strategy = "1..=5usize")] + output_idx: usize, + is_touchpad: bool, + }, + ViewOffsetGestureUpdate { + #[proptest(strategy = "arbitrary_view_offset_gesture_delta()")] + delta: f64, + timestamp: Duration, + is_touchpad: bool, + }, + ViewOffsetGestureEnd { + is_touchpad: Option, + }, + WorkspaceSwitchGestureBegin { + #[proptest(strategy = "1..=5usize")] + output_idx: usize, + is_touchpad: bool, + }, + WorkspaceSwitchGestureUpdate { + #[proptest(strategy = "-400f64..400f64")] + delta: f64, + timestamp: Duration, + is_touchpad: bool, + }, + WorkspaceSwitchGestureEnd { + cancelled: bool, + is_touchpad: Option, + }, + InteractiveMoveBegin { + #[proptest(strategy = "1..=5usize")] + window: usize, + #[proptest(strategy = "1..=5usize")] + output_idx: usize, + #[proptest(strategy = "-20000f64..20000f64")] + px: f64, + #[proptest(strategy = "-20000f64..20000f64")] + py: f64, + }, + InteractiveMoveUpdate { + #[proptest(strategy = "1..=5usize")] + window: usize, + #[proptest(strategy = "-20000f64..20000f64")] + dx: f64, + #[proptest(strategy = "-20000f64..20000f64")] + dy: f64, + #[proptest(strategy = "1..=5usize")] + output_idx: usize, + #[proptest(strategy = "-20000f64..20000f64")] + px: f64, + #[proptest(strategy = "-20000f64..20000f64")] + py: f64, + }, + InteractiveMoveEnd { + #[proptest(strategy = "1..=5usize")] + window: usize, + }, + InteractiveResizeBegin { + #[proptest(strategy = "1..=5usize")] + window: usize, + #[proptest(strategy = "arbitrary_resize_edge()")] + edges: ResizeEdge, + }, + InteractiveResizeUpdate { + #[proptest(strategy = "1..=5usize")] + window: usize, + #[proptest(strategy = "-20000f64..20000f64")] + dx: f64, + #[proptest(strategy = "-20000f64..20000f64")] + dy: f64, + }, + InteractiveResizeEnd { + #[proptest(strategy = "1..=5usize")] + window: usize, + }, +} + +impl Op { + fn apply(self, layout: &mut Layout) { + match self { + Op::AddOutput(id) => { + let name = format!("output{id}"); + if layout.outputs().any(|o| o.name() == name) { + return; + } + + let output = Output::new( + name.clone(), + PhysicalProperties { + size: Size::from((1280, 720)), + subpixel: Subpixel::Unknown, + make: String::new(), + model: String::new(), + }, + ); + output.change_current_state( + Some(Mode { + size: Size::from((1280, 720)), + refresh: 60000, + }), + None, + None, + None, + ); + output.user_data().insert_if_missing(|| OutputName { + connector: name, + make: None, + model: None, + serial: None, + }); + layout.add_output(output.clone()); + } + Op::AddScaledOutput { id, scale } => { + let name = format!("output{id}"); + if layout.outputs().any(|o| o.name() == name) { + return; + } + + let output = Output::new( + name.clone(), + PhysicalProperties { + size: Size::from((1280, 720)), + subpixel: Subpixel::Unknown, + make: String::new(), + model: String::new(), + }, + ); + output.change_current_state( + Some(Mode { + size: Size::from((1280, 720)), + refresh: 60000, + }), + None, + Some(smithay::output::Scale::Fractional(scale)), + None, + ); + output.user_data().insert_if_missing(|| OutputName { + connector: name, + make: None, + model: None, + serial: None, + }); + layout.add_output(output.clone()); + } + Op::RemoveOutput(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.remove_output(&output); + } + Op::FocusOutput(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.focus_output(&output); + } + Op::AddNamedWorkspace { + ws_name, + output_name, + } => { + layout.ensure_named_workspace(&WorkspaceConfig { + name: WorkspaceName(format!("ws{ws_name}")), + open_on_output: output_name.map(|name| format!("output{name}")), + }); + } + Op::UnnameWorkspace { ws_name } => { + layout.unname_workspace(&format!("ws{ws_name}")); + } + Op::SetWorkspaceName { + new_ws_name, + ws_name, + } => { + let ws_ref = + ws_name.map(|ws_name| WorkspaceReference::Name(format!("ws{ws_name}"))); + layout.set_workspace_name(format!("ws{new_ws_name}"), ws_ref); + } + Op::UnsetWorkspaceName { ws_name } => { + let ws_ref = + ws_name.map(|ws_name| WorkspaceReference::Name(format!("ws{ws_name}"))); + layout.unset_workspace_name(ws_ref); + } + Op::AddWindow { mut params } => { + if layout.has_window(¶ms.id) { + return; + } + if let Some(parent_id) = params.parent_id { + if parent_id_causes_loop(layout, params.id, parent_id) { + params.parent_id = None; + } + } + + let win = TestWindow::new(params); + layout.add_window( + win, + AddWindowTarget::Auto, + None, + None, + false, + params.is_floating, + ActivateWindow::default(), + ); + } + Op::AddWindowNextTo { + mut params, + next_to_id, + } => { + let mut found_next_to = false; + + if let Some(InteractiveMoveState::Moving(move_)) = &layout.interactive_move { + let win_id = move_.tile.window().0.id; + if win_id == params.id { + return; + } + if win_id == next_to_id { + found_next_to = true; + } + } + + match &mut layout.monitor_set { + MonitorSet::Normal { monitors, .. } => { + for mon in monitors { + for ws in &mut mon.workspaces { + for win in ws.windows() { + if win.0.id == params.id { + return; + } + + if win.0.id == next_to_id { + found_next_to = true; + } + } + } + } + } + MonitorSet::NoOutputs { workspaces, .. } => { + for ws in workspaces { + for win in ws.windows() { + if win.0.id == params.id { + return; + } + + if win.0.id == next_to_id { + found_next_to = true; + } + } + } + } + } + + if !found_next_to { + return; + } + + if let Some(parent_id) = params.parent_id { + if parent_id_causes_loop(layout, params.id, parent_id) { + params.parent_id = None; + } + } + + let win = TestWindow::new(params); + layout.add_window( + win, + AddWindowTarget::NextTo(&next_to_id), + None, + None, + false, + params.is_floating, + ActivateWindow::default(), + ); + } + Op::AddWindowToNamedWorkspace { + mut params, + ws_name, + } => { + let ws_name = format!("ws{ws_name}"); + let mut ws_id = None; + + if let Some(InteractiveMoveState::Moving(move_)) = &layout.interactive_move { + if move_.tile.window().0.id == params.id { + return; + } + } + + match &mut layout.monitor_set { + MonitorSet::Normal { monitors, .. } => { + for mon in monitors { + for ws in &mut mon.workspaces { + for win in ws.windows() { + if win.0.id == params.id { + return; + } + } + + if ws + .name + .as_ref() + .is_some_and(|name| name.eq_ignore_ascii_case(&ws_name)) + { + ws_id = Some(ws.id()); + } + } + } + } + MonitorSet::NoOutputs { workspaces, .. } => { + for ws in workspaces { + for win in ws.windows() { + if win.0.id == params.id { + return; + } + } + + if ws + .name + .as_ref() + .is_some_and(|name| name.eq_ignore_ascii_case(&ws_name)) + { + ws_id = Some(ws.id()); + } + } + } + } + + let Some(ws_id) = ws_id else { + return; + }; + + if let Some(parent_id) = params.parent_id { + if parent_id_causes_loop(layout, params.id, parent_id) { + params.parent_id = None; + } + } + + let win = TestWindow::new(params); + layout.add_window( + win, + AddWindowTarget::Workspace(ws_id), + None, + None, + false, + params.is_floating, + ActivateWindow::default(), + ); + } + Op::CloseWindow(id) => { + layout.remove_window(&id, Transaction::new()); + } + Op::FullscreenWindow(id) => { + layout.toggle_fullscreen(&id); + } + Op::SetFullscreenWindow { + window, + is_fullscreen, + } => { + layout.set_fullscreen(&window, is_fullscreen); + } + Op::FocusColumnLeft => layout.focus_left(), + Op::FocusColumnRight => layout.focus_right(), + Op::FocusColumnFirst => layout.focus_column_first(), + Op::FocusColumnLast => layout.focus_column_last(), + Op::FocusColumnRightOrFirst => layout.focus_column_right_or_first(), + Op::FocusColumnLeftOrLast => layout.focus_column_left_or_last(), + Op::FocusWindowOrMonitorUp(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.focus_window_up_or_output(&output); + } + Op::FocusWindowOrMonitorDown(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.focus_window_down_or_output(&output); + } + Op::FocusColumnOrMonitorLeft(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.focus_column_left_or_output(&output); + } + Op::FocusColumnOrMonitorRight(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.focus_column_right_or_output(&output); + } + Op::FocusWindowDown => layout.focus_down(), + Op::FocusWindowUp => layout.focus_up(), + Op::FocusWindowDownOrColumnLeft => layout.focus_down_or_left(), + Op::FocusWindowDownOrColumnRight => layout.focus_down_or_right(), + Op::FocusWindowUpOrColumnLeft => layout.focus_up_or_left(), + Op::FocusWindowUpOrColumnRight => layout.focus_up_or_right(), + Op::FocusWindowOrWorkspaceDown => layout.focus_window_or_workspace_down(), + Op::FocusWindowOrWorkspaceUp => layout.focus_window_or_workspace_up(), + Op::FocusWindow(id) => layout.activate_window(&id), + Op::MoveColumnLeft => layout.move_left(), + Op::MoveColumnRight => layout.move_right(), + Op::MoveColumnToFirst => layout.move_column_to_first(), + Op::MoveColumnToLast => layout.move_column_to_last(), + Op::MoveColumnLeftOrToMonitorLeft(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.move_column_left_or_to_output(&output); + } + Op::MoveColumnRightOrToMonitorRight(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.move_column_right_or_to_output(&output); + } + Op::MoveWindowDown => layout.move_down(), + Op::MoveWindowUp => layout.move_up(), + Op::MoveWindowDownOrToWorkspaceDown => layout.move_down_or_to_workspace_down(), + Op::MoveWindowUpOrToWorkspaceUp => layout.move_up_or_to_workspace_up(), + Op::ConsumeOrExpelWindowLeft { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.consume_or_expel_window_left(id.as_ref()); + } + Op::ConsumeOrExpelWindowRight { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.consume_or_expel_window_right(id.as_ref()); + } + Op::ConsumeWindowIntoColumn => layout.consume_into_column(), + Op::ExpelWindowFromColumn => layout.expel_from_column(), + Op::SwapWindowInDirection(direction) => layout.swap_window_in_direction(direction), + Op::CenterColumn => layout.center_column(), + Op::CenterWindow { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.center_window(id.as_ref()); + } + Op::FocusWorkspaceDown => layout.switch_workspace_down(), + Op::FocusWorkspaceUp => layout.switch_workspace_up(), + Op::FocusWorkspace(idx) => layout.switch_workspace(idx), + Op::FocusWorkspaceAutoBackAndForth(idx) => { + layout.switch_workspace_auto_back_and_forth(idx) + } + Op::FocusWorkspacePrevious => layout.switch_workspace_previous(), + Op::MoveWindowToWorkspaceDown => layout.move_to_workspace_down(), + Op::MoveWindowToWorkspaceUp => layout.move_to_workspace_up(), + Op::MoveWindowToWorkspace { + window_id, + workspace_idx, + } => { + let window_id = window_id.filter(|id| layout.has_window(id)); + layout.move_to_workspace(window_id.as_ref(), workspace_idx); + } + Op::MoveColumnToWorkspaceDown => layout.move_column_to_workspace_down(), + Op::MoveColumnToWorkspaceUp => layout.move_column_to_workspace_up(), + Op::MoveColumnToWorkspace(idx) => layout.move_column_to_workspace(idx), + Op::MoveWindowToOutput { + window_id, + output_id: id, + target_ws_idx, + } => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + let mon = layout.monitor_for_output(&output).unwrap(); + + let window_id = window_id.filter(|id| layout.has_window(id)); + let target_ws_idx = target_ws_idx.filter(|idx| mon.workspaces.len() > *idx); + layout.move_to_output(window_id.as_ref(), &output, target_ws_idx); + } + Op::MoveColumnToOutput(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.move_column_to_output(&output); + } + Op::MoveWorkspaceDown => layout.move_workspace_down(), + Op::MoveWorkspaceUp => layout.move_workspace_up(), + Op::MoveWorkspaceToIndex { + ws_name: Some(ws_name), + target_idx, + } => { + let MonitorSet::Normal { monitors, .. } = &mut layout.monitor_set else { + return; + }; + + let Some((old_idx, old_output)) = monitors.iter().find_map(|monitor| { + monitor + .workspaces + .iter() + .enumerate() + .find_map(|(i, ws)| { + if ws.name == Some(format!("ws{ws_name}")) { + Some(i) + } else { + None + } + }) + .map(|i| (i, monitor.output.clone())) + }) else { + return; + }; + + layout.move_workspace_to_idx(Some((Some(old_output), old_idx)), target_idx) + } + Op::MoveWorkspaceToIndex { + ws_name: None, + target_idx, + } => layout.move_workspace_to_idx(None, target_idx), + Op::MoveWorkspaceToMonitor { + ws_name: None, + output_id: id, + } => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + layout.move_workspace_to_output(&output); + } + Op::MoveWorkspaceToMonitor { + ws_name: Some(ws_name), + output_id: id, + } => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + let MonitorSet::Normal { monitors, .. } = &mut layout.monitor_set else { + return; + }; + + let Some((old_idx, old_output)) = monitors.iter().find_map(|monitor| { + monitor + .workspaces + .iter() + .enumerate() + .find_map(|(i, ws)| { + if ws.name == Some(format!("ws{ws_name}")) { + Some(i) + } else { + None + } + }) + .map(|i| (i, monitor.output.clone())) + }) else { + return; + }; + + layout.move_workspace_to_output_by_id(old_idx, Some(old_output), output); + } + Op::SwitchPresetColumnWidth => layout.toggle_width(), + Op::SwitchPresetWindowWidth { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.toggle_window_width(id.as_ref()); + } + Op::SwitchPresetWindowHeight { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.toggle_window_height(id.as_ref()); + } + Op::MaximizeColumn => layout.toggle_full_width(), + Op::SetColumnWidth(change) => layout.set_column_width(change), + Op::SetWindowWidth { id, change } => { + let id = id.filter(|id| layout.has_window(id)); + layout.set_window_width(id.as_ref(), change); + } + Op::SetWindowHeight { id, change } => { + let id = id.filter(|id| layout.has_window(id)); + layout.set_window_height(id.as_ref(), change); + } + Op::ResetWindowHeight { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.reset_window_height(id.as_ref()); + } + Op::ToggleWindowFloating { id } => { + let id = id.filter(|id| layout.has_window(id)); + layout.toggle_window_floating(id.as_ref()); + } + Op::SetWindowFloating { id, floating } => { + let id = id.filter(|id| layout.has_window(id)); + layout.set_window_floating(id.as_ref(), floating); + } + Op::FocusFloating => { + layout.focus_floating(); + } + Op::FocusTiling => { + layout.focus_tiling(); + } + Op::SwitchFocusFloatingTiling => { + layout.switch_focus_floating_tiling(); + } + Op::MoveFloatingWindow { id, x, y, animate } => { + let id = id.filter(|id| layout.has_window(id)); + layout.move_floating_window(id.as_ref(), x, y, animate); + } + Op::SetParent { + id, + mut new_parent_id, + } => { + if !layout.has_window(&id) { + return; + } + + if let Some(parent_id) = new_parent_id { + if parent_id_causes_loop(layout, id, parent_id) { + new_parent_id = None; + } + } + + let mut update = false; + + if let Some(InteractiveMoveState::Moving(move_)) = &layout.interactive_move { + if move_.tile.window().0.id == id { + move_.tile.window().0.parent_id.set(new_parent_id); + update = true; + } + } + + match &mut layout.monitor_set { + MonitorSet::Normal { monitors, .. } => { + 'outer: for mon in monitors { + for ws in &mut mon.workspaces { + for win in ws.windows() { + if win.0.id == id { + win.0.parent_id.set(new_parent_id); + update = true; + break 'outer; + } + } + } + } + } + MonitorSet::NoOutputs { workspaces, .. } => { + 'outer: for ws in workspaces { + for win in ws.windows() { + if win.0.id == id { + win.0.parent_id.set(new_parent_id); + update = true; + break 'outer; + } + } + } + } + } + + if update { + if let Some(new_parent_id) = new_parent_id { + layout.descendants_added(&new_parent_id); + } + } + } + Op::Communicate(id) => { + let mut update = false; + + if let Some(InteractiveMoveState::Moving(move_)) = &layout.interactive_move { + if move_.tile.window().0.id == id { + if move_.tile.window().communicate() { + update = true; + } + + if update { + // FIXME: serial. + layout.update_window(&id, None); + } + return; + } + } + + match &mut layout.monitor_set { + MonitorSet::Normal { monitors, .. } => { + 'outer: for mon in monitors { + for ws in &mut mon.workspaces { + for win in ws.windows() { + if win.0.id == id { + if win.communicate() { + update = true; + } + break 'outer; + } + } + } + } + } + MonitorSet::NoOutputs { workspaces, .. } => { + 'outer: for ws in workspaces { + for win in ws.windows() { + if win.0.id == id { + if win.communicate() { + update = true; + } + break 'outer; + } + } + } + } + } + + if update { + // FIXME: serial. + layout.update_window(&id, None); + } + } + Op::Refresh { is_active } => { + layout.refresh(is_active); + } + Op::AdvanceAnimations { msec_delta } => { + let mut now = layout.clock.now_unadjusted(); + if msec_delta >= 0 { + now = now.saturating_add(Duration::from_millis(msec_delta as u64)); + } else { + now = now.saturating_sub(Duration::from_millis(-msec_delta as u64)); + } + layout.clock.set_unadjusted(now); + layout.advance_animations(); + } + Op::MoveWorkspaceToOutput(id) => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.move_workspace_to_output(&output); + } + Op::ViewOffsetGestureBegin { + output_idx: id, + is_touchpad: normalize, + } => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.view_offset_gesture_begin(&output, normalize); + } + Op::ViewOffsetGestureUpdate { + delta, + timestamp, + is_touchpad, + } => { + layout.view_offset_gesture_update(delta, timestamp, is_touchpad); + } + Op::ViewOffsetGestureEnd { is_touchpad } => { + // We don't handle cancels in this gesture. + layout.view_offset_gesture_end(false, is_touchpad); + } + Op::WorkspaceSwitchGestureBegin { + output_idx: id, + is_touchpad, + } => { + let name = format!("output{id}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + + layout.workspace_switch_gesture_begin(&output, is_touchpad); + } + Op::WorkspaceSwitchGestureUpdate { + delta, + timestamp, + is_touchpad, + } => { + layout.workspace_switch_gesture_update(delta, timestamp, is_touchpad); + } + Op::WorkspaceSwitchGestureEnd { + cancelled, + is_touchpad, + } => { + layout.workspace_switch_gesture_end(cancelled, is_touchpad); + } + Op::InteractiveMoveBegin { + window, + output_idx, + px, + py, + } => { + let name = format!("output{output_idx}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + layout.interactive_move_begin(window, &output, Point::from((px, py))); + } + Op::InteractiveMoveUpdate { + window, + dx, + dy, + output_idx, + px, + py, + } => { + let name = format!("output{output_idx}"); + let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else { + return; + }; + layout.interactive_move_update( + &window, + Point::from((dx, dy)), + output, + Point::from((px, py)), + ); + } + Op::InteractiveMoveEnd { window } => { + layout.interactive_move_end(&window); + } + Op::InteractiveResizeBegin { window, edges } => { + layout.interactive_resize_begin(window, edges); + } + Op::InteractiveResizeUpdate { window, dx, dy } => { + layout.interactive_resize_update(&window, Point::from((dx, dy))); + } + Op::InteractiveResizeEnd { window } => { + layout.interactive_resize_end(&window); + } + } + } +} + +#[track_caller] +fn check_ops(ops: &[Op]) -> Layout { + let mut layout = Layout::default(); + for op in ops { + op.apply(&mut layout); + layout.verify_invariants(); + } + layout +} + +#[track_caller] +fn check_ops_with_options(options: Options, ops: &[Op]) -> Layout { + let mut layout = Layout::with_options(Clock::with_time(Duration::ZERO), options); + + for op in ops { + op.apply(&mut layout); + layout.verify_invariants(); + } + + layout +} + +#[test] +fn operations_dont_panic() { + let every_op = [ + Op::AddOutput(0), + Op::AddOutput(1), + Op::AddOutput(2), + Op::RemoveOutput(0), + Op::RemoveOutput(1), + Op::RemoveOutput(2), + Op::FocusOutput(0), + Op::FocusOutput(1), + Op::FocusOutput(2), + Op::AddNamedWorkspace { + ws_name: 1, + output_name: Some(1), + }, + Op::UnnameWorkspace { ws_name: 1 }, + Op::AddWindow { + params: TestWindowParams::new(0), + }, + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::AddWindowNextTo { + params: TestWindowParams::new(2), + next_to_id: 1, + }, + Op::AddWindowToNamedWorkspace { + params: TestWindowParams::new(3), + ws_name: 1, + }, + Op::CloseWindow(0), + Op::CloseWindow(1), + Op::CloseWindow(2), + Op::FullscreenWindow(1), + Op::FullscreenWindow(2), + Op::FullscreenWindow(3), + Op::FocusColumnLeft, + Op::FocusColumnRight, + Op::FocusColumnRightOrFirst, + Op::FocusColumnLeftOrLast, + Op::FocusWindowOrMonitorUp(0), + Op::FocusWindowOrMonitorDown(1), + Op::FocusColumnOrMonitorLeft(0), + Op::FocusColumnOrMonitorRight(1), + Op::FocusWindowUp, + Op::FocusWindowUpOrColumnLeft, + Op::FocusWindowUpOrColumnRight, + Op::FocusWindowOrWorkspaceUp, + Op::FocusWindowDown, + Op::FocusWindowDownOrColumnLeft, + Op::FocusWindowDownOrColumnRight, + Op::FocusWindowOrWorkspaceDown, + Op::MoveColumnLeft, + Op::MoveColumnRight, + Op::MoveColumnLeftOrToMonitorLeft(0), + Op::MoveColumnRightOrToMonitorRight(1), + Op::ConsumeWindowIntoColumn, + Op::ExpelWindowFromColumn, + Op::CenterColumn, + Op::FocusWorkspaceDown, + Op::FocusWorkspaceUp, + Op::FocusWorkspace(1), + Op::FocusWorkspace(2), + Op::MoveWindowToWorkspaceDown, + Op::MoveWindowToWorkspaceUp, + Op::MoveWindowToWorkspace { + window_id: None, + workspace_idx: 1, + }, + Op::MoveWindowToWorkspace { + window_id: None, + workspace_idx: 2, + }, + Op::MoveColumnToWorkspaceDown, + Op::MoveColumnToWorkspaceUp, + Op::MoveColumnToWorkspace(1), + Op::MoveColumnToWorkspace(2), + Op::MoveWindowDown, + Op::MoveWindowDownOrToWorkspaceDown, + Op::MoveWindowUp, + Op::MoveWindowUpOrToWorkspaceUp, + Op::ConsumeOrExpelWindowLeft { id: None }, + Op::ConsumeOrExpelWindowRight { id: None }, + Op::MoveWorkspaceToOutput(1), + ]; + + for third in every_op { + for second in every_op { + for first in every_op { + // eprintln!("{first:?}, {second:?}, {third:?}"); + + let mut layout = Layout::default(); + first.apply(&mut layout); + layout.verify_invariants(); + second.apply(&mut layout); + layout.verify_invariants(); + third.apply(&mut layout); + layout.verify_invariants(); + } + } + } +} + +#[test] +fn operations_from_starting_state_dont_panic() { + if std::env::var_os("RUN_SLOW_TESTS").is_none() { + eprintln!("ignoring slow test"); + return; + } + + // Running every op from an empty state doesn't get us to all the interesting states. So, + // also run it from a manually-created starting state with more things going on to exercise + // more code paths. + let setup_ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::MoveWindowToWorkspaceDown, + Op::AddWindow { + params: TestWindowParams::new(2), + }, + Op::AddWindow { + params: TestWindowParams::new(3), + }, + Op::FocusColumnLeft, + Op::ConsumeWindowIntoColumn, + Op::AddWindow { + params: TestWindowParams::new(4), + }, + Op::AddOutput(2), + Op::AddWindow { + params: TestWindowParams::new(5), + }, + Op::MoveWindowToOutput { + window_id: None, + output_id: 2, + target_ws_idx: None, + }, + Op::FocusOutput(1), + Op::Communicate(1), + Op::Communicate(2), + Op::Communicate(3), + Op::Communicate(4), + Op::Communicate(5), + ]; + + let every_op = [ + Op::AddOutput(0), + Op::AddOutput(1), + Op::AddOutput(2), + Op::RemoveOutput(0), + Op::RemoveOutput(1), + Op::RemoveOutput(2), + Op::FocusOutput(0), + Op::FocusOutput(1), + Op::FocusOutput(2), + Op::AddNamedWorkspace { + ws_name: 1, + output_name: Some(1), + }, + Op::UnnameWorkspace { ws_name: 1 }, + Op::AddWindow { + params: TestWindowParams::new(0), + }, + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::AddWindow { + params: TestWindowParams::new(2), + }, + Op::AddWindowNextTo { + params: TestWindowParams::new(6), + next_to_id: 0, + }, + Op::AddWindowNextTo { + params: TestWindowParams::new(7), + next_to_id: 1, + }, + Op::AddWindowToNamedWorkspace { + params: TestWindowParams::new(5), + ws_name: 1, + }, + Op::CloseWindow(0), + Op::CloseWindow(1), + Op::CloseWindow(2), + Op::FullscreenWindow(1), + Op::FullscreenWindow(2), + Op::FullscreenWindow(3), + Op::SetFullscreenWindow { + window: 1, + is_fullscreen: false, + }, + Op::SetFullscreenWindow { + window: 1, + is_fullscreen: true, + }, + Op::SetFullscreenWindow { + window: 2, + is_fullscreen: false, + }, + Op::SetFullscreenWindow { + window: 2, + is_fullscreen: true, + }, + Op::FocusColumnLeft, + Op::FocusColumnRight, + Op::FocusColumnRightOrFirst, + Op::FocusColumnLeftOrLast, + Op::FocusWindowOrMonitorUp(0), + Op::FocusWindowOrMonitorDown(1), + Op::FocusColumnOrMonitorLeft(0), + Op::FocusColumnOrMonitorRight(1), + Op::FocusWindowUp, + Op::FocusWindowUpOrColumnLeft, + Op::FocusWindowUpOrColumnRight, + Op::FocusWindowOrWorkspaceUp, + Op::FocusWindowDown, + Op::FocusWindowDownOrColumnLeft, + Op::FocusWindowDownOrColumnRight, + Op::FocusWindowOrWorkspaceDown, + Op::MoveColumnLeft, + Op::MoveColumnRight, + Op::MoveColumnLeftOrToMonitorLeft(0), + Op::MoveColumnRightOrToMonitorRight(1), + Op::ConsumeWindowIntoColumn, + Op::ExpelWindowFromColumn, + Op::CenterColumn, + Op::FocusWorkspaceDown, + Op::FocusWorkspaceUp, + Op::FocusWorkspace(1), + Op::FocusWorkspace(2), + Op::FocusWorkspace(3), + Op::MoveWindowToWorkspaceDown, + Op::MoveWindowToWorkspaceUp, + Op::MoveWindowToWorkspace { + window_id: None, + workspace_idx: 1, + }, + Op::MoveWindowToWorkspace { + window_id: None, + workspace_idx: 2, + }, + Op::MoveWindowToWorkspace { + window_id: None, + workspace_idx: 3, + }, + Op::MoveColumnToWorkspaceDown, + Op::MoveColumnToWorkspaceUp, + Op::MoveColumnToWorkspace(1), + Op::MoveColumnToWorkspace(2), + Op::MoveColumnToWorkspace(3), + Op::MoveWindowDown, + Op::MoveWindowDownOrToWorkspaceDown, + Op::MoveWindowUp, + Op::MoveWindowUpOrToWorkspaceUp, + Op::ConsumeOrExpelWindowLeft { id: None }, + Op::ConsumeOrExpelWindowRight { id: None }, + ]; + + for third in every_op { + for second in every_op { + for first in every_op { + // eprintln!("{first:?}, {second:?}, {third:?}"); + + let mut layout = Layout::default(); + for op in setup_ops { + op.apply(&mut layout); + } + + first.apply(&mut layout); + layout.verify_invariants(); + second.apply(&mut layout); + layout.verify_invariants(); + third.apply(&mut layout); + layout.verify_invariants(); + } + } + } +} + +#[test] +fn primary_active_workspace_idx_not_updated_on_output_add() { + let ops = [ + Op::AddOutput(1), + Op::AddOutput(2), + Op::FocusOutput(1), + Op::AddWindow { + params: TestWindowParams::new(0), + }, + Op::FocusOutput(2), + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::RemoveOutput(2), + Op::FocusWorkspace(3), + Op::AddOutput(2), + ]; + + check_ops(&ops); +} + +#[test] +fn window_closed_on_previous_workspace() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams::new(0), + }, + Op::FocusWorkspaceDown, + Op::CloseWindow(0), + ]; + + check_ops(&ops); +} + +#[test] +fn removing_output_must_keep_empty_focus_on_primary() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams::new(0), + }, + Op::AddOutput(2), + Op::RemoveOutput(1), + ]; + + let layout = check_ops(&ops); + + let MonitorSet::Normal { monitors, .. } = layout.monitor_set else { + unreachable!() + }; + + // The workspace from the removed output was inserted at position 0, so the active workspace + // must change to 1 to keep the focus on the empty workspace. + assert_eq!(monitors[0].active_workspace_idx, 1); +} + +#[test] +fn move_to_workspace_by_idx_does_not_leave_empty_workspaces() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams::new(0), + }, + Op::AddOutput(2), + Op::FocusOutput(2), + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::RemoveOutput(1), + Op::MoveWindowToWorkspace { + window_id: Some(0), + workspace_idx: 2, + }, + ]; + + let layout = check_ops(&ops); + + let MonitorSet::Normal { monitors, .. } = layout.monitor_set else { + unreachable!() + }; + + assert!(monitors[0].workspaces[1].has_windows()); +} + +#[test] +fn empty_workspaces_dont_move_back_to_original_output() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::FocusWorkspaceDown, + Op::AddWindow { + params: TestWindowParams::new(2), + }, + Op::AddOutput(2), + Op::RemoveOutput(1), + Op::FocusWorkspace(1), + Op::CloseWindow(1), + Op::AddOutput(1), + ]; + + check_ops(&ops); +} + +#[test] +fn named_workspaces_dont_update_original_output_on_adding_window() { + let ops = [ + Op::AddOutput(1), + Op::SetWorkspaceName { + new_ws_name: 1, + ws_name: None, + }, + Op::AddOutput(2), + Op::RemoveOutput(1), + Op::FocusWorkspaceUp, + // Adding a window updates the original output for unnamed workspaces. + Op::AddWindow { + params: TestWindowParams::new(1), + }, + // Connecting the previous output should move the named workspace back since its + // original output wasn't updated. + Op::AddOutput(1), + ]; + + let layout = check_ops(&ops); + let (mon, _, ws) = layout + .workspaces() + .find(|(_, _, ws)| ws.name().is_some()) + .unwrap(); + assert!(ws.name().is_some()); // Sanity check. + let mon = mon.unwrap(); + assert_eq!(mon.output_name(), "output1"); +} + +#[test] +fn workspaces_update_original_output_on_moving_to_same_output() { + let ops = [ + Op::AddOutput(1), + Op::SetWorkspaceName { + new_ws_name: 1, + ws_name: None, + }, + Op::AddOutput(2), + Op::RemoveOutput(1), + Op::FocusWorkspaceUp, + Op::MoveWorkspaceToOutput(2), + Op::AddOutput(1), + ]; + + let layout = check_ops(&ops); + let (mon, _, ws) = layout + .workspaces() + .find(|(_, _, ws)| ws.name().is_some()) + .unwrap(); + assert!(ws.name().is_some()); // Sanity check. + let mon = mon.unwrap(); + assert_eq!(mon.output_name(), "output2"); +} + +#[test] +fn workspaces_update_original_output_on_moving_to_same_monitor() { + let ops = [ + Op::AddOutput(1), + Op::SetWorkspaceName { + new_ws_name: 1, + ws_name: None, + }, + Op::AddOutput(2), + Op::RemoveOutput(1), + Op::FocusWorkspaceUp, + Op::MoveWorkspaceToMonitor { + ws_name: Some(1), + output_id: 2, + }, + Op::AddOutput(1), + ]; + + let layout = check_ops(&ops); + let (mon, _, ws) = layout + .workspaces() + .find(|(_, _, ws)| ws.name().is_some()) + .unwrap(); + assert!(ws.name().is_some()); // Sanity check. + let mon = mon.unwrap(); + assert_eq!(mon.output_name(), "output2"); +} + +#[test] +fn large_negative_height_change() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams::new(1), + }, + Op::SetWindowHeight { + id: None, + change: SizeChange::AdjustProportion(-1e129), + }, + ]; + + let mut options = Options::default(); + options.border.off = false; + options.border.width = FloatOrInt(1.); + + check_ops_with_options(options, &ops); +} + +#[test] +fn large_max_size() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { + params: TestWindowParams { + min_max_size: (Size::from((0, 0)), Size::from((i32::MAX, i32::MAX))), + ..TestWindowParams::new(1) + }, + }, + ]; + + let mut options = Options::default(); + options.border.off = false; + options.border.width = FloatOrInt(1.); + + check_ops_with_options(