use brink_runtime::{Element, FastRng, Step, Story};
#[expect(clippy::unwrap_used)]
fn story_from_source(src: &str) -> Story<FastRng> {
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)]
fn story_from_native_source(src: &str) -> Story<FastRng> {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("brink_element_test_{}_{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("main.brink");
std::fs::write(&path, src).unwrap();
let options = brink_analyzer::AnalysisOptions {
conventions: Some("main.brink".to_owned()),
..brink_analyzer::AnalysisOptions::default()
};
let result = brink_compiler::compile_path_with_options(&path, options);
let _ = std::fs::remove_dir_all(&dir);
let data = result.unwrap().data;
let (program, line_tables) = brink_runtime::link(&data).unwrap();
Story::new(std::sync::Arc::new(program), line_tables)
}
#[test]
fn plain_lines_carry_the_narrative_default() {
let mut story = story_from_source("One.\nTwo.\n-> END\n");
let steps = story.continue_maximally().expect("drive to END");
let lines: Vec<_> = steps
.iter()
.filter_map(|s| match s {
Step::Line(line) => Some(line),
_ => None,
})
.collect();
assert_eq!(lines.len(), 2, "{steps:?}");
for line in &lines {
assert_eq!(line.element, Element::narrative(), "{steps:?}");
assert_eq!(line.element.kind, "narrative");
assert!(line.element.data.is_empty());
}
}
#[test]
fn lines_after_a_choice_still_carry_the_narrative_default() {
let mut story = story_from_source(
"-> start\n\
=== start ===\n\
Before.\n\
* [left] Went left.\n-> END\n\
* [right] Went right.\n-> END\n",
);
let _ = story.continue_maximally().expect("drive to choices");
story.choose(0).expect("choose left");
let after_steps = story.continue_maximally().expect("drive to END");
let after_lines: Vec<_> = after_steps
.iter()
.filter_map(|s| match s {
Step::Line(line) => Some(line),
_ => None,
})
.collect();
assert!(!after_lines.is_empty(), "{after_steps:?}");
for line in &after_lines {
assert_eq!(line.element, Element::narrative(), "{after_steps:?}");
}
}
#[test]
fn attach_convention_data_reaches_the_following_run() {
let src = r#"
struct Cue {
speaker: string,
}
struct Parenthetical {
delivery: string,
}
@[convention(claims = "^(?<name>[A-Z][A-Z '-]*)$", attach = Cue, order = 10)]
fn cue(name: string): Cue {
return Cue { speaker: name };
}
@[convention(claims = "^(?<delivery>[a-z][a-z' -]*)$", attach = Parenthetical, order = 20)]
fn parenthetical(delivery: string): Parenthetical {
return Parenthetical { delivery: delivery };
}
@[convention(claims = "^(?<text>[A-Z][A-Z '-]*:)$", order = 30)]
fn transition(text: string) {
return text;
}
flow main() {
@VENDOR
(hushed)
You shouldn't be here after dark.
@KID
Says who?
CUT TO:
-> END
}
"#;
let mut story = story_from_native_source(src);
let steps = story.continue_maximally().expect("drive to END");
let lines: Vec<_> = steps
.iter()
.filter_map(|s| match s {
Step::Line(line) => Some(line),
_ => None,
})
.collect();
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(
texts,
vec![
"You shouldn't be here after dark.\n",
"Says who?\n",
"CUT TO:\n",
],
"attach conventions must consume their own line and produce no event: {steps:?}"
);
let vendor_line = lines[0];
assert_eq!(
vendor_line.element.data.get("speaker").map(String::as_str),
Some("VENDOR"),
"{vendor_line:?}"
);
assert_eq!(
vendor_line.element.data.get("delivery").map(String::as_str),
Some("hushed"),
"{vendor_line:?}"
);
let kid_line = lines[1];
assert_eq!(
kid_line.element.data.get("speaker").map(String::as_str),
Some("KID"),
"{kid_line:?}"
);
assert!(
!kid_line.element.data.contains_key("delivery"),
"KID's turn has no parenthetical — must not inherit VENDOR's 'hushed': {kid_line:?}"
);
let transition_line = lines[2];
assert_eq!(
transition_line.element,
Element::narrative(),
"a bare transition after a dialogue run must not inherit its speaker: {transition_line:?}"
);
}
#[test]
fn compact_cue_fused_dialogue_attaches_and_keeps_interpolation() {
let src = r#"
struct Cue {
speaker: string,
}
@[convention(claims = "^(?<name>[A-Z][A-Z '-]*)$", attach = Cue, order = 10)]
fn cue(name: string): Cue {
return Cue { speaker: name };
}
var count = 3
flow main() {
@KID: I have {count} coins.
-> END
}
"#;
let mut story = story_from_native_source(src);
let steps = story.continue_maximally().expect("drive to END");
let lines: Vec<_> = steps
.iter()
.filter_map(|s| match s {
Step::Line(line) => Some(line),
_ => None,
})
.collect();
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(
texts,
vec!["I have 3 coins.\n"],
"cue's own claimed line must consume itself and produce no event; \
the fused dialogue must interpolate `count` exactly like ordinary \
prose: {steps:?}"
);
let dialogue_line = lines[0];
assert_eq!(
dialogue_line
.element
.data
.get("speaker")
.map(String::as_str),
Some("KID"),
"the fused dialogue must land INSIDE cue's attached run, carrying \
its speaker data, not just render as plain unattached text: \
{dialogue_line:?}"
);
}
#[test]
fn compact_cue_dialogue_with_fused_divert_declines_the_claim() {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let src = r#"
struct Cue {
speaker: string,
}
@[convention(claims = "^(?<name>[A-Z][A-Z '-]*)$", attach = Cue, order = 10)]
fn cue(name: string): Cue {
return Cue { speaker: name };
}
flow main() {
@KID: Goodbye. -> outside
}
flow outside() {
You are outside.
-> END
}
"#;
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"brink_element_compact_cue_divert_{}_{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("main.brink");
std::fs::write(&path, src).expect("write fixture");
let options = brink_analyzer::AnalysisOptions {
conventions: Some("main.brink".to_owned()),
..brink_analyzer::AnalysisOptions::default()
};
let result = brink_compiler::compile_path_with_options(&path, options);
let _ = std::fs::remove_dir_all(&dir);
assert!(
result.is_err(),
"a compact cue whose fused dialogue carries a divert must decline \
the claim and fail to compile (E129), not silently corrupt the \
attached run: {result:?}"
);
}
#[test]
fn compact_cue_dialogue_with_fused_label_declines_the_claim() {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let src = r#"
struct Cue {
speaker: string,
}
@[convention(claims = "^(?<name>[A-Z][A-Z '-]*)$", attach = Cue, order = 10)]
fn cue(name: string): Cue {
return Cue { speaker: name };
}
flow main() {
@KID: (beat) I have an idea.
-> END
}
"#;
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"brink_element_compact_cue_label_{}_{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("main.brink");
std::fs::write(&path, src).expect("write fixture");
let options = brink_analyzer::AnalysisOptions {
conventions: Some("main.brink".to_owned()),
..brink_analyzer::AnalysisOptions::default()
};
let result = brink_compiler::compile_path_with_options(&path, options);
let _ = std::fs::remove_dir_all(&dir);
assert!(
result.is_err(),
"a compact cue whose fused dialogue carries a label must decline \
the claim and fail to compile (E129), not silently absorb it into \
a labeled block: {result:?}"
);
}
#[test]
fn attach_element_data_does_not_leak_across_a_choice_boundary() {
let src = r#"
struct Cue {
speaker: string,
}
@[convention(claims = "^(?<name>[A-Z][A-Z '-]*)$", attach = Cue, order = 10)]
fn cue(name: string): Cue {
return Cue { speaker: name };
}
flow main() {
@VENDOR
You shouldn't be here after dark.
Get out now.
{?
* [Leave] You leave without a word.
}
-> END
}
"#;
let mut story = story_from_native_source(src);
let before_steps = story.continue_maximally().expect("drive to choices");
assert!(
matches!(before_steps.last(), Some(Step::Choices(_))),
"{before_steps:?}"
);
story.choose(0).expect("choose Leave");
let after_steps = story.continue_maximally().expect("drive to END");
let after_lines: Vec<_> = after_steps
.iter()
.filter_map(|s| match s {
Step::Line(line) => Some(line),
_ => None,
})
.collect();
assert!(!after_lines.is_empty(), "{after_steps:?}");
for line in &after_lines {
assert_eq!(
line.element,
Element::narrative(),
"a line in the branch taken after the choice must not inherit \
VENDOR's already-closed attach run: {after_steps:?}"
);
}
}
#[test]
fn a_claimed_headings_own_tags_reach_the_output_line() {
let src = r#"
@[convention(claims = "^(?<kind>INT|EXT)\\. (?<title>.+)$", order = 10)]
fn heading(kind: string, title: string) {
return "-- {kind}. {title} --";
}
flow main() {
INT. MARKET SQUARE - NIGHT [market] #act1
The square is empty.
-> END
}
"#;
let mut story = story_from_native_source(src);
let steps = story.continue_maximally().expect("drive to END");
let lines: Vec<_> = steps
.iter()
.filter_map(|s| match s {
Step::Line(line) => Some(line),
_ => None,
})
.collect();
let heading_line = lines
.iter()
.find(|l| l.text.contains("MARKET SQUARE"))
.expect("expected the claimed heading's own line");
assert_eq!(
heading_line.tags,
vec!["act1".to_string()],
"the heading's own trailing tag must reach OutputLine.tags: {heading_line:?}"
);
}