#![cfg(test)]
use bevy_app::App;
use bevy_asset::{AssetPlugin, Assets, Handle};
use brink_runtime::{Program, World};
use crate::asset::{BrinkStoryAsset, LineTablesAsset, ProgramAsset, fresh_context};
pub fn compile_test_story(source: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>, World) {
let output = brink_compiler::compile("test.ink", |path| {
if path == "test.ink" {
Ok(source.to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unexpected include: {path}"),
))
}
})
.expect("test fixture should compile");
let (program, tables) = brink_runtime::link(&output.data).expect("test fixture should link");
let initial_context = fresh_context(&program);
(program, tables, initial_context)
}
pub fn compile_test_story_with_effect_rows(
source: &str,
) -> (
Program,
Vec<Vec<brink_format::LineEntry>>,
World,
Vec<brink_format::EffectRowEntry>,
) {
let output = brink_compiler::compile("test.ink", |path| {
if path == "test.ink" {
Ok(source.to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unexpected include: {path}"),
))
}
})
.expect("test fixture should compile");
let (program, tables) = brink_runtime::link(&output.data).expect("test fixture should link");
let initial_context = fresh_context(&program);
(program, tables, initial_context, output.data.effect_rows)
}
pub fn compile_test_story_brink(
source: &str,
) -> (Program, Vec<Vec<brink_format::LineEntry>>, World) {
use brink_compiler::{AnalysisOptions, Dialect};
let output = brink_compiler::compile_with_options(
"test.ink",
|path| {
if path == "test.ink" {
Ok(source.to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unexpected include: {path}"),
))
}
},
AnalysisOptions {
dialect: Dialect::Brink,
..AnalysisOptions::default()
},
)
.expect("brink test fixture should compile");
let (program, tables) = brink_runtime::link(&output.data).expect("test fixture should link");
let initial_context = fresh_context(&program);
(program, tables, initial_context)
}
pub fn compile_test_story_brink_gradual(
source: &str,
) -> (Program, Vec<Vec<brink_format::LineEntry>>, World) {
use brink_compiler::{AnalysisOptions, Dialect, TypePolicy};
let output = brink_compiler::compile_with_options(
"test.ink",
|path| {
if path == "test.ink" {
Ok(source.to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unexpected include: {path}"),
))
}
},
AnalysisOptions {
dialect: Dialect::Brink,
types: Some(TypePolicy::Gradual),
..AnalysisOptions::default()
},
)
.expect("brink test fixture should compile");
let (program, tables) = brink_runtime::link(&output.data).expect("test fixture should link");
let initial_context = fresh_context(&program);
(program, tables, initial_context)
}
pub fn make_test_app() -> App {
let mut app = App::new();
app.add_plugins(AssetPlugin::default());
app.add_plugins(crate::BrinkPlugin::<()>::default());
app
}
pub fn add_story_assets(
app: &mut App,
program: Program,
tables: Vec<Vec<brink_format::LineEntry>>,
initial_context: World,
) -> Handle<BrinkStoryAsset> {
let world = app.world_mut();
let program_handle = world
.resource_mut::<Assets<ProgramAsset>>()
.add(ProgramAsset {
program,
initial_context,
effect_rows: Vec::new(),
});
let tables_handle = world
.resource_mut::<Assets<LineTablesAsset>>()
.add(LineTablesAsset { tables });
world
.resource_mut::<Assets<BrinkStoryAsset>>()
.add(BrinkStoryAsset {
program: program_handle,
line_tables: tables_handle,
})
}
mod runtime_story_api {
use super::compile_test_story;
use brink_format::Value;
use brink_runtime::{ExternalFnHandler, ExternalResult, FastRng, Step, Story};
#[expect(
clippy::needless_pass_by_value,
reason = "test helper takes ownership of the produced lines"
)]
fn render(lines: Vec<Step>) -> String {
lines.iter().map(Step::text).collect()
}
#[test]
fn variable_get_set_by_name() {
let (program, tables, _ctx) = compile_test_story("VAR mood = 1\nMood {mood}.\n-> END\n");
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
assert_eq!(story.variable("mood"), Some(&Value::Int(1)));
assert!(story.set_variable("mood", Value::Int(5)));
assert_eq!(story.variable("mood"), Some(&Value::Int(5)));
assert!(!story.set_variable("nope", Value::Int(0)));
assert_eq!(story.variable("nope"), None);
}
#[test]
fn set_variable_reflected_in_output() {
let (program, tables, _ctx) = compile_test_story("VAR mood = 1\nMood {mood}.\n-> END\n");
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
story.set_variable("mood", Value::Int(7));
let text = render(story.continue_maximally().expect("continues"));
assert!(text.contains("Mood 7."), "got {text:?}");
}
#[test]
fn rng_seed_is_deterministic() {
let src = "{RANDOM(1, 1000)}\n-> END\n";
let run = |seed: i32| {
let (program, tables, _ctx) = compile_test_story(src);
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
story.set_rng_seed(seed);
render(story.continue_maximally().expect("continues"))
};
assert_eq!(run(42), run(42), "same seed -> identical RANDOM output");
}
#[test]
fn external_binding_resolves_via_continue_with() {
struct Doubler;
impl ExternalFnHandler for Doubler {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
if name == "double" {
let n = args.first().and_then(Value::as_int).unwrap_or(0);
ExternalResult::Resolved(Value::Int(n * 2))
} else {
ExternalResult::Fallback
}
}
}
let (program, tables, _ctx) =
compile_test_story("EXTERNAL double(x)\nResult: {double(21)}.\n-> END\n");
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let text = render(story.continue_maximally_with(&Doubler).expect("continues"));
assert!(text.contains("Result: 42"), "got {text:?}");
}
#[test]
fn save_load_round_trips_globals() {
let src = "VAR mood = 1\nVAR who = \"a\"\nMood {mood} {who}.\n-> END\n";
let (program, tables, _ctx) = compile_test_story(src);
let program = std::sync::Arc::new(program);
let mut s1 = Story::<FastRng>::new(std::sync::Arc::clone(&program), tables.clone());
s1.set_variable("mood", Value::Int(9));
s1.set_variable("who", Value::from("bob"));
let save = s1.save_state();
let mut s2 = Story::<FastRng>::new(std::sync::Arc::clone(&program), tables);
let report = s2.load_state(&save);
assert!(report.is_clean(), "clean load: {report:?}");
assert_eq!(s2.variable("mood"), Some(&Value::Int(9)));
assert_eq!(s2.variable("who").and_then(Value::as_str), Some("bob"));
}
#[test]
fn load_reports_globals_the_program_lacks() {
let (pa, ta, _) = compile_test_story("VAR foo = 1\n-> END\n");
let mut sa = Story::<FastRng>::new(std::sync::Arc::new(pa), ta);
sa.set_variable("foo", Value::Int(5));
let save = sa.save_state();
let (pb, tb, _) = compile_test_story("VAR bar = 2\n-> END\n");
let mut sb = Story::<FastRng>::new(std::sync::Arc::new(pb), tb);
let report = sb.load_state(&save);
assert_eq!(report.unknown_globals, vec!["foo".to_string()]);
assert!(!report.is_clean());
}
#[test]
fn visit_counts_survive_round_trip() {
let src = "-> start\n=== start ===\nVisits: {start}.\n-> END\n";
let (program, tables, _) = compile_test_story(src);
let program = std::sync::Arc::new(program);
let mut s1 = Story::<FastRng>::new(std::sync::Arc::clone(&program), tables.clone());
let _ = s1.continue_maximally().expect("continues");
let save = s1.save_state();
assert!(!save.visits.is_empty(), "a visit count should be recorded");
let mut s2 = Story::<FastRng>::new(std::sync::Arc::clone(&program), tables);
s2.load_state(&save);
assert_eq!(
s2.save_state().visits,
save.visits,
"visit counts round-trip"
);
}
#[test]
fn call_function_returns_value() {
use brink_runtime::FallbackHandler;
let src = "-> END\n=== function add(a, b) ===\n~ return a + b\n";
let (program, tables, _) = compile_test_story(src);
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let v = story
.call_function("add", &[Value::Int(2), Value::Int(3)], &FallbackHandler)
.expect("calls");
assert_eq!(v, Value::Int(5));
}
#[test]
fn call_function_resolves_external_via_handler() {
struct Doubler;
impl ExternalFnHandler for Doubler {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
if name == "dbl" {
let n = args.first().and_then(Value::as_int).unwrap_or(0);
ExternalResult::Resolved(Value::Int(n * 2))
} else {
ExternalResult::Fallback
}
}
}
let src = "EXTERNAL dbl(x)\n-> END\n=== function scaled(n) ===\n~ return dbl(n) + 1\n";
let (program, tables, _) = compile_test_story(src);
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let v = story
.call_function("scaled", &[Value::Int(10)], &Doubler)
.expect("calls");
assert_eq!(v, Value::Int(21), "dbl(10) + 1");
}
#[test]
fn call_function_unknown_errors() {
use brink_runtime::{FallbackHandler, RuntimeError};
let (program, tables, _) = compile_test_story("-> END\n");
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let err = story
.call_function("nope", &[], &FallbackHandler)
.unwrap_err();
assert!(
matches!(err, RuntimeError::FunctionNotFound(_)),
"got {err:?}"
);
}
#[test]
fn advance_with_surfaces_and_resumes_pending_external() {
use brink_runtime::StepOutcome;
struct Pauser;
impl ExternalFnHandler for Pauser {
fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
if name == "wait" {
ExternalResult::Pending
} else {
ExternalResult::Fallback
}
}
}
let (program, tables, _) = compile_test_story("EXTERNAL wait(x)\nGot {wait(5)}.\n-> END\n");
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let mut text = String::new();
let mut parked = false;
for _ in 0..50 {
match story.advance_with(&Pauser).expect("advance") {
StepOutcome::Step(step) => {
let terminal = step.is_terminal();
text.push_str(step.text());
if terminal {
break;
}
}
StepOutcome::AwaitingExternal => {
assert_eq!(story.pending_external_name(), Some("wait"));
assert_eq!(story.pending_external_args().to_vec(), vec![Value::Int(5)]);
story.resolve_external(Value::Int(7));
parked = true;
}
}
}
assert!(parked, "flow should have parked on the external");
assert!(
text.contains("Got 7."),
"resolved value appears; got {text:?}"
);
}
#[test]
fn choose_path_string_with_args_binds_params() {
let src = "-> END\n=== call(action, present) ===\nYou {action} the {present}.\n-> END\n";
let (program, tables, _ctx) = compile_test_story(src);
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
story
.choose_path_string_with_args("call", &[Value::from("open"), Value::from("box")])
.expect("enters the parameterized knot");
let text = render(story.continue_maximally().expect("continues"));
assert!(
text.contains("You open the box."),
"params bound into the knot body; got {text:?}"
);
}
#[test]
fn choose_path_string_arity_mismatch_errors() {
use brink_runtime::RuntimeError;
let src = "-> END\n=== call(action, present) ===\nYou {action} the {present}.\n-> END\n";
let (program, tables, _ctx) = compile_test_story(src);
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let too_few = story
.choose_path_string_with_args("call", &[Value::from("open")])
.unwrap_err();
assert!(
matches!(
too_few,
RuntimeError::ArgCountMismatch {
expected: 2,
got: 1,
..
}
),
"got {too_few:?}"
);
let none = story.choose_path_string("call").unwrap_err();
assert!(
matches!(
none,
RuntimeError::ArgCountMismatch {
expected: 2,
got: 0,
..
}
),
"got {none:?}"
);
}
#[test]
fn call_function_arity_mismatch_errors() {
use brink_runtime::{FallbackHandler, RuntimeError};
let src = "-> END\n=== function add(a, b) ===\n~ return a + b\n";
let (program, tables, _) = compile_test_story(src);
let mut story = Story::<FastRng>::new(std::sync::Arc::new(program), tables);
let err = story
.call_function("add", &[Value::Int(1)], &FallbackHandler)
.unwrap_err();
assert!(
matches!(
err,
RuntimeError::ArgCountMismatch {
expected: 2,
got: 1,
..
}
),
"got {err:?}"
);
}
}