use brainwires_reasoning::output_parser::{
JsonListParser, JsonOutputParser, OutputParser, RegexOutputParser,
};
use brainwires_reasoning::plan_parser::{ParsedStep, parse_plan_steps, steps_to_tasks};
use proptest::prelude::*;
use serde::Deserialize;
#[test]
fn numbered_lines_with_dot_and_paren_are_both_accepted() {
let input = "1. first\n2) second\n3. third";
let steps = parse_plan_steps(input);
assert_eq!(steps.len(), 3);
assert_eq!(steps[0].description, "first");
assert_eq!(steps[1].description, "second");
assert_eq!(steps[2].description, "third");
}
#[test]
fn step_colon_format_is_accepted() {
let input = "Step 1: build the index\nStep 2: query it";
let steps = parse_plan_steps(input);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].description, "build the index");
assert_eq!(steps[1].description, "query it");
}
#[test]
fn indent_produces_substeps() {
let input = "1. root\n 2. child\n 3. child2\n4. next root";
let steps = parse_plan_steps(input);
assert_eq!(steps.len(), 4);
assert_eq!(steps[0].indent_level, 0);
assert_eq!(steps[1].indent_level, 1);
assert_eq!(steps[2].indent_level, 1);
assert_eq!(steps[3].indent_level, 0);
}
#[test]
fn priority_keywords_flag_is_priority() {
for keyword in ["important", "IMPORTANT", "critical", "Critical", "!"] {
let input = format!("1. do {keyword} thing");
let steps = parse_plan_steps(&input);
assert_eq!(steps.len(), 1);
assert!(
steps[0].is_priority,
"keyword `{keyword}` should flag priority",
);
}
}
#[test]
fn non_priority_items_stay_low_priority() {
let steps = parse_plan_steps("1. ordinary task");
assert_eq!(steps.len(), 1);
assert!(!steps[0].is_priority);
}
#[test]
fn empty_and_whitespace_input_produce_no_steps() {
assert!(parse_plan_steps("").is_empty());
assert!(parse_plan_steps(" \n\n \t\n").is_empty());
}
#[test]
fn prose_without_numbers_produces_no_steps() {
let steps = parse_plan_steps(
"This is a narrative paragraph with no list structure. Nothing\n\
should be extracted from it, even though it mentions step X.",
);
assert!(steps.is_empty());
}
#[test]
fn action_bullets_are_picked_up_but_notes_are_skipped() {
let input = "\
- Note: this is a comment we should skip
- implement the parser module in src/lib.rs
- Warning: this bullet is also noise
- create a new integration test harness";
let steps = parse_plan_steps(input);
assert_eq!(
steps.len(),
2,
"only the two action bullets should extract: {steps:?}",
);
assert!(steps[0].description.contains("implement"));
assert!(steps[1].description.contains("create"));
}
#[test]
fn short_bullets_are_ignored() {
let steps = parse_plan_steps("- add x\n- fix y");
assert!(steps.is_empty());
}
#[test]
fn steps_to_tasks_preserves_step_count_and_priority() {
let input = "1. ordinary\n2. IMPORTANT ship it\n3. cleanup";
let steps = parse_plan_steps(input);
let tasks = steps_to_tasks(&steps, "plan-1234567890");
assert_eq!(tasks.len(), 3);
assert_eq!(
tasks[1].priority,
brainwires_core::TaskPriority::High,
"important step must map to TaskPriority::High",
);
assert!(tasks[0].id.ends_with("-step-1"));
assert!(tasks[2].id.ends_with("-step-3"));
}
#[derive(Debug, Deserialize, PartialEq)]
struct Review {
sentiment: String,
score: i32,
}
#[test]
fn json_parser_extracts_from_markdown_fence_with_language_tag() {
let parser = JsonOutputParser::<Review>::new();
let input = "```json\n{\"sentiment\": \"positive\", \"score\": 9}\n```";
let out = parser.parse(input).unwrap();
assert_eq!(
out,
Review {
sentiment: "positive".into(),
score: 9
}
);
}
#[test]
fn json_parser_extracts_from_markdown_fence_without_language_tag() {
let parser = JsonOutputParser::<Review>::new();
let input = "```\n{\"sentiment\": \"negative\", \"score\": -1}\n```";
let out = parser.parse(input).unwrap();
assert_eq!(out.sentiment, "negative");
}
#[test]
fn json_parser_extracts_from_surrounding_prose() {
let parser = JsonOutputParser::<Review>::new();
let input = "Sure, here's the JSON: {\"sentiment\":\"x\",\"score\":0} — hope it helps!";
let out = parser.parse(input).unwrap();
assert_eq!(out.sentiment, "x");
}
#[test]
fn json_parser_fails_cleanly_on_no_json() {
let parser = JsonOutputParser::<Review>::new();
let err = parser
.parse("Sorry I cannot help with that today")
.unwrap_err();
assert!(
format!("{err:#}").to_lowercase().contains("json"),
"error should mention JSON: {err:#}",
);
}
#[test]
fn json_list_parser_parses_array() {
let parser = JsonListParser::<Review>::new();
let input = "[{\"sentiment\":\"a\",\"score\":1},{\"sentiment\":\"b\",\"score\":2}]";
let out = parser.parse(input).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[1].score, 2);
}
#[test]
fn format_instructions_are_non_empty_and_mention_json() {
let p = JsonOutputParser::<Review>::new();
let instr = p.format_instructions();
assert!(!instr.is_empty());
assert!(instr.to_lowercase().contains("json"));
}
#[test]
fn regex_parser_extracts_named_captures() {
let p = RegexOutputParser::new(r"sentiment: (?P<s>\w+), score: (?P<v>\d+)").unwrap();
let out = p.parse("sentiment: good, score: 7 etc").unwrap();
assert_eq!(out["s"], "good");
assert_eq!(out["v"], "7");
}
#[test]
fn regex_parser_fails_when_pattern_does_not_match() {
let p = RegexOutputParser::new(r"^(?P<head>[A-Z]+)$").unwrap();
let err = p.parse("no uppercase match here").unwrap_err();
assert!(format!("{err:#}").to_lowercase().contains("regex"));
}
#[test]
fn regex_parser_rejects_invalid_pattern_at_construction() {
assert!(RegexOutputParser::new(r"(unterminated").is_err());
}
proptest! {
#[test]
fn plan_parser_is_panic_free_and_numbers_are_monotonic(
input in ".{0,500}",
) {
let steps = parse_plan_steps(&input);
let mut prev = 0;
for s in &steps {
prop_assert!(s.number > prev, "step numbers must strictly increase: {:?}", steps);
prev = s.number;
}
}
#[test]
fn numbered_lines_extract_one_step_each(
count in 1usize..15,
word in "[a-z][a-z ]{3,20}",
) {
let mut lines = String::new();
for i in 1..=count {
lines.push_str(&format!("{i}. {word}\n"));
}
let steps = parse_plan_steps(&lines);
prop_assert_eq!(steps.len(), count);
let expected = word.trim();
for s in &steps {
prop_assert_eq!(s.description.as_str(), expected);
}
}
#[test]
fn json_parser_never_panics_on_arbitrary_text(text in ".{0,300}") {
let p = JsonOutputParser::<serde_json::Value>::new();
let _ = p.parse(&text);
}
#[test]
fn json_parser_extracts_embedded_objects(
pre in "[a-z ]{0,30}",
key in "[a-z][a-z_]{1,10}",
val in 0i32..1000,
post in "[a-z .]{0,30}",
) {
let embedded = format!("{pre}{{\"{key}\":{val}}}{post}");
let p = JsonOutputParser::<serde_json::Value>::new();
let v = p.parse(&embedded).unwrap();
prop_assert_eq!(&v[&key], &serde_json::json!(val));
}
#[test]
fn indent_level_equals_leading_spaces_div_2(spaces in 0usize..20) {
let indent = " ".repeat(spaces);
let line = format!("{indent}1. task");
let steps = parse_plan_steps(&line);
prop_assert_eq!(steps.len(), 1);
prop_assert_eq!(steps[0].indent_level, spaces / 2);
}
}
#[test]
fn parsed_step_default_shape_is_sensible() {
let s = ParsedStep {
number: 1,
description: "x".into(),
indent_level: 0,
is_priority: false,
};
assert_eq!(s.number, 1);
assert!(!s.is_priority);
}