paneru 0.5.2

A sliding, tiling window manager for MacOS.
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Duration;

use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, TaskPoolBuilder};
use bevy::time::TimeUpdateStrategy;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};

use crate::commands::register_commands;
use crate::config::Config;
use crate::ecs::display::DisplayEventsPlugin;
use crate::ecs::focus::FocusEventsPlugin;
use crate::ecs::layout::LayoutEventsPlugin;
use crate::ecs::mouse::MouseEventsPlugin;
use crate::ecs::scroll::ScrollEventsPlugin;
use crate::ecs::state::PaneruState;
use crate::ecs::workspace::WorkspaceEventsPlugin;
use crate::ecs::{
    BProcess, ExistingMarker, FocusFollowsMouse, Initializing, MissionControlActive, SkipReshuffle,
    SpawnWindowTrigger, register_systems, register_triggers,
};
use crate::events::Event;
use crate::manager::{Window, WindowManager};
use crate::platform::{Pid, WinID, WorkspaceId};

use super::*;

/// Simulated frame length, chosen to sit just under
/// `LOOP_MAX_TIMEOUT_FRAME_ACTIVE_MS` — the interval `pump_events` actually
/// paces to once a frame is active, which is when the scroll integrator runs.
/// A coarser tick would land above that integrator's step cap and exercise
/// clamped physics production never sees.
const FRAME: Duration = Duration::from_millis(20);

/// How much simulated time each command in [`TestHarness::run`] is given to
/// play out.
const COMMAND_WINDOW: Duration = Duration::from_millis(500);

/// Derived rather than written out, so that retuning [`FRAME`] keeps the
/// simulated time per command fixed instead of silently rescaling every
/// timing-sensitive expectation in the suite.
const UPDATES_PER_COMMAND: usize = (COMMAND_WINDOW.as_millis() / FRAME.as_millis()) as usize;

type VerifierFunc = Box<dyn FnMut(&mut World, MockState)>;
pub(crate) struct TestHarness {
    pub(crate) app: App,
    pub(crate) mock_state: MockState,
    pub(crate) verifiers: HashMap<usize, VerifierFunc>,
}

impl TestHarness {
    pub(crate) fn new() -> Self {
        let pid = TEST_PROCESS_ID;
        let mut app = setup_world();
        let mut mock_state = MockState::new();

        // Setup default display
        mock_state.add_display(
            TEST_DISPLAY_ID,
            IRect::new(0, 0, TEST_DISPLAY_WIDTH, TEST_DISPLAY_HEIGHT),
            vec![TEST_WORKSPACE_ID],
        );

        // Initialize Bevy with the mocked process and WM
        let world = app.world_mut();

        mock_state.spawn_app(pid, "test", "TestApp");

        let mock_process = mock_state.create_process(pid);
        let process_entity = world.spawn(BProcess(Box::new(mock_process))).id();

        let application = mock_state.create_application(pid);
        world.spawn((ExistingMarker, ChildOf(process_entity), application));

        let wm = mock_state.create_window_manager();
        world.insert_resource(WindowManager(Box::new(wm)));

        Self {
            app,
            mock_state,
            verifiers: HashMap::new(),
        }
    }

    pub(crate) fn world(&mut self) -> &mut World {
        self.app.world_mut()
    }

    pub(crate) fn with_windows(mut self, count: i32) -> Self {
        let pid = TEST_PROCESS_ID;

        let windows = (0..count)
            .map(|i| {
                let win_id = i as WinID;
                let frame = IRect::new(0, 0, TEST_WINDOW_WIDTH, TEST_WINDOW_HEIGHT);
                self.mock_state
                    .spawn_window(pid, TEST_WORKSPACE_ID, win_id, frame)
            })
            .collect::<Vec<_>>();
        self.app.world_mut().trigger(SpawnWindowTrigger(windows));

        self
    }

    pub(crate) fn with_window<F>(mut self, id: WinID, f: F) -> Self
    where
        F: FnOnce(&mut MockWindowData),
    {
        let pid = TEST_PROCESS_ID;
        let frame = IRect::new(0, 0, TEST_WINDOW_WIDTH, TEST_WINDOW_HEIGHT);
        let window = self
            .mock_state
            .spawn_window(pid, TEST_WORKSPACE_ID, id, frame);
        self.mock_state.update_window(id, f);
        self.app
            .world_mut()
            .trigger(SpawnWindowTrigger(vec![window]));
        self
    }

