#![expect(
clippy::unwrap_used,
clippy::panic,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::default_trait_access,
clippy::items_after_statements,
reason = "test harness"
)]
use std::path::Path;
use brink_converter::convert;
use brink_format::{ListValue, SaveState, Value};
use brink_json::InkJson;
use brink_runtime::{
DotNetRng, EventKind, ExternalFnHandler, ExternalReplayMode, ExternalResult, FailReason, Line,
Program, ReplayOutcome, ReplayWarning, SESSION_JOURNAL_CAP, SessionError, SessionJournal,
StepOutcome, Story, StorySession, diff,
};
fn link_fixture(rel: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>) {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(rel);
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let ink: InkJson = serde_json::from_str(&json).unwrap();
let data = convert(&ink).unwrap();
brink_runtime::link(&data).unwrap()
}
const CHOICE_STORY: &str = "tests/tier1/choices/I084-sticky-choices-stay-sticky/story.ink.json";
const LIST_STORY: &str = "tests/tier2/lists/I067-list-save-load/story.ink.json";
const EXTERNAL_STORY: &str = "tests/tier3/runtime/external-function-1-arg-v1/story.ink.json";
const FUNCTION_STORY: &str = "tests/tier2/function/func-none/story.ink.json";
fn run_to_pause(session: &mut StorySession<'_, DotNetRng>) -> Vec<Line> {
session.continue_to_pause().unwrap()
}
#[test]
fn journal_roundtrips_with_tagged_list_value() {
let mut journal = SessionJournal::new(0xDEAD_BEEF, Some(42));
let list = Value::List(
ListValue {
items: vec![brink_format::DefinitionId::new(
brink_format::DefinitionTag::ListItem,
7,
)],
origins: vec![],
}
.into(),
);
journal
.events
.push(brink_runtime::JournalEvent::new(EventKind::External {
name: "get_flag".to_owned(),
args: vec![Value::Int(3)],
result: list.clone(),
}));
journal
.events
.push(brink_runtime::JournalEvent::new(EventKind::Choice {
index: 1,
label: Some("Choose me".to_owned()),
}));
let json = serde_json::to_string(&journal).unwrap();
assert!(
json.contains("List"),
"list value must serialize tagged: {json}"
);
let back: SessionJournal = serde_json::from_str(&json).unwrap();
assert_eq!(back, journal);
if let EventKind::External { result, .. } = &back.events[0].kind {
assert_eq!(result, &list);
} else {
panic!("expected External event");
}
}
#[test]
fn replay_outcome_and_fail_reason_are_json_serializable() {
let replayed = ReplayOutcome::Replayed {
warnings: vec![ReplayWarning::ChoiceLabelDrift {
at_event: 0,
index: 1,
recorded: "old".to_owned(),
found: "new".to_owned(),
}],
};
let json = serde_json::to_string(&replayed).unwrap();
assert!(json.contains("\"type\":\"replayed\""), "{json}");
assert!(json.contains("\"type\":\"choice_label_drift\""), "{json}");
let budget = ReplayOutcome::Failed {
at_event: 3,
reason: FailReason::Budget,
};
let json = serde_json::to_string(&budget).unwrap();
assert!(json.contains("\"type\":\"failed\""), "{json}");
assert!(json.contains("\"type\":\"budget\""), "{json}");
let runtime_err = ReplayOutcome::Failed {
at_event: 5,
reason: FailReason::RuntimeError {
message: "boom".to_owned(),
},
};
let json = serde_json::to_string(&runtime_err).unwrap();
assert!(json.contains("\"type\":\"runtime_error\""), "{json}");
assert!(json.contains("\"message\":\"boom\""), "{json}");
let back: ReplayOutcome = serde_json::from_str(&json).unwrap();
assert_eq!(back, runtime_err);
let awaiting = ReplayOutcome::Failed {
at_event: 2,
reason: FailReason::AwaitingExternal {
name: "ask".to_owned(),
},
};
let json = serde_json::to_string(&awaiting).unwrap();
assert!(json.contains("\"type\":\"awaiting_external\""), "{json}");
assert!(json.contains("\"name\":\"ask\""), "{json}");
}
#[test]
fn record_then_replay_identical_run() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let _ = run_to_pause(&mut session);
session.choose(0).unwrap();
let _ = run_to_pause(&mut session);
session.choose(2).unwrap(); let _ = run_to_pause(&mut session); let after_record = session.snapshot();
let journal = session.journal().clone();
let (replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&journal,
ExternalReplayMode::Recorded,
None,
);
assert!(
matches!(outcome, ReplayOutcome::Replayed { ref warnings } if warnings.is_empty()),
"expected clean replay, got {outcome:?}",
);
assert!(diff(&after_record, &replayed.snapshot()).is_empty());
}
#[test]
fn divergence_choice_out_of_range_truncates_and_parks() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let _ = run_to_pause(&mut session);
session.choose(1).unwrap();
let mut journal = session.journal().clone();
for ev in &mut journal.events {
if let EventKind::Choice { index, .. } = &mut ev.kind {
*index = 99;
}
}
let (replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&journal,
ExternalReplayMode::Recorded,
None,
);
match outcome {
ReplayOutcome::Diverged {
at_event, found, ..
} => {
assert!(matches!(
found,
brink_runtime::DivergenceFound::ChoiceIndexOutOfRange { index: 99, .. }
));
assert!(replayed.journal().truncated);
assert!(replayed.journal().len() <= at_event + 1);
}
other => panic!("expected Diverged, got {other:?}"),
}
}
struct CountingExternal {
value: i32,
calls: std::cell::Cell<u32>,
}
impl ExternalFnHandler for CountingExternal {
fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
if name == "externalFunction" {
self.calls.set(self.calls.get() + 1);
ExternalResult::Resolved(Value::Int(self.value))
} else {
ExternalResult::Fallback
}
}
}
#[test]
fn recorded_replay_does_not_reinvoke_live_does() {
let (program, tables) = link_fixture(EXTERNAL_STORY);
let record_handler = CountingExternal {
value: 77,
calls: std::cell::Cell::new(0),
};
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let mut steps = 0;
loop {
steps += 1;
assert!(steps < 1000, "step budget");
match session.advance_with(&record_handler).unwrap() {
StepOutcome::Line(l) if l.is_terminal() => break,
StepOutcome::Line(_) => {}
StepOutcome::AwaitingExternal => panic!("handler resolves inline"),
}
}
assert_eq!(
record_handler.calls.get(),
1,
"recorded exactly one external"
);
let journal = session.journal().clone();
assert!(journal.events.iter().any(
|e| matches!(&e.kind, EventKind::External { name, result, .. }
if name == "externalFunction" && *result == Value::Int(77))
));
let replay_handler = CountingExternal {
value: 999,
calls: std::cell::Cell::new(0),
};
let (_replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables.clone()),
&journal,
ExternalReplayMode::Recorded,
Some(&replay_handler),
);
assert!(
matches!(outcome, ReplayOutcome::Replayed { .. }),
"{outcome:?}"
);
assert_eq!(
replay_handler.calls.get(),
0,
"recorded mode must not re-invoke externals",
);
let live_handler = CountingExternal {
value: 123,
calls: std::cell::Cell::new(0),
};
let (_replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&journal,
ExternalReplayMode::Live,
Some(&live_handler),
);
assert!(
matches!(outcome, ReplayOutcome::Replayed { .. }),
"{outcome:?}"
);
assert_eq!(
live_handler.calls.get(),
1,
"live mode re-invokes externals"
);
}
#[test]
fn label_drift_is_a_soft_warning_on_replayed() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let _ = run_to_pause(&mut session);
session.choose(0).unwrap();
let mut journal = session.journal().clone();
for ev in &mut journal.events {
if let EventKind::Choice { label, .. } = &mut ev.kind {
*label = Some("STALE LABEL".to_owned());
}
}
let (_replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&journal,
ExternalReplayMode::Recorded,
None,
);
match outcome {
ReplayOutcome::Replayed { warnings } => {
assert!(
warnings.iter().any(|w| matches!(
w,
ReplayWarning::ChoiceLabelDrift { recorded, .. } if recorded == "STALE LABEL"
)),
"expected label-drift warning, got {warnings:?}",
);
}
other => panic!("label drift must be a soft warning on Replayed, got {other:?}"),
}
}
#[test]
fn call_function_is_journaled_but_externals_are_isolated() {
let (program, tables) = link_fixture(FUNCTION_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables), None);
struct Trap;
impl ExternalFnHandler for Trap {
fn call(&self, _name: &str, _args: &[Value]) -> ExternalResult {
ExternalResult::Fallback
}
}
let ret = session.call_function("f", &[], &Trap).unwrap();
assert_eq!(ret, Value::Float(3.8));
let calls = session
.journal()
.events
.iter()
.filter(|e| matches!(&e.kind, EventKind::Call { name, .. } if name == "f"))
.count();
assert_eq!(calls, 1, "call_function journals a Call event");
let externals = session
.journal()
.events
.iter()
.filter(|e| matches!(&e.kind, EventKind::External { .. }))
.count();
assert_eq!(externals, 0, "call_function externals must not journal");
}
#[test]
fn checkpoint_fast_restore_skips_replay() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let _ = run_to_pause(&mut session);
session.choose(0).unwrap();
let _ = run_to_pause(&mut session);
let journal = session.export_journal();
assert!(journal.checkpoint.is_some(), "export embeds a checkpoint");
assert_eq!(journal.program_checksum, program.source_checksum());
let (restored, outcome) =
StorySession::<DotNetRng>::restore(Story::new(&program, tables), journal).unwrap();
assert!(
matches!(outcome, ReplayOutcome::Replayed { ref warnings } if warnings.is_empty()),
"fast-restore returns clean Replayed, got {outcome:?}",
);
let snap = restored.snapshot();
assert!(snap.turn_index >= 1);
}
#[test]
fn restore_without_checkpoint_on_mismatch_errors() {
let (program, tables) = link_fixture(CHOICE_STORY);
let journal = SessionJournal::new(program.source_checksum().wrapping_add(1), None);
match StorySession::<DotNetRng>::restore(Story::new(&program, tables), journal) {
Err(SessionError::ChecksumMismatch { .. }) => {}
Err(other) => panic!("expected ChecksumMismatch, got {other:?}"),
Ok(_) => panic!("expected ChecksumMismatch error"),
}
}
#[test]
fn journal_push_honors_cap() {
let (program, tables) = link_fixture(EXTERNAL_STORY);
let mut src = SessionJournal::new(program.source_checksum(), None);
src.events
.push(brink_runtime::JournalEvent::new(EventKind::Start {
path: None,
args: vec![],
}));
for i in 0..SESSION_JOURNAL_CAP + 5 {
src.events
.push(brink_runtime::JournalEvent::new(EventKind::SetVar {
name: "x".to_owned(),
value: Value::Int(i as i32),
}));
}
let (replayed, _outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&src,
ExternalReplayMode::Recorded,
None,
);
assert!(replayed.journal().truncated, "cap must set truncated");
assert!(replayed.journal().len() <= SESSION_JOURNAL_CAP);
}
#[test]
fn mutation_mid_turn_is_rejected() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables), None);
match session.advance().unwrap() {
StepOutcome::Line(l) => assert!(!l.is_terminal(), "expected non-terminal first line"),
StepOutcome::AwaitingExternal => panic!("no external here"),
}
let err = session.set_var("whatever", Value::Int(1)).unwrap_err();
assert!(
matches!(err, SessionError::MutationMidTurn { op: "set_var" }),
"{err:?}"
);
let err = session.go_to_path("test", &[]).unwrap_err();
assert!(
matches!(err, SessionError::MutationMidTurn { op: "go_to_path" }),
"{err:?}"
);
let save = SaveState {
version: brink_format::SAVE_FORMAT_VERSION,
globals: Default::default(),
visits: vec![],
turns: vec![],
turn_index: 0,
rng_seed: 0,
previous_random: 0,
};
let err = session.load_state(&save).unwrap_err();
assert!(
matches!(err, SessionError::MutationMidTurn { op: "load_state" }),
"{err:?}"
);
}
#[test]
fn mutation_at_boundary_is_allowed_and_journaled() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables), None);
let _ = run_to_pause(&mut session);
session.go_to_path("test", &[]).unwrap();
assert!(
session
.journal()
.events
.iter()
.any(|e| matches!(&e.kind, EventKind::GoToPath { path, .. } if path == "test"))
);
}
#[test]
fn snapshot_and_diff_track_list_membership_and_turns() {
let (program, tables) = link_fixture(LIST_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables), None);
let before = session.snapshot();
let lines = run_to_pause(&mut session);
assert!(!lines.is_empty());
let after = session.snapshot();
assert!(
after.globals.contains_key("t"),
"t global present: {:?}",
after.globals.keys().collect::<Vec<_>>()
);
let d = diff(&before, &after);
assert!(
!d.is_empty(),
"snapshot diff should be non-empty after running"
);
if let Some(delta) = d.list_deltas.get("t") {
assert!(
!delta.added.is_empty(),
"expected list items added to t: {delta:?}"
);
}
if let Some(list) = after.lists.get("t") {
assert!(!list.items.is_empty(), "t has active list members");
let mut sorted = list.items.clone();
sorted.sort();
assert_eq!(list.items, sorted);
}
}
#[test]
fn diff_of_identical_snapshots_is_empty() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables), None);
let _ = run_to_pause(&mut session);
let a = session.snapshot();
let b = session.snapshot();
assert!(diff(&a, &b).is_empty());
}
#[test]
fn escape_hatch_exposes_wrapped_story() {
let (program, tables) = link_fixture(CHOICE_STORY);
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables), None);
let before = session.journal().len();
let _pending = session.story().has_pending_external();
let _ = session.story_mut().stats();
assert_eq!(
session.journal().len(),
before,
"escape-hatch reads never journal"
);
}
struct DeferOnce {
deferred: std::cell::Cell<bool>,
}
impl ExternalFnHandler for DeferOnce {
fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
if name == "externalFunction" && !self.deferred.get() {
self.deferred.set(true);
ExternalResult::Pending
} else {
ExternalResult::Resolved(Value::Int(0))
}
}
}
#[test]
fn live_replay_parks_on_pending_external() {
let (program, tables) = link_fixture(EXTERNAL_STORY);
let rec = CountingExternal {
value: 5,
calls: std::cell::Cell::new(0),
};
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let mut steps = 0;
loop {
steps += 1;
assert!(steps < 1000);
match session.advance_with(&rec).unwrap() {
StepOutcome::Line(l) if l.is_terminal() => break,
StepOutcome::Line(_) => {}
StepOutcome::AwaitingExternal => panic!(),
}
}
let journal = session.journal().clone();
let defer = DeferOnce {
deferred: std::cell::Cell::new(false),
};
let (mut replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&journal,
ExternalReplayMode::Live,
Some(&defer),
);
assert!(
matches!(
outcome,
ReplayOutcome::Failed {
reason: FailReason::AwaitingExternal { .. },
..
}
),
"live replay must park on a deferred external, got {outcome:?}",
);
assert!(
replayed.has_pending_replay(),
"park retains the replay tail"
);
assert!(replayed.has_pending_external());
replayed.resolve_external(Value::Int(5));
let outcome = replayed.continue_replay(Some(&defer));
assert!(
matches!(outcome, ReplayOutcome::Replayed { .. }),
"resumed replay completes, got {outcome:?}",
);
assert!(!replayed.has_pending_replay());
}
fn compile_source(source: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>) {
let out = brink_compiler::compile("main.ink", |_| Ok(source.to_owned())).unwrap();
brink_runtime::link(&out.data).unwrap()
}
const BETWEEN_CHOICES_INK: &str = r"
EXTERNAL probe(x)
-> top
== top ==
First question.
* one
Value is {probe(1)}.
-> second
* two
-> second
== second ==
Second question.
* alpha
Done.
-> END
* beta
Also done.
-> END
=== function probe(x) ===
~ return 0
";
struct DeferProbe {
deferred: std::cell::Cell<bool>,
}
impl ExternalFnHandler for DeferProbe {
fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
if name == "probe" && !self.deferred.get() {
self.deferred.set(true);
ExternalResult::Pending
} else {
ExternalResult::Resolved(Value::Int(42))
}
}
}
#[test]
fn live_replay_resumes_tail_after_external_between_choices() {
let (program, tables) = compile_source(BETWEEN_CHOICES_INK);
let record = DeferProbe {
deferred: std::cell::Cell::new(true), };
let mut session = StorySession::<DotNetRng>::new(Story::new(&program, tables.clone()), None);
let lines = session.continue_to_pause().unwrap();
assert!(
matches!(lines.last(), Some(Line::Choices { .. })),
"expected first choice set, got {lines:?}",
);
session.choose(0).unwrap(); let mut steps = 0;
loop {
steps += 1;
assert!(steps < 1000, "step budget");
match session.advance_with(&record).unwrap() {
StepOutcome::Line(l) if l.is_terminal() => break,
StepOutcome::Line(_) => {}
StepOutcome::AwaitingExternal => panic!("recording handler resolves inline"),
}
}
session.choose(0).unwrap(); let _ = session.continue_to_pause().unwrap();
let journal = session.journal().clone();
let kinds: Vec<&EventKind> = journal.events.iter().map(|e| &e.kind).collect();
assert!(
matches!(
kinds.as_slice(),
[
EventKind::Start { .. },
EventKind::Choice { .. },
EventKind::External { .. },
EventKind::Choice { .. },
]
),
"unexpected journal shape: {kinds:?}",
);
let defer = DeferProbe {
deferred: std::cell::Cell::new(false),
};
let (mut replayed, outcome) = StorySession::<DotNetRng>::replay(
Story::new(&program, tables),
&journal,
ExternalReplayMode::Live,
Some(&defer),
);
assert!(
matches!(
outcome,
ReplayOutcome::Failed {
reason: FailReason::AwaitingExternal { .. },
..
}
),
"expected park on the deferred external, got {outcome:?}",
);
assert!(replayed.has_pending_replay(), "tail must be retained");
let choices_so_far = replayed
.journal()
.events
.iter()
.filter(|e| matches!(e.kind, EventKind::Choice { .. }))
.count();
assert_eq!(choices_so_far, 1);
replayed.resolve_external(Value::Int(42));
let outcome = replayed.continue_replay(Some(&defer));
assert!(
matches!(outcome, ReplayOutcome::Replayed { ref warnings } if warnings.is_empty()),
"resumed replay must complete cleanly, got {outcome:?}",
);
assert!(!replayed.has_pending_replay());
let final_choices = replayed
.journal()
.events
.iter()
.filter(|e| matches!(e.kind, EventKind::Choice { .. }))
.count();
assert_eq!(final_choices, 2, "both recorded choices replayed");
assert_eq!(
replayed.snapshot().status,
brink_runtime::SnapshotStatus::Ended
);
}