#![expect(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::Cell;
use std::sync::Arc;
use brink_format::Value;
use brink_runtime::{
Budget, ExternalFnHandler, ExternalResult, FallbackHandler, FunctionEval, Line, Program,
RuntimeError, SpeculationStep, Story,
};
const SOURCE: &str = "\
VAR gold = 0
-> start
=== start ===
Hello.
~ gold = gold + 5
~ temp r = RANDOM(1, 100)
More text.
-> END
=== shrine ===
At the shrine.
-> END
=== spin ===
-> spin
=== function add(x, y) ===
~ return x + y
";
fn compile_and_link(src: &str) -> (Arc<Program>, Vec<Vec<brink_format::LineEntry>>) {
let out = brink_compiler::compile("t.ink", |p| {
if p == "t.ink" {
Ok(src.to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no such include",
))
}
})
.expect("compile");
let (program, line_tables) = brink_runtime::link(&out.data).expect("link");
(Arc::new(program), line_tables)
}
fn drive_live(story: &mut Story<brink_runtime::DotNetRng>) -> Vec<(String, Vec<String>)> {
let mut lines = Vec::new();
loop {
let line = story.continue_single().unwrap();
let terminal = line.is_terminal();
lines.push((line.text().to_owned(), line.tags().to_vec()));
if terminal {
return lines;
}
}
}
fn drive_speculation(
spec: &mut brink_runtime::Speculation<brink_runtime::DotNetRng>,
) -> Vec<(String, Vec<String>)> {
let mut lines = Vec::new();
loop {
match spec.advance(Budget::default(), &FallbackHandler).unwrap() {
SpeculationStep::Line(line) => {
let terminal = line.is_terminal();
lines.push((line.text().to_owned(), line.tags().to_vec()));
if terminal {
return lines;
}
}
SpeculationStep::AwaitingExternal => panic!("no externals in this story"),
}
}
}
#[test]
fn speculation_mirrors_live_continuation() {
let (program, line_tables) = compile_and_link(SOURCE);
let mut story_a =
Story::<brink_runtime::DotNetRng>::new(Arc::clone(&program), line_tables.clone());
let first_a = story_a.continue_single().unwrap();
assert!(matches!(first_a, Line::Text { .. }));
let mut spec = story_a.speculate();
let spec_rest = drive_speculation(&mut spec);
let mut story_b = Story::<brink_runtime::DotNetRng>::new(Arc::clone(&program), line_tables);
let first_b = story_b.continue_single().unwrap();
assert_eq!(first_a.text(), first_b.text());
let live_rest = drive_live(&mut story_b);
assert_eq!(
spec_rest, live_rest,
"speculation must mirror the live continuation byte-for-byte"
);
}
#[test]
fn speculation_writes_never_reach_the_live_story() {
let (program, line_tables) = compile_and_link(SOURCE);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let gold_before = story.variable("gold").unwrap().clone();
let snapshot_before = story.debug_snapshot();
let mut spec = story.speculate();
let lines = drive_speculation(&mut spec);
assert!(lines.iter().any(|(text, _)| text.contains("Hello")));
let gold_after = story.variable("gold").unwrap().clone();
assert_eq!(gold_after, gold_before, "gold must be unchanged");
let snapshot_after = story.debug_snapshot();
assert_eq!(
snapshot_after.status, snapshot_before.status,
"the live story was never advanced — status must be untouched"
);
assert_eq!(snapshot_after.turn_index, snapshot_before.turn_index);
assert_eq!(snapshot_after.rng.seed, snapshot_before.rng.seed);
assert_eq!(snapshot_after.rng.previous, snapshot_before.rng.previous);
let visits_before: Vec<(String, u32)> = snapshot_before
.visit_counts
.iter()
.map(|v| (v.path.clone(), v.count))
.collect();
let visits_after: Vec<(String, u32)> = snapshot_after
.visit_counts
.iter()
.map(|v| (v.path.clone(), v.count))
.collect();
assert_eq!(
visits_before, visits_after,
"the speculation's `start` visit must not leak into the live story"
);
}
#[test]
fn speculation_budget_caps_a_runaway_step_loop() {
let (program, line_tables) = compile_and_link(SOURCE);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let mut spec = story.speculate();
spec.go_to_path("spin").unwrap();
let tiny = Budget {
steps: 64,
lines: 1_000,
};
let err = spec.advance(tiny, &FallbackHandler).unwrap_err();
assert!(
matches!(err, RuntimeError::StepLimitExceeded(64)),
"expected a step-limit error at the tiny budget, got: {err:?}"
);
}
#[test]
fn speculation_budget_caps_total_lines_produced() {
let (program, line_tables) = compile_and_link(SOURCE);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let mut spec = story.speculate();
let one_line = Budget {
steps: Budget::default().steps,
lines: 1,
};
match spec.advance(one_line, &FallbackHandler).unwrap() {
SpeculationStep::Line(line) => assert!(!line.is_terminal()),
SpeculationStep::AwaitingExternal => panic!("no externals in this story"),
}
let err = spec.advance(one_line, &FallbackHandler).unwrap_err();
assert!(
matches!(err, RuntimeError::LineLimitExceeded(1)),
"expected a line-limit error at the tiny budget, got: {err:?}"
);
}
#[test]
fn speculation_eval_function_returns_pure_value() {
let (program, line_tables) = compile_and_link(SOURCE);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let gold_before = story.variable("gold").unwrap().clone();
let mut spec = story.speculate();
let result = spec
.eval_function("add", &[Value::Int(3), Value::Int(4)], &FallbackHandler)
.unwrap();
match result {
FunctionEval::Returned(value) => assert_eq!(value, Value::Int(7)),
FunctionEval::AwaitingExternal => panic!("`add` has no externals"),
}
assert_eq!(*story.variable("gold").unwrap(), gold_before);
}
#[test]
fn speculation_go_to_path_jumps_and_runs() {
let (program, line_tables) = compile_and_link(SOURCE);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let mut spec = story.speculate();
spec.go_to_path("shrine").unwrap();
match spec.advance(Budget::default(), &FallbackHandler).unwrap() {
SpeculationStep::Line(line) => assert!(line.text().contains("At the shrine")),
SpeculationStep::AwaitingExternal => panic!("no externals in this story"),
}
let snapshot = story.debug_snapshot();
assert_eq!(snapshot.status, "active");
assert_eq!(snapshot.turn_index, 0);
}
const SOURCE_WITH_EXTERNAL: &str = "\
EXTERNAL get_value(x)
VAR gold = 0
-> start
=== start ===
Hello.
~ gold = gold + 5
-> END
=== function query(x) ===
~ return get_value(x)
";
struct DeferOnce {
deferred: Cell<bool>,
}
impl ExternalFnHandler for DeferOnce {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
if name == "get_value" && !self.deferred.get() {
self.deferred.set(true);
ExternalResult::Pending
} else {
let Some(Value::Int(x)) = args.first() else {
panic!("expected an int arg, got {args:?}");
};
ExternalResult::Resolved(Value::Int(x * 2))
}
}
}
#[test]
fn speculation_resume_function_eval_completes_after_deferred_external() {
let (program, line_tables) = compile_and_link(SOURCE_WITH_EXTERNAL);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let gold_before = story.variable("gold").unwrap().clone();
let snapshot_before = story.debug_snapshot();
let mut spec = story.speculate();
let handler = DeferOnce {
deferred: Cell::new(false),
};
let outcome = spec
.eval_function("query", &[Value::Int(21)], &handler)
.unwrap();
assert!(
matches!(outcome, FunctionEval::AwaitingExternal),
"expected the deferred external to pause the eval, got {outcome:?}"
);
spec.resolve_external(Value::Int(42));
let outcome = spec.resume_function_eval(&handler).unwrap();
match outcome {
FunctionEval::Returned(value) => assert_eq!(value, Value::Int(42)),
FunctionEval::AwaitingExternal => panic!("only one external call in `query`"),
}
assert_eq!(*story.variable("gold").unwrap(), gold_before);
let snapshot_after = story.debug_snapshot();
assert_eq!(snapshot_after.status, snapshot_before.status);
assert_eq!(snapshot_after.turn_index, snapshot_before.turn_index);
}
#[test]
fn speculation_resume_function_eval_errors_when_nothing_pending() {
let (program, line_tables) = compile_and_link(SOURCE_WITH_EXTERNAL);
let story = Story::<brink_runtime::DotNetRng>::new(program, line_tables);
let mut spec = story.speculate();
let err = spec.resume_function_eval(&FallbackHandler).unwrap_err();
assert!(
matches!(err, RuntimeError::NotEvaluatingFunction),
"expected NotEvaluatingFunction, got {err:?}"
);
}