use brink_format::Value;
use brink_runtime::{
DotNetRng, ExternalFnHandler, ExternalResult, RanOutOfContentCause, RuntimeError, Step, Story,
};
#[expect(clippy::unwrap_used)]
fn story_from_source(src: &str) -> Story<DotNetRng> {
let data = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned()))
.unwrap()
.data;
let (program, line_tables) = brink_runtime::link(&data).unwrap();
Story::new(std::sync::Arc::new(program), line_tables)
}
#[expect(clippy::unwrap_used, clippy::panic)]
fn drive_to_terminal(story: &mut Story<DotNetRng>) -> (Step, String) {
let mut text = String::new();
for _ in 0..64 {
let step = story.continue_single().unwrap();
text.push_str(step.text());
if step.is_terminal() {
return (step, text);
}
}
panic!("no terminal step within the step budget");
}
#[test]
fn ran_out_of_content_faults_on_the_call_after_the_done_line() {
let mut story = story_from_source("-> k\n== k ==\nHello.\n");
let (terminal, text) = drive_to_terminal(&mut story);
assert!(
matches!(terminal, Step::Done),
"the fault-arming Done terminal follows the trailing text, got {terminal:?}"
);
assert!(
text.contains("Hello."),
"the line's text is delivered: {text:?}"
);
assert!(
!story.did_safe_exit(),
"did_safe_exit must be false right after the ran-out-of-content Done line"
);
assert!(
matches!(
story.continue_single(),
Err(RuntimeError::RanOutOfContent(RanOutOfContentCause::Plain))
),
"the deferred fault surfaces one continue later"
);
}
#[test]
fn explicit_done_is_a_safe_exit_and_does_not_fault() {
let mut story = story_from_source("Hello.\n-> DONE\n");
let (terminal, _) = drive_to_terminal(&mut story);
assert!(matches!(terminal, Step::Done));
assert!(
story.did_safe_exit(),
"did_safe_exit must be true right after an explicit -> DONE"
);
let result = story.continue_single();
assert!(
matches!(result, Ok(Step::Done)),
"a safe exit must not raise the deferred fault, got {result:?}"
);
}
#[test]
fn end_classifies_eagerly_unlike_done() {
let mut story = story_from_source("Hello.\n-> END\n");
let (terminal, _) = drive_to_terminal(&mut story);
assert!(
matches!(terminal, Step::End),
"got {terminal:?}, expected End"
);
assert!(
matches!(story.continue_single(), Err(RuntimeError::StoryEnded)),
"an ended story reports StoryEnded, never RanOutOfContent"
);
}
#[test]
fn tunnel_fall_off_classifies_as_plain_not_tunnel_today() {
let mut story =
story_from_source("-> main\n=== main ===\n-> tunnel ->\n\n=== tunnel ===\nHello.\n");
let (terminal, text) = drive_to_terminal(&mut story);
assert!(matches!(terminal, Step::Done));
assert!(text.contains("Hello."));
assert!(!story.did_safe_exit());
assert!(
matches!(
story.continue_single(),
Err(RuntimeError::RanOutOfContent(RanOutOfContentCause::Plain))
),
"tunnel fall-off is misclassified as Plain today (see #2005) — \
a future fix to Tunnel auto-pop semantics must flip this to Tunnel deliberately"
);
}
#[test]
fn function_fall_off_classifies_as_plain_not_function_today() {
let mut story =
story_from_source("-> main\n=== main ===\n~ temp x = f()\n\n=== function f ===\nHello.\n");
let (terminal, text) = drive_to_terminal(&mut story);
assert!(matches!(terminal, Step::Done));
assert!(text.contains("Hello."));
assert!(!story.did_safe_exit());
assert!(
matches!(
story.continue_single(),
Err(RuntimeError::RanOutOfContent(RanOutOfContentCause::Plain))
),
"function fall-off cascades down to the root frame's own Plain \
classification today (see #2005)"
);
}
struct NoExternals;
impl ExternalFnHandler for NoExternals {
fn call(&self, _name: &str, _args: &[Value]) -> ExternalResult {
ExternalResult::Fallback
}
}
#[test]
fn call_function_void_helper_exhaustion_does_not_clobber_the_pending_plain_cause() {
let mut story = story_from_source(
"-> k\n\
== k ==\n\
Hello.\n\
\n\
=== function f ===\n\
~ g()\n\
~ return true\n\
\n\
=== function g ===\n\
World.\n",
);
let (terminal, text) = drive_to_terminal(&mut story);
assert!(matches!(terminal, Step::Done));
assert!(text.contains("Hello."));
assert!(
!story.did_safe_exit(),
"k's body falls off the end with no -> DONE"
);
let ret = story
.call_function("f", &[], &NoExternals)
.expect("f() should evaluate cleanly with no pending external");
assert_eq!(ret, Value::Bool(true));
assert!(
matches!(
story.continue_single(),
Err(RuntimeError::RanOutOfContent(RanOutOfContentCause::Plain))
),
"g's void-helper exhaustion during call_function must not clobber \
the Plain cause the earlier fall-off recorded"
);
}