    pub(crate) fn with_workspace_window<F>(
        mut self,
        id: WinID,
        workspace_id: WorkspaceId,
        f: F,
    ) -> Self
    where
        F: FnOnce(&mut MockWindowData),
    {
        let pid = TEST_PROCESS_ID;
        let frame = IRect::new(0, 0, TEST_WINDOW_WIDTH, TEST_WINDOW_HEIGHT);
        let window = self.mock_state.spawn_window(pid, workspace_id, id, frame);
        self.mock_state.update_window(id, f);
        self.app
            .world_mut()
            .trigger(SpawnWindowTrigger(vec![window]));
        self
    }

    #[allow(unused)]
    pub(crate) fn with_focused_window(self, id: WinID) -> Self {
        self.mock_state.focus_window(id);
        self
    }

    pub(crate) fn with_display(
        mut self,
        id: u32,
        bounds: IRect,
        workspaces: Vec<WorkspaceId>,
    ) -> Self {
        self.mock_state.add_display(id, bounds, workspaces);
        self
    }

    #[allow(unused)]
    pub(crate) fn with_app<F>(mut self, pid: Pid, bundle_id: &str, name: &str, f: F) -> Self
    where
        F: FnOnce(&mut MockAppData),
    {
        self.mock_state.spawn_app(pid, bundle_id, name);
        self.mock_state.update_app(pid, f);

        let world = self.app.world_mut();
        let mock_process = self.mock_state.create_process(pid);
        let process_entity = world.spawn(BProcess(Box::new(mock_process))).id();

        let application = self.mock_state.create_application(pid);
        world.spawn((ExistingMarker, ChildOf(process_entity), application));

        self
    }

    pub(crate) fn with_config(mut self, config: Config) -> Self {
        self.app.world_mut().insert_resource(config);
        self
    }

    pub(crate) fn with_state(mut self, state: PaneruState) -> Self {
        self.app.world_mut().insert_resource(state);
        self
    }

    pub(crate) fn on_iteration<F>(mut self, iteration: usize, verifier: F) -> Self
    where
        F: FnMut(&mut World, MockState) + 'static,
    {
        self.verifiers.insert(iteration, Box::new(verifier));
        self
    }

    /// Runs the app for `duration` of simulated time, draining the mock
    /// platform's events each frame just as [`Self::run`] does.
    ///
    /// Waits on a deadline — a grace timer expiring, an animation reaching its
    /// target — have to be expressed in time, not in a frame count: a frame is
    /// [`FRAME`] long, so a bare `for _ in 0..10 { app.update() }` silently
    /// changes how long it waits whenever that constant is retuned.
    pub(crate) fn advance(&mut self, duration: Duration) {
        let frames = duration.as_millis().div_ceil(FRAME.as_millis());
        for _ in 0..frames {
            self.app.update();

            for event in self.mock_state.drain_events() {
                self.app.world_mut().write_message::<Event>(event);
            }
        }
    }

    pub(crate) fn run(&mut self, commands: Vec<Event>) {
        for (iteration, command) in commands.into_iter().enumerate() {
            self.app.world_mut().write_message::<Event>(command);

            for _ in 0..UPDATES_PER_COMMAND {
                self.app.update();

                // Drain and process events from our virtual OS
                for event in self.mock_state.drain_events() {
                    self.app.world_mut().write_message::<Event>(event);
                }
            }

            if let Some(verifier) = self.verifiers.get_mut(&iteration) {
                verifier(self.app.world_mut(), self.mock_state.clone());
            }
        }
    }
}

