rig-harness 0.45.0

The harness of the rig coding agent on the rig-ecs kernel: the session and the launcher protocol; every other part is a Bevy plugin crate.
Documentation
use bevy_app::TaskPoolPlugin;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use rig_ecs::AgentPlugin;
use rig_ecs::agent::Notice;
use rig_ecs::stage::{AgentPass, Decision, HoldOf, Outcome, PluginEffects, ReadyToStart, Stage};
use rig_harness_protocol::RELOAD_EXIT_CODE;

use super::{Exit, ExitPlugin, ExitReason, ExitRequest};

/// The exits and notices of each frame.
#[derive(Resource, Default)]
struct Seen {
    exits: Vec<AppExit>,
    notices: Vec<String>,
}

fn app() -> App {
    let mut app = App::new();
    app.add_message::<AppExit>()
        .init_resource::<Seen>()
        .add_plugins((TaskPoolPlugin::default(), AgentPlugin, ExitPlugin))
        .add_systems(
            Last,
            |mut exits: MessageReader<AppExit>,
             mut notices: MessageReader<Notice>,
             mut seen: ResMut<Seen>| {
                seen.exits.extend(exits.read().cloned());
                seen.notices
                    .extend(notices.read().map(|notice| notice.text.clone()));
            },
        );
    app
}

#[test]
fn the_first_reason_stands_and_writes_the_launchers_exit_code() {
    let mut app = app();
    app.world_mut().trigger(Exit(ExitReason::Reload));
    app.world_mut().trigger(Exit(ExitReason::Quit));
    app.update();
    assert_eq!(
        app.world().get_resource::<ExitReason>(),
        Some(&ExitReason::Reload)
    );
    assert_eq!(
        app.world().resource::<Seen>().exits,
        [AppExit::from_code(RELOAD_EXIT_CODE)]
    );
}

#[test]
fn a_plain_exit_gets_the_reason_its_code_stands_for() {
    for (exit, reason) in [
        (AppExit::Success, ExitReason::Quit),
        (AppExit::from_code(RELOAD_EXIT_CODE), ExitReason::Reload),
        (AppExit::from_code(130), ExitReason::Signal),
        (AppExit::from_code(2), ExitReason::Failed(2)),
    ] {
        assert_eq!(reason.exit(), exit);
        let mut app = app();
        app.world_mut().write_message(exit);
        app.update();
        assert_eq!(app.world().get_resource::<ExitReason>(), Some(&reason));
    }
    assert_eq!(
        ExitReason::Switch(None).exit(),
        AppExit::from_code(RELOAD_EXIT_CODE)
    );
}

#[test]
fn a_switch_without_the_launcher_keeps_the_app_running() {
    let mut app = app();
    app.world_mut().trigger(Exit(ExitReason::Switch(None)));
    app.update();
    let seen = app.world().resource::<Seen>();
    assert!(seen.exits.is_empty());
    assert!(
        seen.notices
            .iter()
            .any(|notice| notice.contains("needs the rig launcher")),
        "{:?}",
        seen.notices
    );
    assert!(app.world().get_resource::<ExitReason>().is_none());
}

/// A hold on the first exit asked for.
#[derive(Component)]
struct NotesHold;

fn hold_first_exit(
    exits: Query<(Entity, &Decision), (With<ExitRequest>, ReadyToStart, PluginEffects)>,
    mut held: Local<bool>,
    mut commands: Commands,
) {
    if *held {
        return;
    }
    if let Some((exit, _)) = exits.iter().find(|(_, decision)| !decision.is_decided()) {
        *held = true;
        commands.spawn((NotesHold, HoldOf(exit)));
    }
}

#[test]
fn a_held_exit_waits_for_its_hold_then_exits_once_with_the_first_reason() {
    let mut app = app();
    app.add_systems(AgentPass, hold_first_exit.in_set(Stage::Gate));
    app.world_mut().trigger(Exit(ExitReason::Quit));
    for _ in 0..3 {
        app.update();
    }
    assert!(app.world().resource::<Seen>().exits.is_empty());
    assert_eq!(
        app.world().get_resource::<ExitReason>(),
        Some(&ExitReason::Quit)
    );
    let world = app.world_mut();
    let holds: Vec<Entity> = world
        .query_filtered::<Entity, With<NotesHold>>()
        .iter(world)
        .collect();
    assert_eq!(holds.len(), 1);
    for hold in holds {
        world.despawn(hold);
    }
    app.update();
    app.update();
    assert_eq!(app.world().resource::<Seen>().exits, [AppExit::Success]);
}

#[test]
fn a_refused_exit_still_exits() {
    let mut app = app();
    app.add_systems(
        AgentPass,
        (|mut exits: Query<&mut Decision, (With<ExitRequest>, ReadyToStart, PluginEffects)>| {
            for mut decision in &mut exits {
                decision.decide(
                    rig_core::effect::Decider::plugin("test"),
                    Outcome::refused("not now"),
                );
            }
        })
        .in_set(Stage::Gate),
    );
    app.world_mut().trigger(Exit(ExitReason::Failed(3)));
    app.update();
    assert_eq!(
        app.world().resource::<Seen>().exits,
        [AppExit::from_code(3)]
    );
}