use bevy::prelude::*;
use bevy_fairy::{
evaluate_checkpoint, handle_guide, watch_guide, Checkpoints, DebuggerPlugin, Guide,
GuideOverlayPlugin, Step, BEAT_SECONDS,
};
use bevy::ecs::system::RunSystemOnce;
use serde_json::{json, Value};
#[derive(Resource, Default)]
struct Ready(bool);
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Guide>()
.init_resource::<Checkpoints>()
.init_resource::<Ready>()
.add_systems(Last, evaluate_checkpoint);
register_ready(&mut app);
app
}
fn register_ready(app: &mut App) {
let id = app.register_system(|args: In<Value>, ready: Res<Ready>| {
let want = args.0.get("want").and_then(|v| v.as_bool()).unwrap_or(true);
ready.0 == want
});
app.world_mut().resource_mut::<Checkpoints>().register("ready", id);
}
fn plugin_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::input::InputPlugin, DebuggerPlugin::default()))
.init_resource::<Ready>();
register_ready(&mut app);
app.update();
app
}
fn post(app: &mut App, params: Value) -> Value {
match app.world_mut().run_system_once_with(handle_guide, Some(params)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => panic!("handler refused: {}", e.message),
Err(e) => panic!("could not run handler: {e}"),
}
}
fn refusal(app: &mut App, params: Value) -> String {
match app.world_mut().run_system_once_with(handle_guide, Some(params)) {
Ok(Err(e)) => e.message,
Ok(Ok(v)) => panic!("expected a refusal, got {v}"),
Err(e) => panic!("could not run handler: {e}"),
}
}
fn watch(app: &mut App) -> Option<Value> {
match app.world_mut().run_system_once_with(watch_guide, None) {
Ok(Ok(v)) => v,
Ok(Err(e)) => panic!("watch refused: {}", e.message),
Err(e) => panic!("could not run watch: {e}"),
}
}
fn post_from_rust(app: &mut App, params: Value) {
let steps: Vec<Step> = serde_json::from_value(params["steps"].clone()).expect("steps");
app.world_mut().resource_mut::<Guide>().post(steps);
}
fn watch_refusal(app: &mut App) -> String {
match app.world_mut().run_system_once_with(watch_guide, None) {
Ok(Err(e)) => e.message,
Ok(Ok(v)) => panic!("expected a refusal, got {v:?}"),
Err(e) => panic!("could not run watch: {e}"),
}
}
fn script(checkpoint: Value) -> Value {
json!({"steps": [
{"label": "drop a floor", "goal": "the tile needs something in it",
"do": ["press Enter"], "checkpoint": checkpoint,
"recovery": "if nothing lands, the library row is empty"},
{"label": "save it", "do": ["press Cmd+S"]}
]})
}
#[test]
fn a_checkpoint_that_is_not_met_parks_the_request() {
let mut app = app();
post(&mut app, script(json!("ready")));
assert_eq!(watch(&mut app), None);
assert_eq!(watch(&mut app), None, "still parked on the next frame");
let report = post(&mut app, json!({"read": true}));
assert_eq!(report["guide"]["at"], 1, "still on the first step");
}
#[test]
fn a_checkpoint_that_passes_advances_and_records_it() {
let mut app = app();
post(&mut app, script(json!("ready")));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
let answer = watch(&mut app).expect("the checkpoint passed, so the stream answers");
assert_eq!(answer["passed"], "drop a floor", "names the step that just completed");
assert_eq!(answer["guide"]["at"], 2, "and the app has moved on");
let rows = &answer["guide"]["steps"];
assert_eq!(rows[0]["passes"], 1);
assert_eq!(rows[0]["runs"], 1, "1/1");
assert_eq!(rows[1]["runs"], 1, "the next step is now being attempted");
assert_eq!(rows[1]["passes"], 0);
}
#[test]
fn a_checkpoint_nobody_registered_is_refused_by_name() {
let mut app = app();
post_from_rust(&mut app, script(json!("tile is saved")));
let message = watch_refusal(&mut app);
assert!(message.contains("tile is saved"), "names what was asked for: {message}");
assert!(
message.contains("ready"),
"and what would have worked, so the script can be fixed without reading this crate: {message}"
);
assert!(message.contains("for ever") || message.contains("forever"));
}
#[test]
fn a_step_with_no_checkpoint_is_announced_once_not_every_frame() {
let mut app = app();
post(&mut app, script(Value::Null));
let first = watch(&mut app).expect("says so once");
assert_eq!(first["waiting_on_a_person"], true);
assert_eq!(first["step"], "drop a floor");
assert_eq!(watch(&mut app), None, "and then parks");
assert_eq!(watch(&mut app), None);
post(&mut app, json!({"skip": true}));
let second = watch(&mut app).expect("the next step announces itself in turn");
assert_eq!(second["step"], "save it");
}
#[test]
fn the_end_of_the_script_is_announced_once_not_every_frame() {
let mut app = app();
post(&mut app, json!({"steps": [{"label": "the only step", "checkpoint": "ready"}]}));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
assert_eq!(watch(&mut app).expect("the step passes")["passed"], "the only step");
let done = watch(&mut app).expect("then the script reports itself finished");
assert_eq!(done["done"], true);
assert_eq!(done["guide"]["steps"][0]["passes"], 1);
assert_eq!(watch(&mut app), None, "once, not sixty times a second");
assert_eq!(watch(&mut app), None);
}
#[test]
fn a_checkpoint_that_passes_advances_the_step_with_nobody_watching() {
let mut app = plugin_app();
post(&mut app, script(json!("ready")));
app.update();
let before = post(&mut app, json!({"read": true}));
assert_eq!(before["guide"]["at"], 1, "its condition is false, so it stays: {before}");
assert_eq!(before["guide"]["checkpoint"], "ready", "a read names what the app is waiting for");
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
let after = post(&mut app, json!({"read": true}));
assert_eq!(after["guide"]["at"], 2, "the condition is true, so the app moved on: {after}");
assert_eq!(after["guide"]["steps"][0]["passes"], 1);
assert_eq!(after["guide"]["steps"][0]["runs"], 1, "1/1");
}
#[test]
fn the_watch_stream_reports_a_pass_the_app_recorded_before_it_attached() {
let mut app = plugin_app();
post(&mut app, script(json!("ready")));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
let passed = watch(&mut app).expect("the pass is reported to the first watcher");
assert_eq!(passed["passed"], "drop a floor");
assert_eq!(passed["guide"]["at"], 2, "with the script's position now");
let next = watch(&mut app).expect("then the next step names itself");
assert_eq!(next["waiting_on_a_person"], true);
assert_eq!(watch(&mut app), None, "and nothing is repeated");
}
#[test]
fn a_checkpoint_the_app_cannot_run_is_said_by_a_read() {
let mut app = plugin_app();
let step: Step = serde_json::from_value(json!({"label": "save it", "checkpoint": "tile is saved"}))
.expect("a step");
app.world_mut().resource_mut::<Guide>().post(vec![step]);
app.update();
let read = post(&mut app, json!({"read": true}));
let why = read["guide"]["checkpoint_error"].as_str().unwrap_or_else(|| panic!("a read is silent: {read}"));
assert!(why.contains("tile is saved") && why.contains("ready"), "names it and what would work: {why}");
}
#[test]
fn a_skip_is_an_attempt_that_did_not_pass() {
let mut app = app();
post(&mut app, script(json!("ready")));
let answer = post(&mut app, json!({"skip": true}));
assert_eq!(answer["skipped"], "drop a floor");
let rows = &answer["guide"]["steps"];
assert_eq!(rows[0]["runs"], 1);
assert_eq!(
rows[0]["passes"], 0,
"0/1 — the escape hatch Choong et al. found is needed, because 18 of 20 of their \
participants followed a suggestion that made no sense rather than say so"
);
}
#[test]
fn re_running_the_same_script_accumulates_the_counts() {
let mut app = app();
post(&mut app, script(json!("ready")));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
post(&mut app, script(json!("ready")));
let after = post(&mut app, json!({"read": true}));
assert_eq!(after["guide"]["steps"][0]["runs"], 2, "n counts every attempt");
assert_eq!(after["guide"]["steps"][0]["passes"], 1, "k counts the ones that worked");
app.update();
let again = post(&mut app, json!({"read": true}));
assert_eq!(again["guide"]["steps"][0]["passes"], 2, "2/2");
}
#[test]
fn a_different_script_starts_its_own_counts() {
let mut app = app();
post(&mut app, script(json!("ready")));
post(&mut app, json!({"skip": true}));
post(&mut app, json!({"steps": [{"label": "something else"}]}));
let report = post(&mut app, json!({"read": true}));
assert_eq!(report["guide"]["steps"][0]["step"], "something else");
assert_eq!(report["guide"]["steps"][0]["runs"], 1, "not carried over from a different script");
}
#[test]
fn an_empty_script_is_refused_rather_than_posted() {
let mut app = app();
let message = refusal(&mut app, json!({"steps": []}));
assert!(message.contains("clear"), "and points at the verb that was meant: {message}");
}
#[test]
fn clearing_takes_the_script_down_and_keeps_the_answer() {
let mut app = app();
post(&mut app, script(json!("ready")));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
post(&mut app, json!({"clear": true}));
let report = post(&mut app, json!({"read": true}));
assert_eq!(
report["guide"]["steps"][0]["passes"], 1,
"the transcript survives, because it is what the exercise was for"
);
}
#[test]
fn a_script_naming_an_unregistered_checkpoint_is_refused_when_posted() {
let mut app = app();
let message = refusal(&mut app, script(json!("tile is saved")));
assert!(message.contains("tile is saved"), "names what was asked for: {message}");
assert!(message.contains("drop a floor"), "and the step that asked: {message}");
assert!(message.contains("ready"), "and what would have worked: {message}");
assert!(message.contains("Nothing was posted"), "{message}");
let read = post(&mut app, json!({"read": true}));
assert_eq!(read["guide"]["of"], 0, "nothing was posted: {read}");
}
#[test]
fn a_call_naming_two_verbs_is_refused_and_does_nothing() {
let mut app = app();
post(&mut app, script(json!("ready")));
let message = refusal(&mut app, json!({"clear": true, "skip": true}));
assert!(message.contains("one verb per call"), "{message}");
assert!(message.contains("clear") && message.contains("skip"), "names both: {message}");
let message = refusal(&mut app, json!({"read": true, "steps": [{"label": "x"}]}));
assert!(message.contains("read") && message.contains("steps"), "{message}");
let read = post(&mut app, json!({"read": true}));
assert_eq!(read["guide"]["of"], 2, "a refused call changed nothing: {read}");
assert_eq!(read["guide"]["at"], 1);
}
#[test]
fn skipping_with_nothing_to_skip_is_refused() {
let mut app = app();
let message = refusal(&mut app, json!({"skip": true}));
assert!(message.contains("no current step"), "{message}");
post(&mut app, json!({"steps": [{"label": "the only step"}]}));
post(&mut app, json!({"skip": true}));
let message = refusal(&mut app, json!({"skip": true}));
assert!(message.contains("no current step"), "a finished script has none either: {message}");
}
#[test]
fn visible_is_honoured_with_a_post_and_refused_where_it_cannot_act() {
let mut app = app();
post(&mut app, json!({"steps": [{"label": "quiet"}], "visible": false}));
assert!(!app.world().resource::<Guide>().visible, "the script was posted hidden");
let message = refusal(&mut app, json!({"read": true, "visible": true}));
assert!(message.contains("visible") && message.contains("read"), "{message}");
assert!(!app.world().resource::<Guide>().visible, "and the refusal changed nothing");
}
fn on_screen(app: &mut App) -> Vec<String> {
let mut q = app.world_mut().query::<&Text>();
q.iter(app.world()).map(|t| t.0.clone()).collect()
}
fn shown(app: &mut App) -> bool {
let mut q = app.world_mut().query_filtered::<&Node, With<bevy_fairy::GuideOverlay>>();
q.iter(app.world()).any(|n| n.display != Display::None)
}
fn ui_app() -> App {
let mut app = app();
app.add_plugins(GuideOverlayPlugin);
app.update();
app
}
#[test]
fn the_overlay_says_nothing_until_there_is_a_step() {
let mut app = ui_app();
assert!(!shown(&mut app), "an app with the plugin on and no script shows no card");
assert!(on_screen(&mut app).is_empty());
}
#[test]
fn the_overlay_shows_one_step_and_not_the_script() {
let mut app = ui_app();
post(&mut app, script(json!("ready")));
app.update();
let lines = on_screen(&mut app).join("\n");
assert!(shown(&mut app));
assert!(lines.contains("STEP 1 OF 2"), "segmented, van der Meij: {lines}");
assert!(lines.contains("drop a floor"));
assert!(lines.contains("the tile needs something in it"), "the why — Choong, 13 of 20");
assert!(lines.contains("press Enter"));
assert!(lines.contains("the library row is empty"), "the recovery field 21 of 21 shipped \
tutorials did not have");
assert!(
!lines.contains("save it"),
"and NOT the next step. One at a time is the finding, not a layout preference: {lines}"
);
}
#[test]
fn a_finished_step_is_confirmed_before_the_next_one_arrives() {
let mut app = ui_app();
post(&mut app, script(json!("ready")));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
app.update();
let held = on_screen(&mut app).join("\n");
assert!(held.contains("OK"), "the beat: {held}");
assert!(held.contains("drop a floor"), "confirming the step that just passed: {held}");
assert!(!held.contains("save it"), "the next step has not arrived yet: {held}");
let start = std::time::Instant::now();
while start.elapsed().as_secs_f32() < BEAT_SECONDS + 0.2 {
app.update();
}
let next = on_screen(&mut app).join("\n");
assert!(next.contains("save it"), "then the next step: {next}");
assert!(!next.contains("OK"), "and the confirmation is gone: {next}");
}
#[test]
fn hiding_the_overlay_keeps_the_script() {
let mut app = ui_app();
post(&mut app, script(json!("ready")));
app.update();
assert!(shown(&mut app));
post(&mut app, json!({"visible": false}));
app.update();
assert!(!shown(&mut app));
let report = post(&mut app, json!({"read": true}));
assert_eq!(report["guide"]["at"], 1, "still on step one, just not saying so");
}
#[test]
fn re_posting_a_script_while_step_one_is_up_replaces_the_card() {
let mut app = ui_app();
post(&mut app, script(json!("ready")));
app.update();
assert!(on_screen(&mut app).join("\n").contains("drop a floor"));
post(&mut app, json!({"steps": [{"label": "a different first step", "do": ["press Tab"]}]}));
app.update();
let lines = on_screen(&mut app).join("\n");
assert!(lines.contains("a different first step"), "the card still shows the old script: {lines}");
assert!(!lines.contains("drop a floor"), "{lines}");
}
#[test]
fn the_one_script_this_crate_ships_is_ascii() {
let rel = "examples/plugin_guided_steps_land.rs";
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let Ok(src) = std::fs::read_to_string(root.join(rel)) else {
panic!("{rel} is named by this test and must exist");
};
for (n, line) in src.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") || code.starts_with('*') {
continue;
}
assert!(
code.is_ascii(),
"{rel}:{} has a non-ASCII character in code that may become step copy; Bevy's default \
font has 95 codepoints and would draw it as tofu:\n {line}",
n + 1
);
}
}
#[test]
fn a_host_can_move_the_card_off_its_own_chrome() {
use bevy_fairy::GuidePlacement;
let mut app = app();
app.insert_resource(GuidePlacement { top: 58.0, width: 520.0 });
app.add_plugins(GuideOverlayPlugin);
app.update();
assert_eq!(
app.world().resource::<GuidePlacement>().top,
58.0,
"GuideOverlayPlugin's init_resource must not overwrite a host's value"
);
post(&mut app, script(json!("ready")));
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Node, With<bevy_fairy::GuideOverlay>>();
let node = q.iter(app.world()).next().cloned().expect("the overlay exists");
assert_eq!(node.top, Val::Px(58.0), "the card moved to where the host asked");
assert_eq!(node.width, Val::Px(520.0));
assert_eq!(
node.margin.left,
Val::Px(-260.0),
"and stayed centred: the pull-back is half the width, not a constant that only matched the \
default"
);
}
#[test]
fn a_skipped_step_is_not_confirmed_as_a_pass() {
let mut app = ui_app();
post(&mut app, script(json!("ready")));
post(&mut app, json!({"skip": true}));
app.update();
let skipped = on_screen(&mut app).join("\n");
assert!(skipped.contains("SKIPPED"), "says what actually happened: {skipped}");
assert!(skipped.contains("drop a floor"), "and to which step: {skipped}");
assert!(!skipped.contains("OK"), "and does NOT congratulate them for it: {skipped}");
let mut app = ui_app();
post(&mut app, script(json!("ready")));
app.world_mut().resource_mut::<Ready>().0 = true;
app.update();
app.update();
let passed = on_screen(&mut app).join("\n");
assert!(passed.contains("OK"), "a real pass still reads as one: {passed}");
assert!(!passed.contains("SKIPPED"), "{passed}");
}
#[test]
fn a_read_says_when_the_current_step_is_waiting_on_a_person() {
let mut app = app();
post(&mut app, script(Value::Null));
assert_eq!(watch(&mut app).expect("announced once")["waiting_on_a_person"], true);
assert_eq!(watch(&mut app), None, "and the stream now says nothing at all");
let report = post(&mut app, json!({"read": true}))["guide"].clone();
assert_eq!(report["waiting_on_a_person"], true, "the read still knows: {report}");
assert_eq!(report["step"], "drop a floor", "and which step it is: {report}");
post(&mut app, script(json!("ready")));
let report = post(&mut app, json!({"read": true}))["guide"].clone();
assert_eq!(
report["waiting_on_a_person"], false,
"this one is waiting on a condition, which is not the same thing: {report}"
);
assert_eq!(report["step"], "drop a floor");
}
#[test]
fn the_card_says_when_a_step_is_waiting_on_the_person() {
let mut app = ui_app();
post(&mut app, script(Value::Null));
app.update();
let lines = on_screen(&mut app).join("\n");
assert!(lines.contains("drop a floor"), "the step is up: {lines}");
assert!(
lines.contains("yours to judge"),
"the card says it is waiting on a person, in words, on the screen: {lines}"
);
assert!(
lines.contains("no key advances this step"),
"and says no key will do it, because this crate binds none -- an author hunting for the \
key that does not exist is worse off than one told plainly: {lines}"
);
let mut app = ui_app();
post(&mut app, script(json!("ready")));
app.update();
let lines = on_screen(&mut app).join("\n");
assert!(lines.contains("drop a floor"));
assert!(
!lines.contains("yours to judge"),
"this one is watched, so saying otherwise would be a lie every third step: {lines}"
);
}