met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: Unit tests for the reasoning parsers.
//!
//! Owner: server.
//! Invariants: none beyond the types.

use super::{ReasoningFormat, ReasoningParser};

fn parser(fmt: ReasoningFormat) -> Box<dyn ReasoningParser> {
    fmt.into_parser()
}

#[test]
fn qwen_closed_block_splits_on_end_tag() {
    let p = parser(ReasoningFormat::Qwen);
    let (reasoning, content) = p.extract_thinking(
        "I need to think about this\n</think>\nThe answer is 42.",
        true,
    );
    assert_eq!(reasoning.unwrap(), "I need to think about this");
    assert_eq!(content, "The answer is 42.");
}

#[test]
fn deepseek_r1_closed_block_splits_on_end_tag() {
    let p = parser(ReasoningFormat::DeepSeekR1);
    let (reasoning, content) =
        p.extract_thinking("counting steps</think>Your name is Zephyr.", true);
    assert_eq!(reasoning.unwrap(), "counting steps");
    assert_eq!(content, "Your name is Zephyr.");
}

#[test]
fn minimax_strips_model_emitted_opening_tag() {
    // 2026-09-26: A leading `<think>` is consumed even though the prompt opened
    // the block.
    let p = parser(ReasoningFormat::MiniMax);
    let (reasoning, content) =
        p.extract_thinking("<think>weighing options</think>final answer", true);
    assert_eq!(reasoning.unwrap(), "weighing options");
    assert_eq!(content, "final answer");
}

#[test]
fn thinking_on_tagless_output_is_reasoning_not_content() {
    // 2026-09-26: With thinking on, tagless output from a `<think>` format is
    // unfinished reasoning, and none of it reaches the content.
    for fmt in [
        ReasoningFormat::Qwen,
        ReasoningFormat::DeepSeekR1,
        ReasoningFormat::MiniMax,
    ] {
        let p = parser(fmt);
        let (reasoning, content) =
            p.extract_thinking("The user typed 'My name is' and then... wait", true);
        assert_eq!(
            reasoning.as_deref(),
            Some("The user typed 'My name is' and then... wait"),
            "{} must route tagless thinking-on output to reasoning",
            p.name(),
        );
        assert_eq!(
            content,
            "",
            "{}: no raw chain-of-thought in content",
            p.name()
        );
    }
}

#[test]
fn thinking_off_tagless_output_is_content() {
    // 2026-09-26: With thinking off, tagless output is the answer.
    let p = parser(ReasoningFormat::DeepSeekR1);
    let (reasoning, content) = p.extract_thinking("Your name is Zephyr.", false);
    assert!(reasoning.is_none());
    assert_eq!(content, "Your name is Zephyr.");
}

#[test]
fn thinking_disabled_discards_reasoning_keeps_answer() {
    // 2026-09-26: With thinking off, a closed block is still split off and
    // only the answer is returned.
    let p = parser(ReasoningFormat::Qwen);
    let (reasoning, content) = p.extract_thinking("reasoning\n</think>\ncontent", false);
    assert!(reasoning.is_none());
    assert_eq!(content, "content");
}

#[test]
fn truncated_reasoning_with_partial_tag_streamed_in() {
    let p = parser(ReasoningFormat::DeepSeekR1);
    let (reasoning, content) = p.extract_thinking("<think>still reasoning", true);
    assert_eq!(reasoning.unwrap(), "still reasoning");
    assert_eq!(content, "");
}

#[test]
fn mistral_closed_block() {
    let p = parser(ReasoningFormat::Mistral);
    let (reasoning, content) = p.extract_thinking(
        "[THINK]Let me reason here[/THINK]Paris is the capital.",
        true,
    );
    assert_eq!(reasoning.unwrap(), "Let me reason here");
    assert_eq!(content, "Paris is the capital.");
}

#[test]
fn mistral_no_tags_is_all_content() {
    // 2026-09-26: Mistral output without `[THINK]` is all content, even with
    // thinking on.
    let p = parser(ReasoningFormat::Mistral);
    let (reasoning, content) = p.extract_thinking("Paris is the capital of France.", true);
    assert!(reasoning.is_none());
    assert_eq!(content, "Paris is the capital of France.");
}