fn setup_world() -> App {
    static DONE: OnceLock<()> = OnceLock::new();
    DONE.get_or_init(|| {
        _ = tracing_subscriber::registry()
            .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
            .with(
                fmt::layer()
                    .with_level(true)
                    .with_line_number(true)
                    .with_file(true)
                    .with_target(true)
                    .with_thread_ids(false)
                    .with_writer(std::io::stderr)
                    .compact(),
            )
            .try_init();

        let _pool = AsyncComputeTaskPool::get_or_init(|| {
            TaskPoolBuilder::new()
                .num_threads(1) // Keep it light for tests
                .build()
        });
        assert!(AsyncComputeTaskPool::try_get().is_some());
    });
    let mut bevy_app = App::new();
    bevy_app
        .add_plugins(MinimalPlugins)
        .add_message::<Event>()
        .insert_resource(SkipReshuffle(false))
        .insert_resource(MissionControlActive(false))
        .insert_resource(FocusFollowsMouse(None))
        .insert_resource(Config::default())
        .insert_resource(Initializing)
        .add_plugins(MouseEventsPlugin)
        .add_plugins(ScrollEventsPlugin)
        .add_plugins(WorkspaceEventsPlugin)
        .add_plugins(LayoutEventsPlugin)
        .add_plugins(FocusEventsPlugin)
        .add_plugins(DisplayEventsPlugin)
        .add_plugins((register_triggers, register_systems, register_commands));

    bevy_app.insert_resource(TimeUpdateStrategy::ManualDuration(FRAME));

    bevy_app
}

pub(crate) fn find_window_entity(window_id: WinID, world: &mut World) -> Entity {
    let mut query = world.query::<(&Window, Entity)>();
    query
        .iter(world)
        .find(|(w, _)| w.id() == window_id)
        .map_or_else(|| panic!("window {window_id} not found"), |(_, e)| e)
}

#[macro_export]
macro_rules! assert_window_at {
    ($world:expr, $id:expr, $x:expr, $y:expr) => {{
        let mut query = $world.query::<&$crate::manager::Window>();
        let window = query
            .iter($world)
            .find(|w| w.id() == $id)
            .expect("window not found");
        assert_eq!(
            window.frame().min.x,
            $x,
            "window {} x position mismatch",
            $id
        );
        assert_eq!(
            window.frame().min.y,
            $y,
            "window {} y position mismatch",
            $id
        );
    }};
}

#[macro_export]
macro_rules! assert_window_size {
    ($world:expr, $id:expr, $w:expr, $h:expr) => {{
        let mut query = $world.query::<&$crate::manager::Window>();
        let window = query
            .iter($world)
            .find(|w| w.id() == $id)
            .expect("window not found");
        let frame = window.frame();
        assert_eq!(frame.width(), $w, "window {} width mismatch", $id);
        assert_eq!(frame.height(), $h, "window {} height mismatch", $id);
    }};
}

#[macro_export]
macro_rules! assert_focused {
    ($world:expr, $id:expr) => {{
        let mut query = $world.query::<(
            &$crate::manager::Window,
            bevy::ecs::query::Has<$crate::ecs::FocusedMarker>,
        )>();
        let (_, focused) = query
            .iter($world)
            .find(|(w, _)| w.id() == $id)
            .expect("window not found");
        assert!(focused, "window {} should be focused", $id);
    }};
}

#[macro_export]
macro_rules! assert_on_workspace {
    ($world:expr, $window_id:expr, $workspace_id:expr) => {{
        let entity = $crate::tests::harness::find_window_entity($window_id, $world);
        let mut query = $world.query::<&$crate::ecs::layout::LayoutStrip>();
        let found = query
            .iter($world)
            .any(|strip| strip.id() == $workspace_id && strip.index_of(entity).is_ok());
        assert!(
            found,
            "window {} should be on workspace {}",
            $window_id, $workspace_id
        );
    }};
}

#[macro_export]
macro_rules! assert_not_on_workspace {
    ($world:expr, $window_id:expr, $workspace_id:expr) => {{
        let entity = $crate::tests::harness::find_window_entity($window_id, $world);
        let mut query = $world.query::<&$crate::ecs::layout::LayoutStrip>();
        let found = query
            .iter($world)
            .any(|strip| strip.id() == $workspace_id && strip.index_of(entity).is_ok());
        assert!(
            !found,
            "window {} should NOT be on workspace {}",
            $window_id, $workspace_id
        );
    }};
}