bitt 0.5.0

Bevy integration testing toolkit
Documentation
use std::{
    fs::{create_dir_all, File},
    path::PathBuf,
};

use bevy::{
    app::AppExit,
    input::mouse::{MouseMotion, MouseWheel},
    prelude::*,
    utils::HashMap,
    window::PrimaryWindow,
};

use crate::TestWrangler;

use super::{StartTime, TestScript, UserInput};

#[derive(Debug, Clone, Copy, Event)]
struct SaveQuitEvent;

#[derive(Debug, Clone, Resource)]
struct ScriptPath(PathBuf);

pub(crate) struct RecordingPlugin {
    pub(crate) script_path: PathBuf,
}

impl Plugin for RecordingPlugin {
    fn build(&self, app: &mut App) {
        app.insert_resource(TestScript::default())
            .add_systems(First, (script_recorder, recording_asserter).chain())
            .add_event::<SaveQuitEvent>()
            .insert_resource(ScriptPath(self.script_path.clone()))
            .add_systems(PostUpdate, save_script.run_if(on_event::<SaveQuitEvent>()));
    }
}

#[allow(clippy::too_many_arguments)]
fn script_recorder(
    mut script: ResMut<TestScript>,
    time: Res<Time<Real>>,
    first_update: Option<Res<StartTime>>,
    input: Res<ButtonInput<KeyCode>>,
    mouse_buttons: Res<ButtonInput<MouseButton>>,
    pad_buttons: Res<ButtonInput<GamepadButton>>,
    axis: Res<Axis<GamepadAxis>>,
    mut scroll_evr: EventReader<MouseWheel>,
    mut motion_evr: EventReader<MouseMotion>,
    mut axis_cache: Local<HashMap<GamepadAxis, f32>>,
    window_query: Query<&Window, With<PrimaryWindow>>,
) {
    let Some(start_time) = first_update else {
        return;
    };

    let timestamp = time.elapsed() - start_time.0;

    for key in input.get_just_pressed() {
        script.events.push((timestamp, UserInput::KeyPress(*key)));
    }

    for key in input.get_just_released() {
        script.events.push((timestamp, UserInput::KeyRelese(*key)));
    }

    for button in mouse_buttons.get_just_pressed() {
        script
            .events
            .push((timestamp, UserInput::MouseButtonPress(*button)));
    }

    for button in mouse_buttons.get_just_released() {
        script
            .events
            .push((timestamp, UserInput::MouseButtonRelease(*button)));
    }

    for button in pad_buttons.get_just_pressed() {
        script
            .events
            .push((timestamp, UserInput::ControllerButtonPress(*button)));
    }

    for button in pad_buttons.get_just_released() {
        script
            .events
            .push((timestamp, UserInput::ControllerButtonRelease(*button)));
    }

    if axis.is_changed() {
        for dev in axis.devices() {
            let Some(value) = axis.get(*dev) else {
                continue;
            };

            if axis_cache
                .get(dev)
                .map(|cached| (value - cached).abs() > 0.01)
                .unwrap_or(true)
            {
                axis_cache.insert(*dev, value);
                script
                    .events
                    .push((timestamp, UserInput::ControllerAxisChange(*dev, value)));
            }
        }
    }

    for scroll in scroll_evr.read() {
        script
            .events
            .push((timestamp, UserInput::MouseScroll(*scroll)));
    }

    let window = window_query.single();
    let cursor_pos = window.cursor_position();

    for motion in motion_evr.read() {
        script
            .events
            .push((timestamp, UserInput::MouseMove(motion.delta, cursor_pos)));
    }
}

fn recording_asserter(
    asserter: ResMut<TestWrangler>,
    mut quit_events: EventWriter<SaveQuitEvent>,
    mut delay: Local<Option<Timer>>,
    time: Res<Time<Real>>,
) {
    if let Some(ref mut timer) = *delay {
        if timer.tick(time.delta()).just_finished() {
            quit_events.send(SaveQuitEvent);
        }
    } else if asserter.outcome == Some(true) {
        *delay = Some(Timer::from_seconds(0.2, TimerMode::Once));
    }
}

fn save_script(
    script: Res<TestScript>,
    path: Res<ScriptPath>,
    time: Res<Time<Real>>,
    first_update: Option<Res<StartTime>>,
    mut quit_events: ResMut<Events<AppExit>>,
) {
    let Some(start_time) = first_update else {
        return;
    };

    let mut script = script.clone();
    script
        .events
        .push((time.elapsed() - start_time.0, UserInput::Quit));

    let prefix = path.0.parent().unwrap();
    create_dir_all(prefix).unwrap();
    let file = File::create(&path.0).unwrap();
    serde_json::to_writer(file, &script).unwrap();
    quit_events.send(AppExit);
}