#[test]
fn mistral_cut_off_mid_think() {
    let p = parser(ReasoningFormat::Mistral);
    let (reasoning, content) = p.extract_thinking("[THINK]reasoning not finished", true);
    assert_eq!(reasoning.unwrap(), "reasoning not finished");
    assert_eq!(content, "");
}

#[test]
fn first_end_tag_bounds_reasoning_leftover_blocks_stripped() {
    // 2026-09-26: Reasoning ends at the first `</think>`; a later balanced
    // pair is removed from the content.
    let p = parser(ReasoningFormat::Qwen);
    let (reasoning, content) =
        p.extract_thinking("first thought</think>middle<think>leaked</think>end", true);
    assert_eq!(reasoning.unwrap(), "first thought");
    assert_eq!(content, "middleend");
}

#[test]
fn content_before_model_emitted_start_tag_is_content() {
    let p = parser(ReasoningFormat::Mistral);
    let (reasoning, content) =
        p.extract_thinking("prefix [THINK]the reasoning[/THINK] the answer", true);
    assert_eq!(reasoning.unwrap(), "the reasoning");
    assert_eq!(content, "prefix  the answer");
}

#[test]
fn gemma4_channel_split() {
    let p = parser(ReasoningFormat::Gemma4);
    let (reasoning, content) = p.extract_thinking(
        "<|channel>thought\nworking it out<channel|>The final answer.",
        true,
    );
    assert_eq!(reasoning.unwrap(), "working it out");
    assert_eq!(content, "The final answer.");
}

#[test]
fn gemma4_no_channel_is_all_content() {
    let p = parser(ReasoningFormat::Gemma4);
    let (reasoning, content) = p.extract_thinking("just a direct answer", true);
    assert!(reasoning.is_none());
    assert_eq!(content, "just a direct answer");
}

#[test]
fn gemma4_open_channel_no_close_is_reasoning() {
    let p = parser(ReasoningFormat::Gemma4);
    let (reasoning, content) = p.extract_thinking("<|channel>thought\ncut off here", true);
    assert_eq!(reasoning.unwrap(), "cut off here");
    assert_eq!(content, "");
}

#[test]
fn parsers_have_distinct_identity() {
    assert_eq!(parser(ReasoningFormat::Qwen).name(), "qwen");
    assert_eq!(parser(ReasoningFormat::DeepSeekR1).name(), "deepseek_r1");
    assert_eq!(parser(ReasoningFormat::MiniMax).name(), "minimax");
    assert_eq!(parser(ReasoningFormat::Mistral).name(), "mistral");
    assert_eq!(parser(ReasoningFormat::Gemma4).name(), "gemma4");
}

#[test]
fn parser_tags() {
    assert_eq!(parser(ReasoningFormat::DeepSeekR1).start_tag(), "<think>");
    assert_eq!(parser(ReasoningFormat::DeepSeekR1).end_tag(), "</think>");
    assert_eq!(parser(ReasoningFormat::Mistral).start_tag(), "[THINK]");
    assert_eq!(parser(ReasoningFormat::Mistral).end_tag(), "[/THINK]");
    assert_eq!(parser(ReasoningFormat::Gemma4).end_tag(), "<channel|>");
}

#[test]
fn format_from_str() {
    use std::str::FromStr;
    assert_eq!(
        ReasoningFormat::from_str("qwen").unwrap(),
        ReasoningFormat::Qwen
    );
    assert_eq!(
        ReasoningFormat::from_str("deepseek_r1").unwrap(),
        ReasoningFormat::DeepSeekR1
    );
    for alias in ["nemotron", "nemotron_h", "nano_v3"] {
        assert_eq!(
            ReasoningFormat::from_str(alias).unwrap(),
            ReasoningFormat::DeepSeekR1,
        );
    }
    assert_eq!(
        ReasoningFormat::from_str("minimax").unwrap(),
        ReasoningFormat::MiniMax
    );
    assert_eq!(
        ReasoningFormat::from_str("mistral").unwrap(),
        ReasoningFormat::Mistral
    );
    assert_eq!(
        ReasoningFormat::from_str("gemma4").unwrap(),
        ReasoningFormat::Gemma4
    );
    assert!(ReasoningFormat::from_str("nonsense").is_err());
}