#![expect(clippy::unwrap_used, reason = "test harness")]
use brink_format::{
ContainerDef, CountingFlags, DefinitionId, DefinitionTag, LineContent, LineEntry, NameId,
Opcode, ScopeLineTable, StoryData,
};
use brink_runtime::{DotNetRng, Step, Story};
fn def_id(tag: DefinitionTag, n: u64) -> DefinitionId {
DefinitionId::new(tag, n)
}
fn assemble(ops: &[Opcode]) -> Vec<u8> {
let mut buf = Vec::new();
for op in ops {
op.encode(&mut buf);
}
buf
}
fn story_with(root_ops: &[Opcode], alts: &[(DefinitionId, i32)]) -> StoryData {
let root_id = def_id(DefinitionTag::Address, 1);
let mut containers = vec![ContainerDef {
id: root_id,
scope_id: root_id,
name: Some(NameId(0)),
bytecode: assemble(root_ops),
counting_flags: CountingFlags::empty(),
path_hash: 0,
param_count: 0,
params: vec![],
local: false,
}];
for (id, path_hash) in alts {
containers.push(ContainerDef {
id: *id,
scope_id: root_id,
name: None,
bytecode: Vec::new(),
counting_flags: CountingFlags::VISITS,
path_hash: *path_hash,
param_count: 0,
params: vec![],
local: false,
});
}
StoryData {
containers,
line_tables: vec![ScopeLineTable {
scope_id: root_id,
lines: vec![LineEntry {
content: LineContent::Plain(String::new()),
flags: brink_format::LineFlags::from_plain(""),
source_hash: 0,
audio_ref: None,
slot_info: Vec::new(),
source_location: None,
}],
}],
variables: vec![],
list_defs: vec![],
list_items: vec![],
externals: vec![],
addresses: vec![],
address_paths: vec![],
name_table: vec!["root".to_string()],
list_literals: vec![],
literal_pool: vec![],
struct_shapes: vec![],
private_defs: vec![],
alias_table: vec![],
effect_rows: vec![],
frame_shapes: Vec::new(),
debug_info: None,
line_variant_groups: Vec::new(),
source_checksum: 0,
}
}
fn run(data: &StoryData) -> String {
let (program, line_tables) = brink_runtime::link(data).unwrap();
let mut story = Story::<DotNetRng>::new(std::sync::Arc::new(program), line_tables);
let mut out = String::new();
for _ in 0..64 {
match story.continue_single().unwrap() {
Step::Line(line) => out.push_str(&line.text),
Step::Done | Step::End | Step::Suspended => return out,
Step::Choices(_) => unreachable!("no choices in these fixtures"),
}
}
unreachable!("fixture did not terminate");
}
fn emit_target_touch(alt: DefinitionId) -> [Opcode; 4] {
[
Opcode::PushDivertTarget(alt),
Opcode::TouchVisit,
Opcode::EmitValue,
Opcode::EmitNewline,
]
}
#[test]
fn touch_visit_returns_pre_increment_and_advances() {
let alt = def_id(DefinitionTag::Address, 100);
let mut ops = Vec::new();
for _ in 0..3 {
ops.extend(emit_target_touch(alt));
}
ops.push(Opcode::Done);
let out = run(&story_with(&ops, &[(alt, 7)]));
assert_eq!(out, "0\n1\n2\n", "three touches = view indices 0, 1, 2");
}
#[test]
fn touch_visit_state_is_per_container() {
let a = def_id(DefinitionTag::Address, 100);
let b = def_id(DefinitionTag::Address, 101);
let mut ops = Vec::new();
ops.extend(emit_target_touch(a));
ops.extend(emit_target_touch(b));
ops.extend(emit_target_touch(a));
ops.push(Opcode::Done);
let out = run(&story_with(&ops, &[(a, 1), (b, 2)]));
assert_eq!(out, "0\n0\n1\n", "a: 0 then 1; b: 0 — independent counters");
}
#[test]
fn touch_visit_tolerates_a_non_target_operand() {
let alt = def_id(DefinitionTag::Address, 100);
let ops = [
Opcode::PushInt(42),
Opcode::TouchVisit,
Opcode::EmitValue,
Opcode::EmitNewline,
Opcode::PushDivertTarget(alt),
Opcode::TouchVisit,
Opcode::EmitValue,
Opcode::EmitNewline,
Opcode::Done,
];
let out = run(&story_with(&ops, &[(alt, 0)]));
assert_eq!(out, "0\n0\n");
}
fn emit_shuffle_index(alt: DefinitionId, seq_count: i32, num_elements: i32) -> [Opcode; 6] {
[
Opcode::PushInt(seq_count),
Opcode::PushInt(num_elements),
Opcode::PushDivertTarget(alt),
Opcode::ShuffleIndexOf,
Opcode::EmitValue,
Opcode::EmitNewline,
]
}
#[test]
fn shuffle_index_of_yields_a_permutation() {
const N: i32 = 4;
let alt = def_id(DefinitionTag::Address, 100);
let mut ops = Vec::new();
for seq_count in 0..N {
ops.extend(emit_shuffle_index(alt, seq_count, N));
}
ops.push(Opcode::Done);
let out = run(&story_with(&ops, &[(alt, 1234)]));
let mut seen: Vec<i32> = out.lines().map(|l| l.parse::<i32>().unwrap()).collect();
seen.sort_unstable();
assert_eq!(seen, vec![0, 1, 2, 3], "one loop visits each branch once");
}
#[test]
fn shuffle_index_of_is_deterministic() {
let alt = def_id(DefinitionTag::Address, 100);
let mut ops = Vec::new();
for seq_count in 0..4 {
ops.extend(emit_shuffle_index(alt, seq_count, 4));
}
ops.push(Opcode::Done);
let data = story_with(&ops, &[(alt, 1234)]);
assert_eq!(run(&data), run(&data));
}
#[test]
fn shuffle_index_of_seeds_by_the_named_containers_hash() {
const N: i32 = 8;
let a = def_id(DefinitionTag::Address, 100);
let b = def_id(DefinitionTag::Address, 101);
let c = def_id(DefinitionTag::Address, 102);
let mut ops = Vec::new();
for alt in [a, b, c] {
for seq_count in 0..N {
ops.extend(emit_shuffle_index(alt, seq_count, N));
}
}
ops.push(Opcode::Done);
let out = run(&story_with(&ops, &[(a, 111), (b, 222), (c, 111)]));
let nums: Vec<i32> = out.lines().map(|l| l.parse().unwrap()).collect();
let (pa, rest) = nums.split_at(N as usize);
let (pb, pc) = rest.split_at(N as usize);
assert_eq!(pa, pc, "equal path_hash => identical permutation");
assert_ne!(
pa, pb,
"distinct path_hash must decorrelate the permutations (with 8 \
elements, identical draws would be a 1-in-40320 seed collision — \
if this ever flakes, the seeding is broken, not the test)"
);
}
#[test]
fn shuffle_index_of_tolerates_an_unknown_target() {
let ghost = def_id(DefinitionTag::Address, 999);
let mut ops = Vec::new();
ops.extend(emit_shuffle_index(ghost, 0, 3));
ops.push(Opcode::Done);
let out = run(&story_with(&ops, &[]));
let n: i32 = out.trim().parse().unwrap();
assert!((0..3).contains(&n), "index still in range, got {n}");
}