#![allow(
clippy::unwrap_used,
clippy::panic,
clippy::print_stderr,
clippy::wildcard_enum_match_arm,
clippy::match_same_arms
)]
use std::collections::HashMap;
use std::path::Path;
use brink_runtime::{DotNetRng, Line, Story};
fn compile_mem(
entry: &str,
files: &HashMap<&str, &str>,
) -> Result<brink_format::StoryData, brink_compiler::CompileError> {
brink_compiler::compile(entry, |path| {
files.get(path).map(|s| (*s).to_string()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("file not found: {path}"),
)
})
})
.map(|output| output.data)
}
#[test]
fn compile_minimal_story() {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", "Hello, world!\n")]);
let story = compile_mem("main.ink", &files).unwrap();
assert!(
!story.containers.is_empty(),
"expected non-empty containers"
);
}
#[test]
fn compile_story_with_knots() {
let files: HashMap<&str, &str> = HashMap::from([(
"main.ink",
"\
Hello!
-> greet
== greet ==
Welcome to the story.
-> END
",
)]);
let story = compile_mem("main.ink", &files).unwrap();
assert!(
!story.containers.is_empty(),
"expected non-empty containers"
);
}
#[test]
fn compile_follows_includes() {
let files: HashMap<&str, &str> = HashMap::from([
("main.ink", "INCLUDE helpers.ink\nHello!\n-> greet\n"),
("helpers.ink", "== greet ==\nWelcome.\n-> END\n"),
]);
let story = compile_mem("main.ink", &files).unwrap();
assert!(
!story.containers.is_empty(),
"expected non-empty containers"
);
}
#[test]
fn compile_nested_includes() {
let files: HashMap<&str, &str> = HashMap::from([
("main.ink", "INCLUDE a.ink\nMain content.\n"),
("a.ink", "INCLUDE b.ink\n"),
("b.ink", "VAR x = 5\n== knot_b ==\nHello from b.\n-> END\n"),
]);
let story = compile_mem("main.ink", &files).unwrap();
assert!(
!story.containers.is_empty(),
"expected non-empty containers"
);
}
#[test]
fn compile_circular_includes_detected() {
let files: HashMap<&str, &str> = HashMap::from([
("a.ink", "INCLUDE b.ink\nContent A.\n"),
("b.ink", "INCLUDE a.ink\nContent B.\n"),
]);
let err = compile_mem("a.ink", &files).unwrap_err();
assert!(
matches!(err, brink_compiler::CompileError::CircularInclude(_)),
"expected CircularInclude variant, got: {err}"
);
}
#[test]
fn compile_resolves_relative_include_paths() {
let files: HashMap<&str, &str> = HashMap::from([
("src/main.ink", "INCLUDE utils/helpers.ink\nHello!\n"),
("src/utils/helpers.ink", "== greet ==\nHi.\n-> END\n"),
]);
let story = compile_mem("src/main.ink", &files).unwrap();
assert!(
!story.containers.is_empty(),
"expected non-empty containers"
);
}
#[test]
fn compile_missing_entry_file() {
let files: HashMap<&str, &str> = HashMap::new();
let err = compile_mem("nonexistent.ink", &files).unwrap_err();
assert!(
matches!(err, brink_compiler::CompileError::Io(_)),
"expected I/O error for missing entry file, got: {err}"
);
}
#[test]
fn compile_missing_included_file() {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", "INCLUDE missing.ink\nHello!\n")]);
let err = compile_mem("main.ink", &files).unwrap_err();
assert!(
matches!(err, brink_compiler::CompileError::Io(_)),
"expected I/O error for missing included file, got: {err}"
);
}
#[test]
fn compile_path_reads_from_disk() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/tier1/basics/I001-minimal-story/story.ink");
let story = brink_compiler::compile_path(&path).unwrap();
assert!(
!story.data.containers.is_empty(),
"expected non-empty containers"
);
}
#[test]
fn compile_path_nested_includes_from_disk() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/tier3/misc/I025-nested-includes/story.ink");
let story = brink_compiler::compile_path(&path).unwrap();
assert!(
!story.data.containers.is_empty(),
"expected non-empty containers"
);
}
fn compile_and_run(source: &str, inputs: &[usize]) -> String {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let (program, line_tables) = brink_runtime::link(&data).unwrap();
let mut story = Story::<DotNetRng>::new(std::sync::Arc::new(program), line_tables);
let mut output = String::new();
let mut input_idx = 0;
loop {
let lines = story.continue_maximally().unwrap();
let last = lines.last().unwrap();
match last {
Line::Text { .. } | Line::Done { .. } | Line::End { .. } => {
for line in &lines {
output.push_str(line.text());
}
break;
}
Line::Choices { choices, .. } => {
for line in &lines {
output.push_str(line.text());
}
let idx = if input_idx < inputs.len() {
let c = inputs[input_idx];
input_idx += 1;
c
} else {
0
};
assert!(
idx < choices.len(),
"choice index {idx} out of range (only {} choices available)",
choices.len()
);
story.choose(idx).unwrap();
}
}
}
output
}
#[test]
fn choices_after_tunnel_call_are_yielded() {
let source = "\
-> main
=== function is_alive ===
~ return true
=== check ===
{ is_alive():
->->
}
-> END
=== main ===
Before choices.
-> check ->
* [Option A]
Chose A.
* [Option B]
Chose B.
- -> END
";
let result = compile_and_run(source, &[0]);
assert!(
result.contains("Chose A"),
"expected 'Chose A' after tunnel return, got: {result:?}"
);
}
#[test]
fn choices_after_tunnel_call_with_args_are_yielded() {
let source = "\
VAR hp = 2
-> main
=== function is_alive ===
~ return hp > 0
=== get_hit(x) ===
~ hp = hp - x
{ is_alive():
->->
}
-> END
=== main ===
Start.
-> get_hit(1) ->
* [Fight]
You fight.
* [Flee]
You flee.
- -> END
";
let result = compile_and_run(source, &[0]);
assert!(
result.contains("You fight"),
"expected 'You fight' after tunnel return, got: {result:?}"
);
}
#[test]
fn nested_choices_after_tunnel_in_stitch() {
let source = "\
VAR hp = 2
-> main
=== function is_alive ===
~ return hp > 0
=== get_hit(x) ===
~ hp = hp - x
{ is_alive():
->->
}
-> END
=== main ===
Choose:
* [Yes]
You chose yes.
-> END
* [No]
You chose no.
-> get_hit(1) ->
** [Fight]
You fight.
** [Flee]
You flee.
- -> END
";
let result = compile_and_run(source, &[1, 0]);
assert!(
result.contains("You fight"),
"expected inner choice after tunnel return, got: {result:?}"
);
}
#[test]
fn list_items_display_without_origin_prefix() {
let source = "\
LIST colors = (red), green, (blue)
{colors}
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "red, blue\n");
}
#[test]
fn multi_list_display_without_origin_prefix() {
let source = "\
LIST a = (x), y
LIST b = (p), q
{a + b}
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "x, p\n");
}
#[test]
fn external_function_uses_ink_fallback() {
let source = "\
EXTERNAL greet()
The value is {greet()}.
-> END
=== function greet() ===
~ return \"hello\"
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "The value is hello.\n");
}
#[test]
fn external_function_fallback_with_args() {
let source = "\
EXTERNAL add(x, y)
The value is {add(3, 4)}.
-> END
=== function add(x, y) ===
~ return x + y
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "The value is 7.\n");
}
#[test]
fn include_content_appears_before_main() {
let files: HashMap<&str, &str> = HashMap::from([
("main.ink", "INCLUDE a.ink\nINCLUDE b.ink\nThis is main.\n"),
("a.ink", "This is A.\n"),
("b.ink", "This is B.\n"),
]);
let data = compile_mem("main.ink", &files).unwrap();
let (program, line_tables) = brink_runtime::link(&data).unwrap();
let mut story = Story::<DotNetRng>::new(std::sync::Arc::new(program), line_tables);
let lines = story.continue_maximally().unwrap();
let result: String = lines.iter().map(Line::text).collect();
assert_eq!(
result, "This is A.\nThis is B.\nThis is main.\n",
"included file content must appear before main file content"
);
}
#[test]
fn divert_to_standalone_labeled_gather() {
let source = "\
-> knot
=== knot ===
-> knot.gather
- (gather) g
-> DONE
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "g\n");
}
#[test]
fn divert_target_with_parameter() {
let source = "\
VAR x = ->place
->x (5)
== place (a) ==
{a}
-> DONE
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "5\n");
}
#[test]
fn tunnel_onwards_with_arg() {
let source = "\
-> a ->
=== a ===
->-> b (5 + 3)
=== b (x) ===
{x}
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "8\n");
}
#[test]
fn tunnel_onwards_with_param_default_choice() {
let source = "\
-> tunnel ->
== tunnel ==
* ->-> elsewhere (8)
== elsewhere (x) ==
{x}
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "8\n");
}
#[test]
fn variable_tunnel_call() {
let source = "\
-> one_then_tother(-> tunnel)
=== one_then_tother(-> x) ===
-> x -> end
=== tunnel ===
STUFF
->->
=== end ===
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "STUFF\n");
}
#[test]
fn tunnel_return_at_gather_with_thread() {
let source = "\
-> knot
=== knot
<- threadA
When should this get printed?
-> DONE
=== threadA
-> tunnel ->
Finishing thread.
-> DONE
=== tunnel
- I'm in a tunnel
* I'm an option
- ->->
";
let result = compile_and_run(source, &[0]);
assert_eq!(
result,
"I'm in a tunnel\nWhen should this get printed?\nI'm an option\nFinishing thread.\n"
);
}
#[test]
fn gather_bare_tunnel_return() {
let source = "\
-> start
== start ==
-> tun ->
After tunnel.
-> END
== tun ==
- Gathered.
* Pick me
- ->->
";
let result = compile_and_run(source, &[0]);
assert_eq!(result, "Gathered.\nPick me\nAfter tunnel.\n");
}
#[test]
fn gather_tunnel_return_with_override() {
let source = "\
-> start
== start ==
-> tun ->
Should not print.
-> END
== tun ==
- In tunnel.
* Pick me
- ->-> destination
== destination ==
Overridden.
-> END
";
let result = compile_and_run(source, &[0]);
assert_eq!(result, "In tunnel.\nPick me\nOverridden.\n");
}
#[test]
fn gather_tunnel_call() {
let source = "\
-> start
== start ==
* Pick me
- -> inner_tunnel ->
After inner tunnel.
-> END
== inner_tunnel ==
Inside inner tunnel.
->->
";
let result = compile_and_run(source, &[0]);
assert_eq!(
result,
"Pick me\nInside inner tunnel.\nAfter inner tunnel.\n"
);
}
#[test]
fn gather_thread_start() {
let source = "\
-> start
== start ==
* Pick me
- <- bg_thread
+ Next
-
Done.
-> END
== bg_thread ==
* Background option
- -> DONE
";
let result = compile_and_run(source, &[0, 0]);
assert!(
result.contains("Background option"),
"expected thread's choice from gather `<- bg_thread` to be available, got: {result:?}"
);
}
#[test]
fn gather_tunnel_return_emits_tunnel_return_opcode() {
let source = "\
-> start
== start ==
-> tun ->
After.
-> END
== tun ==
- Top.
* Option
- ->->
";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let mut buf = String::new();
brink_format::write_inkt(&data, &mut buf).unwrap();
assert!(
buf.contains("tunnel_return"),
"expected tunnel_return in bytecode for gather `->->`, got:\n{buf}"
);
}
#[test]
#[ignore = "thread completion doesn't resume main flow — runtime thread merging bug"]
fn tunnel_and_thread_choices_merge() {
let source = "\
-> knot_with_options ->
Finished tunnel.
Starting thread.
<- thread_with_options
* E
-
Done.
== knot_with_options ==
* A
* B
-
->->
== thread_with_options ==
* C
* D
- -> DONE
";
let result = compile_and_run(source, &[0, 0]);
assert_eq!(result, "A\nFinished tunnel.\nStarting thread.\nC\nDone.\n");
}
#[test]
fn thread_choices_merge_with_tunnel() {
let source = "\
-> knot
=== knot
<- threadB
-> tunnel ->
THE END
-> END
=== tunnel
- blah blah
* wigwag
- ->->
=== threadB
* option
- something
-> DONE
";
let result = compile_and_run(source, &[0]);
assert_eq!(result, "blah blah\noption\nsomething\n");
}
#[test]
fn multiple_thread_choices_merge() {
let source = "\
-> start
== start ==
-> tunnel ->
The end
-> END
== tunnel ==
<- place1
<- place2
-> DONE
== place1 ==
This is place 1.
* choice in place 1
- ->->
== place2 ==
This is place 2.
* choice in place 2
- ->->
";
let result = compile_and_run(source, &[0]);
assert!(
result.contains("choice in place 1"),
"expected first thread's choice to be available, got: {result:?}"
);
}
#[test]
fn thread_choice_loop_with_variable_divert() {
let source = "\
-> start
=== start ===
Here is some gold. Do you want it?
- (top)
<- choices(-> top)
+ Yes
You win!
-> END
=== choices(-> goback) ===
+ No
Try again!
-> goback
";
let result = compile_and_run(source, &[1, 1, 0]);
assert!(
result.contains("You win!"),
"expected loop with thread choices, got: {result:?}"
);
}
#[test]
fn choice_set_does_not_emit_begin_choice_set() {
let source = "\
-> start
== start ==
* Choice A
* Choice B
- Done.
";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let mut buf = String::new();
brink_format::write_inkt(&data, &mut buf).unwrap();
assert!(
!buf.contains("begin_choice_set"),
"begin_choice_set should not appear in compiled output:\n{buf}"
);
assert!(
!buf.contains("end_choice_set"),
"end_choice_set should not appear in compiled output:\n{buf}"
);
}
#[test]
fn three_threads_all_choices_merge() {
let source = "\
-> start
== start ==
<- t1
<- t2
<- t3
* local choice
- Done.
== t1 ==
* thread 1 choice
- -> DONE
== t2 ==
* thread 2 choice
- -> DONE
== t3 ==
* thread 3 choice
- -> DONE
";
let result = compile_and_run(source, &[0]);
assert!(
result.contains("Done.") || result.contains("choice"),
"expected all thread choices to be available, got: {result:?}"
);
}
#[test]
fn thread_choice_with_once_only_filtering() {
let source = "\
-> start
== start ==
<- thread_opts
+ [sticky] Sticky text
- -> END
== thread_opts ==
* once only
-> start
- -> DONE
";
let result = compile_and_run(source, &[0, 0]);
assert!(
result.contains("once only") || result.contains("Sticky text"),
"expected both choices to be available initially, got: {result:?}"
);
}
#[test]
fn nested_thread_in_tunnel_choices_merge() {
let source = "\
-> start
== start ==
-> tun ->
* caller choice
- The end.
== tun ==
<- inner_thread
* tunnel choice
- ->->
== inner_thread ==
* thread choice
- -> DONE
";
let result = compile_and_run(source, &[0]);
assert!(
result.contains("The end.") || result.contains("choice"),
"expected thread+tunnel+caller choices to merge, got: {result:?}"
);
}
#[test]
fn nested_gather_three_levels() {
let source = "\
* A
* * B
* * * C
- - - Inner gather.
- - Middle gather.
- Outer gather.
-> END
";
let result = compile_and_run(source, &[0, 0, 0]);
assert_eq!(
result,
"A\nB\nC\nInner gather.\nMiddle gather.\nOuter gather.\n"
);
}
#[test]
fn nested_gather_with_second_choice_round() {
let source = "\
* First
* * Second
* * Third
- - Between.
* * Fourth
- - After fourth.
- Final.
-> END
";
let result = compile_and_run(source, &[0, 0, 0]);
assert_eq!(
result,
"First\nSecond\nBetween.\nFourth\nAfter fourth.\nFinal.\n"
);
}
#[test]
fn nested_gather_with_glue_continuation() {
let source = "\
* Outer choice
* * Deep choice
- - After deep, <>
- outer end.
-> END
";
let result = compile_and_run(source, &[0, 0]);
assert_eq!(
result,
"Outer choice\nDeep choice\nAfter deep, outer end.\n"
);
}
#[test]
fn stitch_params_by_value() {
let source = "\
-> greet.say(\"Hello\", \"world\")
== greet ==
= say(greeting, who)
{greeting}, {who}!
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Hello, world!\n");
}
#[test]
fn ref_param_global_var() {
let source = "\
VAR x = 1
~ bump(x)
{x}
-> END
=== function bump(ref target) ===
~ target = target + 1
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "2\n");
}
#[test]
fn ref_param_function_two_refs() {
let source = "\
VAR a = 10
VAR b = 0
~ swap(a, b)
a={a} b={b}
-> END
=== function swap(ref x, ref y) ===
~ temp t = x
~ x = y
~ y = t
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "a=0 b=10\n");
}
#[test]
fn tower_of_hanoi_mini() {
let source = "\
LIST Discs = one, two, three
VAR post1 = ()
VAR post2 = ()
VAR post3 = ()
~ post1 = LIST_ALL(Discs)
-> gameloop
=== function can_move(from_list, to_list) ===
{
- LIST_COUNT(from_list) == 0:
~ return false
- LIST_COUNT(to_list) > 0 && LIST_MIN(from_list) > LIST_MIN(to_list):
~ return false
- else:
~ return true
}
=== function move_ring( ref from, ref to ) ===
~ temp whichRingToMove = LIST_MIN(from)
~ from -= whichRingToMove
~ to += whichRingToMove
=== gameloop
Start.
- (top)
+ [ Regard]
Regarded.
<- move_post(1, 2, post1, post2)
-> DONE
= move_post(from_post_num, to_post_num, ref from_post_list, ref to_post_list)
+ { can_move(from_post_list, to_post_list) }
[ Move ]
{ move_ring(from_post_list, to_post_list) }
Moved.
-> top
";
let result = compile_and_run(source, &[0, 0]);
assert!(
result.contains("Moved") || result.contains("Regarded"),
"expected tower-of-hanoi mini to produce output, got: {result:?}"
);
}
#[test]
fn ref_param_list_move_ring() {
let source = "\
LIST Discs = one, two, three
VAR post1 = ()
VAR post2 = ()
~ post1 = LIST_ALL(Discs)
~ move_ring(post1, post2)
{post1}
{post2}
-> END
=== function move_ring( ref from, ref to ) ===
~ temp whichRingToMove = LIST_MIN(from)
~ from -= whichRingToMove
~ to += whichRingToMove
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "two, three\none\n");
}
#[test]
#[ignore = "visit count for gather labels not incremented on re-entry"]
fn space_between_interpolations_preserved() {
let source = "\
VAR gatherCount = 0
- (loop)
~ gatherCount++
{gatherCount} {loop}
{gatherCount<3:->loop}
-> DONE
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "1 1\n2 2\n3 3\n");
}
#[test]
fn conditional_divert_basic() {
let source = "\
VAR x = 1
{x == 1:->yes}
Nope.
-> END
== yes ==
Yes!
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Yes!\n");
}
#[test]
fn conditional_divert_loop() {
let source = "\
VAR i = 0
- (loop)
~ i++
{i}
{i < 3:->loop}
-> DONE
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "1\n2\n3\n");
}
#[test]
fn conditional_text_then_divert() {
let source = "\
VAR x = 1
{x == 1: Going there! ->yes}
Nope.
-> END
== yes ==
Arrived.
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Going there! Arrived.\n");
}
#[test]
fn conditional_divert_false_branch() {
let source = "\
VAR x = 0
{x == 1:->yes}
Fallthrough.
-> END
== yes ==
Yes!
-> END
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Fallthrough.\n");
}
#[test]
fn ref_parameter_modifies_caller_variable() {
let source = "\
VAR x = 0
~ bump(x)
{x}
-> DONE
=== function bump(ref n) ===
~ n++
";
let result = compile_and_run(source, &[]);
assert_eq!(result, "1\n");
}
#[test]
#[ignore = "runtime thread merging infinite loop with multiple conditional-choice threads"]
fn tower_of_hanoi_6threads() {
let source = "\
LIST Discs = one, two, three
VAR post1 = ()
VAR post2 = ()
VAR post3 = ()
~ post1 = LIST_ALL(Discs)
-> gameloop
=== function can_move(from_list, to_list) ===
{
- LIST_COUNT(from_list) == 0:
~ return false
- LIST_COUNT(to_list) > 0 && LIST_MIN(from_list) > LIST_MIN(to_list):
~ return false
- else:
~ return true
}
=== function move_ring( ref from, ref to ) ===
~ temp whichRingToMove = LIST_MIN(from)
~ from -= whichRingToMove
~ to += whichRingToMove
=== gameloop
Start.
- (top)
+ [ Regard]
Regarded.
<- move_post(1, 2, post1, post2)
<- move_post(2, 1, post2, post1)
<- move_post(1, 3, post1, post3)
<- move_post(3, 1, post3, post1)
<- move_post(3, 2, post3, post2)
<- move_post(2, 3, post2, post3)
-> DONE
= move_post(from_post_num, to_post_num, ref from_post_list, ref to_post_list)
+ { can_move(from_post_list, to_post_list) }
[ Move {from_post_num} to {to_post_num} ]
{ move_ring(from_post_list, to_post_list) }
Moved.
-> top
";
let result = compile_and_run(source, &[0, 0]);
assert!(
result.contains("Moved") || result.contains("Regarded"),
"expected output, got: {result:?}"
);
}
fn diagnostic_codes(err: &brink_compiler::CompileError) -> Vec<&'static str> {
match err {
brink_compiler::CompileError::Diagnostics(diags) => {
diags.iter().map(|d| d.code.as_str()).collect()
}
_ => vec![],
}
}
#[test]
fn compile_error_nested_choice_in_conditional() {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", "{ true:\n * choice\n}\n")]);
let result = compile_mem("main.ink", &files);
let err = result.expect_err(
"choice inside inline conditional should be a compile error, \
but compilation succeeded",
);
let codes = diagnostic_codes(&err);
assert!(
codes.contains(&"E029"),
"expected E029 (choice in conditional must explicitly divert), got: {codes:?}"
);
}
#[test]
fn choice_in_conditional_with_divert_is_valid() {
let source = "=== play_game ===\n{ true:\n + [Burn] -> play_game\n}\n-> END\n";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let result = compile_mem("main.ink", &files);
assert!(
result.is_ok(),
"choice with divert in conditional should compile: {result:?}"
);
}
#[test]
fn choice_in_conditional_with_gather_continuation_is_valid() {
let source =
"=== play_game ===\n{ true:\n + (burny) [Burn]\n Hello\n}\n- -> burny\n-> END\n";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let result = compile_mem("main.ink", &files);
assert!(
result.is_ok(),
"choice in conditional with gather continuation should compile: {result:?}"
);
}
#[test]
fn compile_error_disallow_empty_diverts() {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", "->\n")]);
let result = compile_mem("main.ink", &files);
let err = result.expect_err("bare `->` should be a compile error, but compilation succeeded");
let codes = diagnostic_codes(&err);
assert!(
codes.contains(&"E012"),
"expected E012 (divert is missing a target), got: {codes:?}"
);
}
#[test]
fn unresolved_function_call_is_compile_error() {
let files: HashMap<&str, &str> = HashMap::from([(
"main.ink",
"\
~ temp x = DOES_NOT_EXIST()
{x}
-> END
",
)]);
let result = compile_mem("main.ink", &files);
assert!(
result.is_err(),
"calling a nonexistent function should produce a compile error, not succeed silently"
);
}
#[test]
fn turns_builtin_compiles_and_runs() {
let output = compile_and_run(
"\
~ temp t = TURNS()
turn is {t}
-> END
",
&[],
);
assert_eq!(output.trim(), "turn is 0");
}
#[test]
fn turns_builtin_increments_across_choices() {
let output = compile_and_run(
"\
turn {TURNS()}
+ [continue]
-
turn {TURNS()}
-> END
",
&[0],
);
assert_eq!(output.trim(), "turn 0\nturn 1");
}
fn compile_and_run_steps(source: &str, inputs: &[usize]) -> Vec<(String, Option<usize>)> {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let (program, line_tables) = brink_runtime::link(&data).unwrap();
let mut story = Story::<DotNetRng>::new(std::sync::Arc::new(program), line_tables);
let mut steps = Vec::new();
let mut input_idx = 0;
let mut guard = 0;
loop {
guard += 1;
assert!(guard < 100, "infinite loop detected");
let lines = story.continue_maximally().unwrap();
let combined_text: String = lines.iter().map(Line::text).collect();
let last = lines.last().unwrap();
match last {
Line::Text { .. } | Line::Done { .. } | Line::End { .. } => {
steps.push((combined_text, None));
break;
}
Line::Choices { choices, .. } => {
let count = choices.len();
steps.push((combined_text.clone(), Some(count)));
let idx = if input_idx < inputs.len() {
let c = inputs[input_idx];
input_idx += 1;
c
} else {
0
};
assert!(
idx < count,
"choice index {idx} out of range (only {count} choices), text so far: {combined_text:?}"
);
story.choose(idx).unwrap();
}
}
}
steps
}
#[test]
fn sequence_branch_starts_with_newline() {
let source = "\
-> test
=== test ===
{ stopping:
- Branch one.
- Branch two.
}
* [Again] Prefix. -> test
- -> END
";
let steps = compile_and_run_steps(source, &[0]);
assert!(
steps.len() >= 2,
"expected at least 2 steps, got {}",
steps.len()
);
let text = &steps[1].0;
assert!(
text.contains("Prefix.") && text.contains("Branch two."),
"expected both 'Prefix.' and 'Branch two.' in output, got: {text:?}"
);
assert!(
!text.contains("Prefix. Branch two.") && !text.contains("Prefix.Branch two."),
"expected newline between 'Prefix.' and 'Branch two.', got: {text:?}"
);
}
#[test]
fn choices_inside_sequence_branch_accumulate_with_parent() {
let source = "\
-> test
=== test ===
{ stopping:
- At the table, I drew a card. Ace of Hearts.
- 2 of Diamonds.
\"Should I hit you again,\" the croupier asks.
* [No.] I left the table. -> END
- King of Spades.
\"You lose,\" he crowed.
-> END
}
+ [Draw a card] I drew a card. -> test
";
let steps = compile_and_run_steps(source, &[0, 0]);
let second_choice_count = steps[1].1;
assert_eq!(
second_choice_count,
Some(2),
"expected 2 choices (No. + Draw a card) on second visit, got: {second_choice_count:?}"
);
}
#[test]
fn content_after_multiline_conditional_preserved() {
let source = "\
{true:
a
} <> b
";
let result = compile_and_run(source, &[]);
assert_eq!(
result, "a b\n",
"glue + text after conditional must be preserved"
);
}
#[test]
fn content_after_multiline_conditional_with_nested_conditional() {
let source = "\
{true:
a
} <> { true:
b
}
";
let result = compile_and_run(source, &[]);
assert_eq!(
result, "a b\n",
"glue + conditional after conditional must be preserved"
);
}
#[test]
fn shuffle_once_exhausts_after_all_branches_visited() {
let source = "\
~ SEED_RANDOM(1)
one: {f()}
two: {f()}
three: {f()}
four: {f()}
== function f ==
{shuffle once:
- A
- B
}
";
let result = compile_and_run(source, &[]);
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 4, "expected 4 output lines, got: {result:?}");
let first_two_content: Vec<&str> = lines[0..2]
.iter()
.map(|l| l.split(": ").nth(1).unwrap_or("").trim())
.collect();
let mut sorted = first_two_content.clone();
sorted.sort_unstable();
assert_eq!(
sorted,
vec!["A", "B"],
"first two calls should produce A and B (in any order), got: {first_two_content:?}"
);
for (i, line) in lines[2..].iter().enumerate() {
let after_colon = line.split(": ").nth(1).unwrap_or("").trim();
assert!(
after_colon.is_empty(),
"call {} (line {:?}) should produce no text after exhaustion, got: {after_colon:?}",
i + 3,
line,
);
}
}
#[test]
fn shuffle_stopping_pins_to_last_branch() {
let source = "\
~ SEED_RANDOM(1)
one: {f()}
two: {f()}
three: {f()}
four: {f()}
five: {f()}
== function f ==
{stopping shuffle:
- A
- B
- final
}
";
let result = compile_and_run(source, &[]);
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 5, "expected 5 output lines, got: {result:?}");
let first_three_content: Vec<String> = lines[0..3]
.iter()
.map(|l| l.split(": ").nth(1).unwrap_or("").trim().to_string())
.collect();
let mut sorted: Vec<&str> = first_three_content.iter().map(String::as_str).collect();
sorted.sort_unstable();
assert_eq!(
sorted,
vec!["A", "B", "final"],
"first three calls should produce A, B, final (in any order), got: {first_three_content:?}"
);
for (i, line) in lines[3..].iter().enumerate() {
let after_colon = line.split(": ").nth(1).unwrap_or("").trim();
assert_eq!(
after_colon,
"final",
"call {} should pin to 'final' after exhaustion, got: {after_colon:?}",
i + 4,
);
}
}
#[test]
fn shuffle_once_codegen_emits_min_opcode() {
use brink_format::Opcode;
let source = "\
{shuffle once:
- A
- B
}
";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let seq_container = data
.containers
.iter()
.find(|c| {
let mut offset = 0;
let mut has_sequence = false;
while offset < c.bytecode.len() {
if let Ok(op) = Opcode::decode(&c.bytecode, &mut offset) {
if matches!(op, Opcode::Sequence(..)) {
has_sequence = true;
}
} else {
break;
}
}
has_sequence
})
.expect("should find a container with a Sequence opcode");
let mut offset = 0;
let mut has_min = false;
while offset < seq_container.bytecode.len() {
if let Ok(op) = Opcode::decode(&seq_container.bytecode, &mut offset) {
if matches!(op, Opcode::Min) {
has_min = true;
}
} else {
break;
}
}
assert!(
has_min,
"shuffle once container must emit Min opcode for exhaustion clamping"
);
}
#[test]
fn keyword_once_as_knot_name_and_divert_target() {
let source = "\
-> once
== once ==
Hello from once.
-> END
";
let result = compile_and_run(source, &[]);
assert!(
result.contains("Hello from once"),
"knot named 'once' should work, got: {result:?}"
);
}
#[test]
fn thread_in_logic_compiles_and_runs() {
let source = "\
-> once ->
-> once ->
== once ==
{<- content|}
->->
== content ==
Content
-> DONE
";
let result = compile_and_run(source, &[]);
assert!(
result.contains("Content"),
"thread-in-logic should produce 'Content', got: {result:?}"
);
}
#[test]
fn template_single_variable() {
let source = "VAR name = \"World\"\nHello, {name}!\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Hello, World!\n");
}
#[test]
fn template_multiple_interpolations() {
let source = "VAR a = \"one\"\nVAR b = \"two\"\n{a} and {b}\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "one and two\n");
}
#[test]
fn template_expression_interpolation() {
let source = "VAR n = 3\nResult: {n * 2}\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Result: 6\n");
}
#[test]
fn template_interpolation_at_start() {
let source = "VAR x = \"Hello\"\n{x} world\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Hello world\n");
}
#[test]
fn template_interpolation_at_end() {
let source = "VAR x = \"world\"\nHello {x}\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Hello world\n");
}
#[test]
fn plain_text_regression() {
let source = "Just plain text.\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Just plain text.\n");
}
#[test]
fn template_integer_interpolation() {
let source = "VAR count = 42\nThere are {count} items.\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "There are 42 items.\n");
}
#[test]
fn template_float_interpolation() {
let source = "VAR pi = 3.14\nPi is {pi}.\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Pi is 3.14.\n");
}
#[test]
fn template_bool_interpolation() {
let source = "VAR flag = true\nFlag: {flag}\n";
let result = compile_and_run(source, &[]);
assert_eq!(result, "Flag: true\n");
}
fn compile_mem_with_warnings(
entry: &str,
files: &HashMap<&str, &str>,
) -> Result<brink_compiler::CompileOutput, brink_compiler::CompileError> {
brink_compiler::compile(entry, |path| {
files.get(path).map(|s| (*s).to_string()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("file not found: {path}"),
)
})
})
}
#[test]
fn warnings_surfaced_alongside_successful_compilation() {
let files: HashMap<&str, &str> = HashMap::from([(
"main.ink",
"VAR name = \"world\"\nCONST greeting = \"hi {name}\"\n{greeting}\n",
)]);
let output = compile_mem_with_warnings("main.ink", &files).unwrap();
assert!(
!output.data.containers.is_empty(),
"compilation should succeed"
);
assert!(
output.warnings.iter().any(|w| w.code.as_str() == "E030"),
"expected E030 warning, got: {:?}",
output
.warnings
.iter()
.map(|w| w.code.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn warning_from_included_file_carries_its_path() {
let files: HashMap<&str, &str> = HashMap::from([
("main.ink", "INCLUDE phone.ink\n-> reveal\n"),
("phone.ink", "=== reveal ===\n-> END\nAnd we're off.\n"),
]);
let output = compile_mem_with_warnings("main.ink", &files).unwrap();
let e033 = output
.warnings
.iter()
.find(|w| w.code.as_str() == "E033")
.expect("expected an E033 warning from the unreachable line in phone.ink");
assert_eq!(
e033.path, "phone.ink",
"E033 from phone.ink must be attributed to phone.ink, not the entry"
);
}
#[test]
fn clean_compilation_has_no_warnings() {
let files: HashMap<&str, &str> = HashMap::from([("main.ink", "Hello, world!\n-> END\n")]);
let output = compile_mem_with_warnings("main.ink", &files).unwrap();
assert!(
output.warnings.is_empty(),
"expected no warnings for clean source, got: {:?}",
output
.warnings
.iter()
.map(|w| format!("[{}] {}", w.code.as_str(), w.message))
.collect::<Vec<_>>()
);
}
#[test]
fn glue_in_choice_body_emits_glue_opcode() {
let source = "\
-> knot
=== knot
* [Yes]
Yes considered. <>
* [No]
No way. <>
- He seemed to know.
-> END
";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let mut buf = String::new();
brink_format::write_inkt(&data, &mut buf).unwrap();
eprintln!("{buf}");
assert!(
buf.contains("glue"),
"expected glue opcode in bytecode, got:\n{buf}"
);
}
#[test]
fn glue_in_choice_body_runtime_joins_text() {
let source = "\
-> knot
=== knot
* [Yes]
Yes considered. <>
* [No]
No way. <>
- He seemed to know.
-> END
";
let files: HashMap<&str, &str> = HashMap::from([("main.ink", source)]);
let data = compile_mem("main.ink", &files).unwrap();
let (program, line_tables) = brink_runtime::link(&data).unwrap();
let mut story = Story::<DotNetRng>::new(std::sync::Arc::new(program), line_tables);
let line = story.continue_single().unwrap();
match &line {
Line::Choices { choices, .. } => {
assert_eq!(choices.len(), 2);
story.choose(0).unwrap(); }
other => panic!("expected Choices, got: {other:?}"),
}
let line = story.continue_single().unwrap();
let text = match &line {
Line::Text { text, .. } => text.clone(),
Line::End { text, .. } => text.clone(),
Line::Done { text, .. } => text.clone(),
Line::Choices { .. } => panic!("expected text output, got choices"),
};
eprintln!("got text: {text:?}");
assert!(
text.contains("Yes considered. He seemed to know."),
"expected glue to join choice text with gather text, got: {text:?}"
);
}
#[test]
fn compile_error_inline_conditional_with_logic() {
let files: HashMap<&str, &str> = HashMap::from([(
"main.ink",
"VAR x = 0\n{ true: ~ x = 2 }\nValue {x}.\n-> END\n",
)]);
let err = compile_mem("main.ink", &files).expect_err(
"an inline conditional containing a `~` logic statement is invalid ink \
(logic belongs in a multiline block) and should be a compile error",
);
let codes = diagnostic_codes(&err);
assert!(!codes.is_empty(), "expected a diagnostic, got: {codes:?}");
}
#[test]
fn compile_error_inline_multi_branch_conditional() {
let files: HashMap<&str, &str> = HashMap::from([(
"main.ink",
"VAR n = 5\nIt is {n > 8: big|n > 4: medium|small}.\n-> END\n",
)]);
let err = compile_mem("main.ink", &files).expect_err(
"an inline conditional with conditions on each branch is invalid ink \
(multi-branch switches require the multiline block form) and should be \
a compile error",
);
let codes = diagnostic_codes(&err);
assert!(!codes.is_empty(), "expected a diagnostic, got: {codes:?}");
}