//! Typed view of a parsed mcptest suite.
//!
//! `mcptest-config` validates the YAML against the JSON Schema and
//! returns a [`serde_yaml::Value`]; the runner-side executor needs a
//! typed view so it can dispatch tool calls and matchers without
//! re-walking the untyped tree on every test. This module sits between
//! the schema-validated YAML and the runner: it lifts the bits the
//! executor cares about (servers, tools, resources, prompts,
//! assertions) into Rust types and leaves everything else alone.
//!
//! Scope for v1: tools, resources, and prompts over stdio plus
//! streamable-http and legacy-sse, with the four core matchers
//! (exact, contains, regex, subset). The richer surface (schema,
//! snapshot, llm-judge) is parsed into explicit typed variants
//! ([`Matcher::Schema`], [`Matcher::Snapshot`], [`Matcher::LlmJudge`])
//! so the compiler tracks every supported matcher; the executor still
//! reports a deferred-evaluation failure for those three until the
//! live runner closer wires them through
//! `mcptest_core::matchers`.

use std::collections::BTreeMap;

use serde_yaml::Value as YamlValue;

mod agent_gates;
mod agent_gates_parse;
mod agent_types;
mod budgets_parse;
mod checks_parse;
mod compliance_parse;
mod compositions_parse;
mod context_rot_parse;
mod derived_parse;
pub mod duration;
mod elicitation_parse;
mod eval_parse;
pub mod faults;
mod faults_parse;
mod fixtures_parse;
pub mod lint;
mod matcher;
mod matcher_parse;
mod named_graded;
mod refs;
mod robustness_parse;
mod scenarios_parse;
pub mod scoring;
mod security_parse;
mod server_parse;
mod test_parse;
mod types;

// Re-exported within the crate so the composition evaluator can parse a raw
// matcher object through the same code path the suite loader uses.
pub(crate) use matcher_parse::parse_matcher;

pub use agent_gates::{
    ContextRotProbeGate, DistractorGate, EqualFunctionGate, GoldenPathGate, NameFreeDiscovery,
    OrchestrationGate, RecoveryGate, StabilityGate, TokenEfficiencyGate, ToolEdgesGate,
    ToolSelectionFloor, TrajectoryAxesGate, TrajectoryGate,
};
pub use agent_types::*;
pub use compliance_parse::ComplianceCheck;
pub use duration::{parse_human_duration, DurationParseError};
pub use faults::{FaultKind, FaultSpec, FaultTarget};
pub use matcher::Matcher;
pub use refs::{rebase_cassette_paths, validate_server_refs};
pub use types::*;

use crate::hooks::HookStep;
use agent_gates_parse::{
    parse_distractor_gate, parse_equal_function_gate, parse_golden_path_gate, parse_matrix_assert,
    parse_name_free_discovery, parse_orchestration_gate, parse_stability_gate,
    parse_token_efficiency_gate, parse_tool_edges_gate, parse_tool_selection,
    parse_trajectory_axes_gate, parse_trajectory_gate,
};
use context_rot_parse::parse_context_rot_probe_gate;
use derived_parse::parse_derived_metrics;
use eval_parse::parse_eval_metrics;
use server_parse::{parse_agent_servers, parse_budget, parse_providers, parse_servers};
use test_parse::{
    parse_agent_turns, parse_assert_sets, parse_expectations, parse_prompt_test,
    parse_resource_test, parse_skip_only, parse_tags, parse_test_section, parse_test_threshold,
    parse_tool_test, parse_transform, require_str,
};

mod parse_error;
pub use parse_error::ParseError;

/// Parse a schema-validated [`YamlValue`] into a typed [`ResolvedSuite`].
///
/// This is the bridge between `mcptest-config` (returns YamlValue) and
/// the executor (consumes the typed model). The schema validation
/// already ran upstream; here we only extract the shape the executor
/// uses, and we tolerate unknown keys so future YAML extensions do not
/// break older runners.
pub fn parse(value: &YamlValue) -> Result<ResolvedSuite, ParseError> {
    let servers = parse_servers(value)?;
    let mut tests = Vec::new();
    parse_test_section(value, "tools", parse_tool_test, &mut tests)?;
    parse_test_section(value, "resources", parse_resource_test, &mut tests)?;
    parse_test_section(value, "prompts", parse_prompt_test, &mut tests)?;
    let providers = parse_providers(value)?;
    let mut agents = parse_agents(value, &providers)?;
    let budget = parse_budget(value)?;
    let default_test = parse_default_test(value)?;
    let hooks = parse_hooks(value, "")?;
    let calibration = checks_parse::parse_calibration(value)?;
    let tool_quality = checks_parse::parse_tool_quality(value)?;
    let grade_delta = checks_parse::parse_grade_delta(value)?;
    let spec_version_check = checks_parse::parse_spec_version_check(value)?;
    let compliance_tests = compliance_parse::parse(value)?;
    let target_versions = parse_target_versions(value)?;
    let fixtures = fixtures_parse::parse(value)?;
    let compositions = compositions_parse::parse(value)?;
    let faults = faults_parse::parse(value)?;
    let security = security_parse::parse(value)?;
    let scenarios = scenarios_parse::parse(value)?;
    let input_responder = elicitation_parse::parse_suite_input_responder(value)?;
    let model_compat = checks_parse::parse_model_compat(value)?;
    let conformance = checks_parse::parse_conformance(value)?;
    let coverage = checks_parse::parse_coverage(value)?;
    let performance = checks_parse::parse_performance(value)?;
    // Fold the suite-level `defaultTest` baseline into every test
    // and agent test after parsing so the merge rule lives in one place.
    if let Some(default) = &default_test {
        for t in &mut tests {
            merge_default_into_test(default, t);
        }
        for a in &mut agents {
            merge_default_into_agent(default, a);
        }
    }
    // A `jury.*` analytics target only resolves when the test runs
    // exactly one jury. Reject zero or several at load time, after the
    // `defaultTest` merge so an appended jury assertion counts too.
    for test in &tests {
        validate_jury_targets(&test.name, &test.expectations)?;
        fixtures_parse::validate_inject_error(test, &fixtures)?;
    }
    for agent in &agents {
        validate_jury_targets(&agent.name, &agent.expectations)?;
        faults_parse::validate_inject_names(agent, &faults)?;
    }

    Ok(ResolvedSuite {
        servers,
        tests,
        agents,
        budget,
        providers,
        default_test,
        hooks,
        calibration,
        tool_quality,
        grade_delta,
        spec_version_check,
        compliance_tests,
        target_versions,
        fixtures,
        compositions,
        faults,
        security,
        scenarios,
        input_responder,
        model_compat,
        conformance,
        coverage,
        performance,
    })
}

/// Parse the optional suite-level `target_versions:` list.
///
/// Each entry must be a YAML string that round-trips through
/// [`ProtocolVersion::from_wire`]. A missing key returns an empty
/// vector so existing suites are unchanged and the runner takes its
/// default-protocol path. A non-sequence shape or an unknown version
/// is a load-time error so a typo never silently selects a
/// session-based fallback.
fn parse_target_versions(
    root: &YamlValue,
) -> Result<Vec<crate::protocol::ProtocolVersion>, ParseError> {
    let Some(node) = root.get("target_versions") else {
        return Ok(Vec::new());
    };
    let seq = node.as_sequence().ok_or(ParseError::BadShape {
        path: "/target_versions".to_string(),
        field: "target_versions",
        expected: "sequence of protocol-version strings (e.g. `2025-11-25`)",
    })?;
    let mut out: Vec<crate::protocol::ProtocolVersion> = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        let raw = item.as_str().ok_or(ParseError::BadShape {
            path: format!("/target_versions[{i}]"),
            field: "target_versions",
            expected: "protocol-version string (e.g. `2025-11-25`)",
        })?;
        let parsed = crate::protocol::ProtocolVersion::from_wire(raw).ok_or_else(|| {
            ParseError::UnknownProtocolVersion {
                index: i,
                value: raw.to_string(),
            }
        })?;
        // De-dupe but preserve declaration order; the fan-out runner
        // (v2) reports per-version results in the order the suite
        // listed them.
        if !out.contains(&parsed) {
            out.push(parsed);
        }
    }
    Ok(out)
}

/// Enforce the one-jury rule for `jury.*` targets.
///
/// A `jury.agreement` / `jury.confidence` / `jury.escalate` target reads the
/// analytics of the jury that runs in the same test. That is well defined
/// only when the test has exactly one `llm-jury` assertion, so a test that
/// uses any `jury.*` target with zero or several juries is a load error.
fn validate_jury_targets(test: &str, expectations: &[Assertion]) -> Result<(), ParseError> {
    let uses_jury_target = expectations
        .iter()
        .any(|a| a.target == "jury" || a.target.starts_with("jury."));
    if !uses_jury_target {
        return Ok(());
    }
    let jury_count = expectations
        .iter()
        .filter(|a| matches!(a.matcher, Matcher::LlmJury(_)))
        .count();
    if jury_count == 1 {
        Ok(())
    } else {
        Err(ParseError::JuryTargetArity {
            test: test.to_string(),
            jury_count,
        })
    }
}

/// Parse the optional suite-level `defaultTest:` baseline.
/// Returns `None` when the key is absent, leaving every test unchanged.
fn parse_default_test(root: &YamlValue) -> Result<Option<DefaultTest>, ParseError> {
    let Some(node) = root.get("defaultTest") else {
        return Ok(None);
    };
    let path = "/defaultTest";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "defaultTest",
            expected: "an object",
        });
    }
    Ok(Some(DefaultTest {
        expectations: parse_expectations(node, path)?,
        assert_sets: parse_assert_sets(node, path)?,
        threshold: parse_test_threshold(node, path)?,
        derived_metrics: parse_derived_metrics(node, path)?,
        transform: parse_transform(node, path)?,
    }))
}

/// Merge a [`DefaultTest`] baseline into one [`TypedTest`].
///
/// Assertions, assert-sets, and derived metrics are appended after the
/// test's own (the test's come first). The threshold is a default the
/// test only inherits when it set none of its own, so an explicit
/// per-test threshold always wins.
fn merge_default_into_test(default: &DefaultTest, test: &mut TypedTest) {
    test.expectations
        .extend(default.expectations.iter().cloned());
    test.assert_sets.extend(default.assert_sets.iter().cloned());
    test.derived_metrics
        .extend(default.derived_metrics.iter().cloned());
    if test.threshold.is_none() {
        test.threshold = default.threshold;
    }
    // A per-test transform overrides the default wholesale; the
    // test only inherits the default when it declared none of its own.
    if test.transform.is_none() {
        test.transform = default.transform.clone();
    }
}

/// Merge a [`DefaultTest`] baseline into one [`AgentTest`].
/// Mirrors [`merge_default_into_test`]: append the assertion-bearing
/// lists, inherit the threshold only when the agent test set none.
fn merge_default_into_agent(default: &DefaultTest, agent: &mut AgentTest) {
    agent
        .expectations
        .extend(default.expectations.iter().cloned());
    agent
        .assert_sets
        .extend(default.assert_sets.iter().cloned());
    agent
        .derived_metrics
        .extend(default.derived_metrics.iter().cloned());
    if agent.threshold.is_none() {
        agent.threshold = default.threshold;
    }
}

/// Parse a `hooks:` block at `root` into typed [`Hooks`].
///
/// Each of `beforeAll`, `afterAll`, `beforeEach`, `afterEach` is either a
/// scalar (one command) or a list of command strings. An absent block, or
/// any absent key, yields empty command lists so the no-hooks path is
/// preserved exactly. `path` prefixes error locations (empty for the
/// suite root).
fn parse_hooks(root: &YamlValue, path: &str) -> Result<Hooks, ParseError> {
    let Some(node) = root.get("hooks") else {
        return Ok(Hooks::default());
    };
    let block_path = format!("{path}/hooks");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "hooks",
            expected: "an object",
        });
    }
    Ok(Hooks {
        before_all: parse_hook_commands(node, &block_path, "beforeAll")?,
        after_all: parse_hook_commands(node, &block_path, "afterAll")?,
        before_each: parse_hook_commands(node, &block_path, "beforeEach")?,
        after_each: parse_hook_commands(node, &block_path, "afterEach")?,
    })
}

/// Parse one hook key into a list of [`HookStep`]s. A scalar string is
/// sugar for a single-element list; a sequence becomes the list in order.
/// Each element is either a plain command string ([`HookStep::Plain`],
/// ) or a map opting into the context ABI ([`HookStep::Context`],
/// ). A non-string, non-map element is a typed error so a stray `42`
/// does not silently vanish.
fn parse_hook_commands(
    node: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Vec<HookStep>, ParseError> {
    let Some(value) = node.get(field) else {
        return Ok(Vec::new());
    };
    if let Some(s) = value.as_str() {
        return Ok(vec![HookStep::Plain(s.to_string())]);
    }
    let Some(seq) = value.as_sequence() else {
        return Err(ParseError::BadShape {
            path: format!("{path}/{field}"),
            field,
            expected: "a command string or a list of command strings",
        });
    };
    let mut out = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        out.push(parse_hook_step(
            item,
            &format!("{path}/{field}/{i}"),
            field,
        )?);
    }
    Ok(out)
}

/// Parse one hook step element. A YAML string is a plain command; a map
/// must carry `mode: context` and a `command:` string to opt into the
/// context ABI. A map missing `mode: context` is a clear error so the
/// opt-in is never ambiguous.
fn parse_hook_step(
    item: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<HookStep, ParseError> {
    if let Some(s) = item.as_str() {
        return Ok(HookStep::Plain(s.to_string()));
    }
    let Some(map) = item.as_mapping() else {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "a command string or a `{command, mode: context}` map",
        });
    };
    match map
        .get(YamlValue::String("mode".to_string()))
        .and_then(YamlValue::as_str)
    {
        Some("context") => {}
        _ => {
            return Err(ParseError::BadShape {
                path: path.to_string(),
                field,
                expected: "a map hook to declare `mode: context` (the only supported mode)",
            })
        }
    }
    let raw = map
        .get(YamlValue::String("command".to_string()))
        .and_then(YamlValue::as_str)
        .ok_or(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "a context hook to carry a `command:` string",
        })?;
    let command = split_command(raw, path, field)?;
    Ok(HookStep::Context { command })
}

/// Split a hook command string into program plus argv with POSIX shell
/// rules (the same splitter the  plain hooks and  transforms
/// use), so an unterminated quote fails at load time rather than at spawn.
fn split_command(
    raw: &str,
    path: &str,
    field: &'static str,
) -> Result<TransformCommand, ParseError> {
    let parts = shell_words::split(raw).map_err(|_| ParseError::BadShape {
        path: path.to_string(),
        field,
        expected: "a parseable shell command (check your quoting)",
    })?;
    let mut iter = parts.into_iter();
    let program = iter.next().ok_or(ParseError::BadShape {
        path: path.to_string(),
        field,
        expected: "a non-empty command",
    })?;
    Ok(TransformCommand {
        program,
        args: iter.collect(),
    })
}

fn parse_agents(
    root: &YamlValue,
    providers: &BTreeMap<String, ProviderSpec>,
) -> Result<Vec<AgentTest>, ParseError> {
    let Some(seq) = root.get("agents").and_then(|v| v.as_sequence()) else {
        return Ok(Vec::new());
    };
    let mut out = Vec::with_capacity(seq.len());
    for (i, entry) in seq.iter().enumerate() {
        let path = format!("/agents/{i}");
        out.push(parse_agent_test(entry, &path, providers)?);
    }
    Ok(out)
}

fn parse_agent_test(
    entry: &YamlValue,
    path: &str,
    providers: &BTreeMap<String, ProviderSpec>,
) -> Result<AgentTest, ParseError> {
    let name = require_str(entry, path, "name")?.to_string();
    let models = parse_agent_models(entry, path, providers)?;
    // `prompt` is optional only when a `turns:` block supplies the user side
    //; the both-absent case is rejected once `turns` is parsed.
    let prompt = entry
        .get("prompt")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let system_prompt = entry
        .get("system_prompt")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let servers = parse_agent_servers(entry, path)?;
    let max_turns = opt_u32(entry, "max_turns").unwrap_or(10);
    let max_tokens = opt_u32(entry, "max_tokens").unwrap_or(1024);
    //  run-wide caps. Each is optional and absent means unlimited,
    // so a suite written before this ticket runs exactly as before.
    let token_budget = opt_u32(entry, "token_budget");
    let max_tool_calls = opt_u32(entry, "max_tool_calls");
    let time_budget = parse_time_budget(entry, path)?;
    let expectations = parse_expectations(entry, path)?;
    let tags = parse_tags(entry, path)?;
    let (skip, only) = parse_skip_only(entry, path)?;
    // `runs:` defaults to 1 and is clamped up so a `runs: 0` typo can
    // never produce zero executor rows (which would silently vanish
    // from the report). The schema also pins `minimum: 1` upstream.
    let runs = opt_u32(entry, "runs").unwrap_or(1).max(1);
    let tool_selection = parse_tool_selection(entry, path)?;
    let matrix_assert = parse_matrix_assert(entry, path)?;
    let stability = parse_stability_gate(entry, path, &name, runs)?;
    let equal_function = parse_equal_function_gate(entry, path)?;
    let token_efficiency = parse_token_efficiency_gate(entry, path)?;
    let tool_edges = parse_tool_edges_gate(entry, path)?;
    let orchestration = parse_orchestration_gate(entry, path)?;
    let discovery = parse_name_free_discovery(entry, path)?;
    let distractors = parse_distractor_gate(entry, path)?;
    let context_rot_probe = parse_context_rot_probe_gate(entry, path)?;
    let golden_path = parse_golden_path_gate(entry, path)?;
    let trajectory = parse_trajectory_gate(entry, path)?;
    let trajectory_axes = parse_trajectory_axes_gate(entry, path)?;
    let assert_sets = parse_assert_sets(entry, path)?;
    let threshold = parse_test_threshold(entry, path)?;
    let derived_metrics = parse_derived_metrics(entry, path)?;
    let eval = parse_eval_metrics(entry, path)?;
    let turns = parse_agent_turns(entry, path)?;
    let inject = faults_parse::parse_inject(entry, path)?;
    let recovery = faults_parse::parse_recovery_gate(entry, path)?;
    if prompt.is_empty() && turns.is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "prompt",
            expected: "a `prompt` or a `turns:` block",
        });
    }
    Ok(AgentTest {
        name,
        models,
        servers,
        prompt,
        system_prompt,
        max_turns,
        max_tokens,
        token_budget,
        max_tool_calls,
        time_budget,
        expectations,
        eval,
        turns,
        tags,
        skip,
        only,
        runs,
        tool_selection,
        matrix_assert,
        stability,
        equal_function,
        token_efficiency,
        tool_edges,
        orchestration,
        discovery,
        distractors,
        context_rot_probe,
        golden_path,
        trajectory,
        trajectory_axes,
        inject,
        recovery,
        assert_sets,
        threshold,
        derived_metrics,
    })
}

/// Read an optional `u32` field, treating an absent, non-integer, or
/// out-of-`u64`-range value as `None`. Used for the agent run-wide caps where
/// absent means unlimited; the schema pins the valid range upstream.
fn opt_u32(entry: &YamlValue, key: &str) -> Option<u32> {
    entry.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
}

/// Parse the optional `time_budget:` field on an agent test.
/// Accepts a human duration string like `"30s"` or `"2m"`. Returns `None`
/// when absent (unlimited) and a typed [`ParseError::BadDuration`] when the
/// value is present but not a string or not a parseable duration.
fn parse_time_budget(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<std::time::Duration>, ParseError> {
    let Some(node) = entry.get("time_budget") else {
        return Ok(None);
    };
    let text = node.as_str().ok_or(ParseError::BadShape {
        path: format!("{path}/time_budget"),
        field: "time_budget",
        expected: "a duration string like \"30s\" or \"2m\"",
    })?;
    let parsed = parse_human_duration(text).map_err(|source| ParseError::BadDuration {
        path: format!("{path}/time_budget"),
        field: "time_budget",
        source,
    })?;
    Ok(Some(parsed))
}

/// Parse the model list for an agent test. Accepts either
/// `model: <id>` (singleton sugar) or `models: [<entry>, ...]`
/// (matrix). Each entry is either a string (auto-detect family) or an
/// object `{provider: <name>, id: <model>}` referencing the
/// `providers:` block. Returns a non-empty `Vec` of [`ModelRef`].
fn parse_agent_models(
    entry: &YamlValue,
    path: &str,
    providers: &BTreeMap<String, ProviderSpec>,
) -> Result<Vec<ModelRef>, ParseError> {
    if let Some(seq) = entry.get("models").and_then(|v| v.as_sequence()) {
        if seq.is_empty() {
            return Err(ParseError::BadShape {
                path: path.to_string(),
                field: "models",
                expected: "non-empty sequence of model entries",
            });
        }
        let mut out = Vec::with_capacity(seq.len());
        for (i, item) in seq.iter().enumerate() {
            let entry_path = format!("{path}/models/{i}");
            out.push(parse_model_ref(item, &entry_path, providers)?);
        }
        return Ok(out);
    }
    let singleton = require_str(entry, path, "model")?.to_string();
    Ok(vec![ModelRef::AutoDetect(singleton)])
}

fn parse_model_ref(
    item: &YamlValue,
    path: &str,
    providers: &BTreeMap<String, ProviderSpec>,
) -> Result<ModelRef, ParseError> {
    if let Some(s) = item.as_str() {
        return Ok(ModelRef::AutoDetect(s.to_string()));
    }
    if let Some(map) = item.as_mapping() {
        let provider = map
            .get(YamlValue::String("provider".into()))
            .and_then(|v| v.as_str())
            .ok_or(ParseError::Missing {
                path: path.to_string(),
                field: "provider",
            })?
            .to_string();
        let id = map
            .get(YamlValue::String("id".into()))
            .and_then(|v| v.as_str())
            .ok_or(ParseError::Missing {
                path: path.to_string(),
                field: "id",
            })?
            .to_string();
        if !providers.contains_key(&provider) {
            return Err(ParseError::BadShape {
                path: path.to_string(),
                field: "provider",
                expected: "name declared under top-level `providers:`",
            });
        }
        return Ok(ModelRef::Named { provider, id });
    }
    Err(ParseError::BadShape {
        path: path.to_string(),
        field: "model entry",
        expected: "string id or {provider, id} object",
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;
    use serde_yaml::from_str;

    /// Unwrap a transform step as a subprocess command for assertions, or
    /// panic if it is the inline jq form.
    fn command_of(step: &TransformStep) -> &TransformCommand {
        match step {
            TransformStep::Command(c) => c,
            TransformStep::Jq(p) => panic!("expected a command step, got jq `{p}`"),
        }
    }

    // === WOR-1267: skip / only authoring markers ===

    #[test]
    fn parses_skip_reason_and_only_marker_on_tool_and_agent_tests() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
tools:
  - name: parked
    server: fs
    tool: search
    skip: "waiting on upstream fix"
  - name: focused
    server: fs
    tool: search
    only: true
agents:
  - name: agent parked
    model: claude-sonnet-4-5
    servers: [fs]
    prompt: hi
    skip: "provider quota exhausted"
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("skip/only parse");
        assert_eq!(
            suite.tests[0].skip.as_deref(),
            Some("waiting on upstream fix")
        );
        assert!(!suite.tests[0].only);
        assert!(suite.tests[0].skip.is_some());
        assert!(suite.tests[1].skip.is_none());
        assert!(suite.tests[1].only);
        assert_eq!(
            suite.agents[0].skip.as_deref(),
            Some("provider quota exhausted")
        );
        assert!(!suite.agents[0].only);
    }

    #[test]
    fn rejects_bare_boolean_skip_without_a_reason() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
tools:
  - name: parked
    server: fs
    tool: search
    skip: true
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("bare boolean skip must be rejected");
        assert!(err.to_string().contains("non-empty reason"), "got: {err}");
    }

    #[test]
    fn rejects_empty_string_skip_reason() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
resources:
  - name: parked
    server: fs
    uri: "file:///x"
    skip: "  "
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("whitespace-only skip reason must be rejected");
        assert!(err.to_string().contains("non-empty reason"), "got: {err}");
    }

    #[test]
    fn rejects_non_boolean_only_marker() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
prompts:
  - name: focused
    server: fs
    prompt: greet
    only: "yes"
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("string only must be rejected");
        assert!(err.to_string().contains("boolean"), "got: {err}");
    }

    // === : target_versions suite parsing ===

    #[test]
    fn parser_returns_empty_target_versions_when_key_omitted() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("baseline suite parses");
        assert!(
            suite.target_versions.is_empty(),
            "missing target_versions must parse as empty so existing suites stay unchanged"
        );
    }

    #[test]
    fn parser_round_trips_a_target_versions_list() {
        // The 2025-11-25 / 2026-07-28 pair models the common
        // deprecation-window contract test: same suite, both contracts.
        let yaml: YamlValue = from_str(
            r#"
target_versions:
  - "2025-11-25"
  - "2026-07-28"
servers:
  fs:
    command: ["node", "./server.js"]
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("target_versions parses");
        assert_eq!(
            suite.target_versions,
            vec![
                crate::protocol::ProtocolVersion::V2025_11_25,
                crate::protocol::ProtocolVersion::V2026_07_28,
            ]
        );
    }

    #[test]
    fn parser_de_dupes_target_versions_in_order() {
        let yaml: YamlValue = from_str(
            r#"
target_versions:
  - "2026-07-28"
  - "2025-11-25"
  - "2026-07-28"
servers:
  fs:
    command: ["node", "./server.js"]
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("duplicates de-dupe, do not error");
        assert_eq!(
            suite.target_versions,
            vec![
                crate::protocol::ProtocolVersion::V2026_07_28,
                crate::protocol::ProtocolVersion::V2025_11_25,
            ]
        );
    }

    #[test]
    fn parser_rejects_unknown_target_version() {
        let yaml: YamlValue = from_str(
            r#"
target_versions:
  - "2099-12-31"
servers:
  fs:
    command: ["node", "./server.js"]
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("unknown version is a parse error");
        match err {
            ParseError::UnknownProtocolVersion { index, value } => {
                assert_eq!(index, 0);
                assert_eq!(value, "2099-12-31");
            }
            other => panic!("expected UnknownProtocolVersion, got {other:?}"),
        }
    }

    #[test]
    fn parser_rejects_non_sequence_target_versions() {
        let yaml: YamlValue = from_str(
            r#"
target_versions: "2026-07-28"
servers:
  fs:
    command: ["node", "./server.js"]
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("non-sequence shape must error");
        assert!(matches!(err, ParseError::BadShape { .. }), "got: {err:?}");
    }

    #[test]
    fn parser_rejects_non_string_target_version_entry() {
        let yaml: YamlValue = from_str(
            r#"
target_versions:
  - 20260728
servers:
  fs:
    command: ["node", "./server.js"]
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("non-string entry must error");
        assert!(matches!(err, ParseError::BadShape { .. }), "got: {err:?}");
    }

    #[test]
    fn parses_stdio_server_with_args_and_env() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
    env:
      LOG_LEVEL: warn
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let spec = &suite.servers["fs"];
        match spec {
            ServerSpec::Stdio { command, args, env } => {
                assert_eq!(command, "node");
                assert_eq!(args, &["./server.js".to_string()]);
                assert_eq!(env.get("LOG_LEVEL"), Some(&"warn".to_string()));
            }
            _ => panic!("expected stdio"),
        }
    }

    #[test]
    fn parses_streamable_http_with_default_transport() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://api.example.com/mcp"
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.servers["api"] {
            ServerSpec::StreamableHttp { url, .. } => {
                assert_eq!(url, "https://api.example.com/mcp");
            }
            _ => panic!("expected streamable-http"),
        }
    }

    #[test]
    fn parses_oauth_client_credentials_on_a_url_server() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://api.example.com/mcp"
    auth:
      oauth:
        grant: client_credentials
        token_endpoint: "https://auth.example.com/token"
        client_id: svc
        client_secret_env: SECRET_ENV
        scope: "a b"
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.servers["api"] {
            ServerSpec::StreamableHttp {
                oauth: Some(cc), ..
            } => {
                assert_eq!(cc.token_endpoint, "https://auth.example.com/token");
                assert_eq!(cc.client_id, "svc");
                assert_eq!(cc.client_secret_env, "SECRET_ENV");
                assert_eq!(cc.scope.as_deref(), Some("a b"));
            }
            other => panic!("expected oauth client-credentials, got {other:?}"),
        }
    }

    #[test]
    fn rejects_oauth_with_an_unsupported_grant() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://api.example.com/mcp"
    auth:
      oauth:
        grant: password
        token_endpoint: "https://auth.example.com/token"
        client_id: svc
        client_secret_env: SECRET_ENV
"#,
        )
        .unwrap();
        assert!(
            parse(&yaml).is_err(),
            "only client_credentials is supported"
        );
    }

    #[test]
    fn rejects_oauth_missing_a_required_field() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://api.example.com/mcp"
    auth:
      oauth:
        grant: client_credentials
        client_id: svc
"#,
        )
        .unwrap();
        assert!(
            parse(&yaml).is_err(),
            "token_endpoint + client_secret_env are required"
        );
    }

    #[test]
    fn server_inherits_the_suite_level_auth_default() {
        let yaml: YamlValue = from_str(
            r#"
auth:
  oauth:
    grant: client_credentials
    token_endpoint: "https://auth.example.com/token"
    client_id: svc
    client_secret_env: SECRET_ENV
servers:
  api:
    url: "https://api.example.com/mcp"
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.servers["api"] {
            ServerSpec::StreamableHttp {
                oauth: Some(cc), ..
            } => assert_eq!(cc.client_id, "svc"),
            other => panic!("expected inherited oauth, got {other:?}"),
        }
    }

    #[test]
    fn server_auth_overrides_the_suite_level_default() {
        let yaml: YamlValue = from_str(
            r#"
auth:
  oauth:
    grant: client_credentials
    token_endpoint: "https://auth.example.com/token"
    client_id: svc
    client_secret_env: SECRET_ENV
servers:
  api:
    url: "https://api.example.com/mcp"
    auth:
      bearer_token_env: API_TOKEN
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        // The server's own `auth:` replaces the default as a whole, so the
        // inherited oauth does not apply.
        match &suite.servers["api"] {
            ServerSpec::StreamableHttp { oauth, .. } => {
                assert!(oauth.is_none(), "server auth overrides the suite default");
            }
            other => panic!("expected streamable-http, got {other:?}"),
        }
    }

    #[test]
    fn parses_legacy_sse_when_transport_is_set() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://legacy.example.com/sse"
    transport: legacy-sse
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert!(matches!(
            &suite.servers["api"],
            ServerSpec::LegacySse { .. }
        ));
    }

    #[test]
    fn parses_stateless_http_when_transport_is_set() {
        // `transport: stateless-http` opts a server into the 2026-07-28
        // stateless streamable-HTTP transport (WOR-1383).
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://api.example.com/mcp"
    transport: stateless-http
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.servers["api"] {
            ServerSpec::StreamableHttp { url, stateless, .. } => {
                assert_eq!(url, "https://api.example.com/mcp");
                assert!(*stateless, "stateless-http must set the stateless flag");
            }
            other => panic!("expected stateless streamable-http, got {other:?}"),
        }
    }

    #[test]
    fn default_streamable_http_is_session_not_stateless() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  api:
    url: "https://api.example.com/mcp"
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.servers["api"] {
            ServerSpec::StreamableHttp { stateless, .. } => {
                assert!(!*stateless, "default transport must stay session-based");
            }
            other => panic!("expected streamable-http, got {other:?}"),
        }
    }

    #[test]
    fn parses_cassette_server_with_a_declared_refresh_target() {
        // WOR-1279: `command:` or `url:` next to `cassette:` declares the
        // live target `--update-cassettes` re-records from.
        let yaml: YamlValue = from_str(
            r#"
servers:
  recorded:
    cassette: ./api.json
    command: ["node", "server.js"]
  remote:
    cassette: ./remote.json
    url: "http://localhost:9000/mcp"
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("cassette with refresh target parses");
        match &suite.servers["recorded"] {
            ServerSpec::Cassette {
                path,
                refresh_command,
                refresh_url,
            } => {
                assert_eq!(path, "./api.json");
                assert_eq!(
                    refresh_command.as_deref(),
                    Some(["node".to_string(), "server.js".to_string()].as_slice())
                );
                assert!(refresh_url.is_none());
            }
            other => panic!("expected cassette, got {other:?}"),
        }
        match &suite.servers["remote"] {
            ServerSpec::Cassette { refresh_url, .. } => {
                assert_eq!(refresh_url.as_deref(), Some("http://localhost:9000/mcp"));
            }
            other => panic!("expected cassette, got {other:?}"),
        }
    }

    #[test]
    fn rejects_cassette_server_with_both_refresh_targets() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  bad:
    cassette: ./api.json
    command: ["a"]
    url: "http://b"
"#,
        )
        .unwrap();
        assert!(matches!(
            parse(&yaml),
            Err(ParseError::AmbiguousServer { .. })
        ));
    }

    #[test]
    fn rejects_server_with_both_command_and_url() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  bad:
    command: ["a"]
    url: "http://b"
"#,
        )
        .unwrap();
        assert!(matches!(
            parse(&yaml),
            Err(ParseError::AmbiguousServer { .. })
        ));
    }

    #[test]
    fn reference_in_stdio_args_is_a_parse_error() {
        // A `vault://` secret reference in argv would leak through
        // process listings, so it is rejected at parse time rather than
        // resolved.
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["server", "vault://kv/api#token"]
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::ReferenceInArgs { path, value }) => {
                assert_eq!(path, "/servers/fs/command/1");
                assert_eq!(value, "vault://kv/api#token");
            }
            other => panic!("expected ReferenceInArgs, got {other:?}"),
        }
    }

    #[test]
    fn reserved_url_scheme_in_args_is_allowed() {
        // A reserved-scheme value (here `file://`) is an ordinary path, not
        // a secret reference, so it stays in argv untouched.
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["server", "file:///workspace/data.db"]
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("a reserved-scheme arg must be allowed");
        match &suite.servers["fs"] {
            ServerSpec::Stdio { command, args, .. } => {
                assert_eq!(command, "server");
                assert_eq!(args, &["file:///workspace/data.db".to_string()]);
            }
            other => panic!("expected stdio, got {other:?}"),
        }
    }

    #[test]
    fn parses_tool_test_with_exact_matcher() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
tools:
  - name: echoes input
    server: fs
    tool: echo
    args:
      message: hi
    expect:
      - target: result.isError
        matcher:
          exact: false
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.tests.len(), 1);
        let t = &suite.tests[0];
        assert_eq!(t.name, "echoes input");
        match &t.action {
            Action::ToolCall { tool, args } => {
                assert_eq!(tool, "echo");
                assert_eq!(args, &serde_json::json!({"message": "hi"}));
            }
            _ => panic!("expected ToolCall"),
        }
        assert_eq!(t.expectations.len(), 1);
        assert_eq!(t.expectations[0].target, "result.isError");
        assert!(matches!(
            t.expectations[0].matcher,
            Matcher::Exact(Value::Bool(false))
        ));
    }

    // === WOR-1280: long-form `expect:` mapping ===

    #[test]
    fn long_form_expect_mapping_parses_assertions_and_budgets() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
tools:
  - name: budgeted echo
    server: fs
    tool: echo
    expect:
      assertions:
        - target: result.isError
          matcher:
            exact: false
      max_duration_ms: 500
      max_response_tokens: 200
      response_headers:
        content-type: application/json
      response_headers_absent: [set-cookie]
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let t = &suite.tests[0];
        // The WOR-1280 bug: the mapping form used to parse to ZERO
        // assertions, so the test passed vacuously. The assertions must
        // come through `assertions:` now.
        assert_eq!(t.expectations.len(), 1);
        assert_eq!(t.expectations[0].target, "result.isError");
        assert_eq!(t.budgets.max_duration_ms, Some(500));
        let tokens = t.budgets.max_response_tokens.as_ref().expect("budget");
        assert_eq!(tokens.budget, 200);
        assert_eq!(t.budgets.response_headers.len(), 1);
        assert_eq!(t.budgets.response_headers_absent, vec!["set-cookie"]);
    }

    #[test]
    fn long_form_expect_mapping_collects_assert_sets_too() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
tools:
  - name: budgeted set
    server: fs
    tool: echo
    expect:
      assertions:
        - assert-set:
            name: coverage
            threshold: 0.5
            assertions:
              - target: result
                matcher: { icontains: "a" }
              - target: result
                matcher: { icontains: "b" }
      max_duration_ms: 250
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let t = &suite.tests[0];
        assert!(t.expectations.is_empty());
        assert_eq!(t.assert_sets.len(), 1);
        assert_eq!(t.assert_sets[0].assertions.len(), 2);
        assert_eq!(t.budgets.max_duration_ms, Some(250));
    }

    #[test]
    fn short_form_expect_array_carries_no_budgets() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
tools:
  - name: plain echo
    server: fs
    tool: echo
    expect:
      - target: result.isError
        matcher:
          exact: false
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let t = &suite.tests[0];
        assert_eq!(t.expectations.len(), 1);
        assert!(t.budgets.is_empty());
    }

    #[test]
    fn parses_resource_and_prompt_tests() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs:
    command: ["node", "./server.js"]
resources:
  - name: reads readme
    server: fs
    uri: "file:///workspace/README.md"
    expect:
      - target: result.contents[0].mimeType
        matcher:
          contains: text
prompts:
  - name: bug triage
    server: fs
    prompt: bug_triage
    args:
      severity: high
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.tests.len(), 2);
        match &suite.tests[0].action {
            Action::ResourceRead { uri } => assert!(uri.contains("README")),
            _ => panic!("expected ResourceRead"),
        }
        match &suite.tests[1].action {
            Action::PromptGet { name, args } => {
                assert_eq!(name, "bug_triage");
                assert_eq!(args.as_ref().unwrap()["severity"], "high");
            }
            _ => panic!("expected PromptGet"),
        }
    }

    #[test]
    fn matcher_with_multiple_keys_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: bad
    server: fs
    tool: t
    expect:
      - target: x
        matcher:
          exact: 1
          contains: 1
"#,
        )
        .unwrap();
        assert!(matches!(parse(&yaml), Err(ParseError::MatcherShape { .. })));
    }

    #[test]
    fn unknown_matcher_rejected_at_parse_time() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: typo test
    server: fs
    tool: t
    expect:
      - target: x
        matcher:
          shema: { type: object }
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::UnknownMatcher { key, .. }) => assert_eq!(key, "shema"),
            other => panic!("expected UnknownMatcher, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_schema_matcher() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: schema test
    server: fs
    tool: t
    expect:
      - target: result.structuredContent
        matcher:
          schema:
            type: object
            required: [status]
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.tests[0].expectations[0].matcher {
            Matcher::Schema(value) => {
                assert_eq!(value["type"], serde_json::json!("object"));
                assert_eq!(value["required"], serde_json::json!(["status"]));
            }
            other => panic!("expected Schema, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_snapshot_matcher() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: snapshot test
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          snapshot: tools/echo/baseline
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.tests[0].expectations[0].matcher {
            Matcher::Snapshot(key) => assert_eq!(key, "tools/echo/baseline"),
            other => panic!("expected Snapshot, got {other:?}"),
        }
    }

    /// Parse a single tool test whose one expectation carries `matcher_yaml`
    /// (indented two spaces to sit under `matcher:`), returning that matcher.
    fn parse_single_matcher(matcher_yaml: &str) -> Matcher {
        let doc = format!(
            r#"
servers:
  fs: {{ command: ["a"] }}
tools:
  - name: t
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          {matcher_yaml}
"#
        );
        let yaml: YamlValue = from_str(&doc).unwrap();
        let suite = parse(&yaml).unwrap();
        suite.tests[0].expectations[0].matcher.clone()
    }

    #[test]
    fn parser_round_trips_contains_all_and_any() {
        match parse_single_matcher("contains-all: [foo, bar]") {
            Matcher::ContainsAll(v) => assert_eq!(v, serde_json::json!(["foo", "bar"])),
            other => panic!("expected ContainsAll, got {other:?}"),
        }
        match parse_single_matcher("contains-any: [foo, bar]") {
            Matcher::ContainsAny(v) => assert_eq!(v, serde_json::json!(["foo", "bar"])),
            other => panic!("expected ContainsAny, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_icontains_and_starts_with() {
        match parse_single_matcher("icontains: World") {
            Matcher::IContains(s) => assert_eq!(s, "World"),
            other => panic!("expected IContains, got {other:?}"),
        }
        match parse_single_matcher("starts-with: Hello") {
            Matcher::StartsWith(s) => assert_eq!(s, "Hello"),
            other => panic!("expected StartsWith, got {other:?}"),
        }
    }

    #[test]
    fn icontains_rejects_non_string_argument() {
        let doc = r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          icontains: [not, a, string]
"#;
        let yaml: YamlValue = from_str(doc).unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "icontains"),
            other => panic!("expected BadShape, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_is_json_with_optional_schema() {
        match parse_single_matcher("is-json: ~") {
            Matcher::IsJson(v) => assert!(v.is_null()),
            other => panic!("expected IsJson, got {other:?}"),
        }
        let with_schema = "is-json: { schema: { type: object } }";
        match parse_single_matcher(with_schema) {
            Matcher::IsJson(v) => assert_eq!(v["schema"]["type"], serde_json::json!("object")),
            other => panic!("expected IsJson, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_is_valid_tools_call() {
        match parse_single_matcher("is-valid-tools-call: ~") {
            Matcher::IsValidToolsCall(v) => assert!(v.is_null()),
            other => panic!("expected IsValidToolsCall, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_levenshtein() {
        match parse_single_matcher("levenshtein: { value: sitting, max: 3 }") {
            Matcher::Levenshtein(v) => {
                assert_eq!(v["value"], serde_json::json!("sitting"));
                assert_eq!(v["max"], serde_json::json!(3));
            }
            other => panic!("expected Levenshtein, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_is_xml_with_optional_root() {
        match parse_single_matcher("is-xml: ~") {
            Matcher::IsXml(v) => assert!(v.is_null()),
            other => panic!("expected IsXml, got {other:?}"),
        }
        match parse_single_matcher("is-xml: { root: note }") {
            Matcher::IsXml(v) => assert_eq!(v["root"], serde_json::json!("note")),
            other => panic!("expected IsXml, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_is_sql() {
        match parse_single_matcher("is-sql: ~") {
            Matcher::IsSql(v) => assert!(v.is_null()),
            other => panic!("expected IsSql, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_similar() {
        match parse_single_matcher(
            "similar: { value: hello, threshold: 0.8, model: text-embedding-3-small }",
        ) {
            Matcher::Similar(v) => {
                assert_eq!(v["value"], serde_json::json!("hello"));
                assert_eq!(v["model"], serde_json::json!("text-embedding-3-small"));
            }
            other => panic!("expected Similar, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_cel() {
        match parse_single_matcher("cel: \"value.size() > 0\"") {
            Matcher::Cel(program) => assert_eq!(program, "value.size() > 0"),
            other => panic!("expected Cel, got {other:?}"),
        }
    }

    #[test]
    fn named_graded_factuality_desugars_to_llm_judge() {
        // The named model-graded matchers are sugar over `llm-judge`: parsing
        // `factuality: { reference: ... }` yields an LlmJudge block whose rubric
        // embeds the reference, so the executor needs no new variant.
        match parse_single_matcher("factuality: { reference: \"Paris is the capital\" }") {
            Matcher::LlmJudge(v) => {
                let rubric = v["rubric"].as_str().expect("rubric string");
                assert!(rubric.contains("Paris is the capital"));
                assert_eq!(v["threshold"], serde_json::json!(0.7));
            }
            other => panic!("expected LlmJudge, got {other:?}"),
        }
    }

    #[test]
    fn named_graded_honors_threshold_and_model_overrides() {
        match parse_single_matcher(
            "answer-relevance: { query: \"q\", threshold: 0.9, model: gpt-4o }",
        ) {
            Matcher::LlmJudge(v) => {
                assert_eq!(v["threshold"], serde_json::json!(0.9));
                assert_eq!(v["model"], serde_json::json!("gpt-4o"));
            }
            other => panic!("expected LlmJudge, got {other:?}"),
        }
    }

    #[test]
    fn named_graded_missing_required_field_is_an_error() {
        let yaml: serde_yaml::Value =
            serde_yaml::from_str("context-faithfulness: { threshold: 0.8 }").unwrap();
        assert!(parse_matcher(&yaml, "expect[0]").is_err());
    }

    #[test]
    fn parser_round_trips_not_wrapping_an_inner_matcher() {
        match parse_single_matcher("not: { contains: hello }") {
            Matcher::Not(inner) => match *inner {
                Matcher::Contains(v) => assert_eq!(v, serde_json::json!("hello")),
                other => panic!("expected inner Contains, got {other:?}"),
            },
            other => panic!("expected Not, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_one_of_with_inner_matchers() {
        // `oneOf` is one of three composition wrappers.
        match parse_single_matcher("oneOf: [ { exact: 1 }, { exact: 2 } ]") {
            Matcher::OneOf(children) => {
                assert_eq!(children.len(), 2);
                assert!(matches!(children[0], Matcher::Exact(_)));
                assert!(matches!(children[1], Matcher::Exact(_)));
            }
            other => panic!("expected OneOf, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_any_of_with_a_single_child() {
        // anyOf with one child is legal (passes when that child passes).
        match parse_single_matcher("anyOf: [ { contains: a } ]") {
            Matcher::AnyOf(children) => {
                assert_eq!(children.len(), 1);
                assert!(matches!(children[0], Matcher::Contains(_)));
            }
            other => panic!("expected AnyOf, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_all_of_with_nested_not() {
        // Nesting works: allOf carries a not which carries a regex.
        match parse_single_matcher("allOf: [ { contains: ok }, { not: { regex: bad } } ]") {
            Matcher::AllOf(children) => {
                assert_eq!(children.len(), 2);
                assert!(matches!(children[1], Matcher::Not(_)));
            }
            other => panic!("expected AllOf, got {other:?}"),
        }
    }

    #[test]
    fn parser_rejects_empty_composition_body() {
        // An empty composition has no useful semantics; we reject it as a
        // shape error so the suite author catches the typo at load time.
        let doc = r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          oneOf: []
"#;
        let yaml: YamlValue = from_str(doc).unwrap();
        let err = parse(&yaml).expect_err("empty oneOf must be a shape error");
        assert!(matches!(err, ParseError::BadShape { .. }), "got: {err:?}");
    }

    #[test]
    fn parser_rejects_composition_with_non_sequence_body() {
        // The body must be a YAML sequence; an object is a shape error.
        let doc = r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          anyOf: { exact: 1 }
"#;
        let yaml: YamlValue = from_str(doc).unwrap();
        let err = parse(&yaml).expect_err("anyOf must hold a sequence");
        assert!(matches!(err, ParseError::BadShape { .. }), "got: {err:?}");
    }

    #[test]
    fn not_with_malformed_inner_matcher_is_rejected() {
        // The inner matcher must itself be a single-key object; two keys
        // trips the same MatcherShape check as a top-level matcher.
        let doc = r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          not: { contains: a, exact: b }
"#;
        let yaml: YamlValue = from_str(doc).unwrap();
        assert!(matches!(parse(&yaml), Err(ParseError::MatcherShape { .. })));
    }

    #[test]
    fn parses_agent_test_with_default_knobs() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: sacramento weather
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: What is the weather in Sacramento?
    expect:
      - target: tool_calls[0].name
        matcher:
          exact: get_weather
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.agents.len(), 1);
        let agent = &suite.agents[0];
        assert_eq!(agent.name, "sacramento weather");
        assert_eq!(
            agent.models,
            vec![ModelRef::AutoDetect("claude-sonnet-4-5".into())]
        );
        assert_eq!(agent.servers, vec!["weather".to_string()]);
        assert_eq!(agent.max_turns, 10);
        assert_eq!(agent.max_tokens, 1024);
        assert!(agent.system_prompt.is_none());
        assert_eq!(agent.expectations.len(), 1);
        assert_eq!(agent.expectations[0].target, "tool_calls[0].name");
    }

    #[test]
    fn parses_agent_test_with_system_prompt_and_budgets() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: weather call
    model: claude-sonnet-4-5
    servers: [weather]
    system_prompt: You are a weather assistant.
    prompt: What is the weather in Sacramento?
    max_turns: 4
    max_tokens: 256
budget:
  per_test_usd_cents: 50
  per_suite_usd_cents: 1000
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(
            suite.agents[0].system_prompt.as_deref(),
            Some("You are a weather assistant.")
        );
        assert_eq!(suite.agents[0].max_turns, 4);
        assert_eq!(suite.agents[0].max_tokens, 256);
        assert_eq!(suite.budget.per_test_usd_cents, Some(50));
        assert_eq!(suite.budget.per_suite_usd_cents, Some(1000));
    }

    #[test]
    fn agent_run_caps_default_to_unlimited() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: bare
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let agent = &suite.agents[0];
        assert_eq!(agent.token_budget, None);
        assert_eq!(agent.max_tool_calls, None);
        assert_eq!(agent.time_budget, None);
    }

    #[test]
    fn parses_agent_run_caps() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: capped
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    token_budget: 5000
    max_tool_calls: 3
    time_budget: 2m
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let agent = &suite.agents[0];
        assert_eq!(agent.token_budget, Some(5000));
        assert_eq!(agent.max_tool_calls, Some(3));
        assert_eq!(agent.time_budget, Some(std::time::Duration::from_secs(120)));
    }

    #[test]
    fn bad_time_budget_string_is_a_typed_parse_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: bad
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    time_budget: 5 fortnights
"#,
        )
        .unwrap();
        let err = parse(&yaml).unwrap_err();
        assert!(matches!(err, ParseError::BadDuration { field, .. } if field == "time_budget"));
    }

    #[test]
    fn parses_agent_test_with_models_matrix() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: model matrix
    models:
      - claude-sonnet-4-5
      - gpt-5
      - gemini-2.5-pro
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(
            suite.agents[0].models,
            vec![
                ModelRef::AutoDetect("claude-sonnet-4-5".into()),
                ModelRef::AutoDetect("gpt-5".into()),
                ModelRef::AutoDetect("gemini-2.5-pro".into()),
            ]
        );
    }

    #[test]
    fn parses_providers_block_and_named_model_refs() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
providers:
  openrouter:
    type: openai
    base_url: https://openrouter.ai/api/v1
    api_key_env: OPENROUTER_API_KEY
  local-vllm:
    type: openai
    base_url: http://localhost:8000/v1
agents:
  - name: named-provider matrix
    models:
      - claude-sonnet-4-5
      - { provider: openrouter, id: openai/gpt-4o }
      - { provider: local-vllm, id: meta-llama/Llama-3.1-70B }
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.providers.len(), 2);
        let or = suite.providers.get("openrouter").expect("openrouter spec");
        assert_eq!(or.kind, ProviderKind::OpenAi);
        assert_eq!(or.base_url, "https://openrouter.ai/api/v1");
        assert_eq!(or.api_key_env.as_deref(), Some("OPENROUTER_API_KEY"));
        let vllm = suite.providers.get("local-vllm").expect("vllm spec");
        assert!(vllm.api_key_env.is_none());
        assert_eq!(
            suite.agents[0].models,
            vec![
                ModelRef::AutoDetect("claude-sonnet-4-5".into()),
                ModelRef::Named {
                    provider: "openrouter".into(),
                    id: "openai/gpt-4o".into(),
                },
                ModelRef::Named {
                    provider: "local-vllm".into(),
                    id: "meta-llama/Llama-3.1-70B".into(),
                },
            ]
        );
    }

    #[test]
    fn named_model_ref_with_unknown_provider_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: unknown provider
    models:
      - { provider: missing, id: gpt-4o }
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "provider"),
            other => panic!("expected BadShape provider, got {other:?}"),
        }
    }

    #[test]
    fn provider_with_unsupported_type_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
providers:
  bogus:
    type: not-a-real-protocol
    base_url: https://example.com
agents:
  - name: x
    models: [claude-3]
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "type"),
            other => panic!("expected BadShape type, got {other:?}"),
        }
    }

    #[test]
    fn agent_test_with_empty_models_list_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: empty matrix
    models: []
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "models"),
            other => panic!("expected BadShape on models, got {other:?}"),
        }
    }

    #[test]
    fn agent_test_without_model_or_models_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: missing model
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::Missing { field, .. }) => assert_eq!(field, "model"),
            other => panic!("expected Missing model, got {other:?}"),
        }
    }

    #[test]
    fn agent_test_without_servers_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: bad
    model: claude-sonnet-4-5
    prompt: hi
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::Missing { field, .. }) => assert_eq!(field, "servers"),
            other => panic!("expected Missing servers, got {other:?}"),
        }
    }

    #[test]
    fn agent_runs_defaults_to_one_and_tool_selection_is_none() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: single run
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.agents[0].runs, 1);
        assert!(suite.agents[0].tool_selection.is_none());
        assert!(suite.agents[0].eval.is_empty());
    }

    #[test]
    fn parses_eval_rubric_on_an_agent() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      rubric:
        threshold: 0.8
        criteria:
          - name: got the weather
            weight: 2
            description: "The reply states a temperature."
          - name: was polite
            description: "The reply is courteous."
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.agents[0].eval.len(), 1);
        match &suite.agents[0].eval[0] {
            crate::suite::EvalMetric::Rubric(cfg) => {
                assert_eq!(cfg.criteria.len(), 2);
                assert!((cfg.threshold - 0.8).abs() < 1e-9);
                assert!(!cfg.strict);
                assert_eq!(cfg.criteria[0].name, "got the weather");
                assert!((cfg.criteria[0].weight - 2.0).abs() < 1e-9);
                assert!((cfg.criteria[1].weight - 1.0).abs() < 1e-9); // default
            }
            other => panic!("expected a rubric metric, got {other:?}"),
        }
    }

    #[test]
    fn parses_eval_rubric_decision_tree() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      rubric:
        threshold: 0.7
        tree:
          ask: "did the agent call a weather tool?"
          yes:
            ask: "did it report a temperature?"
            yes: { score: 1.0, reason: "tool called and temperature reported" }
            no: { score: 0.4, reason: "tool called but no temperature" }
          no: { score: 0.0, reason: "no tool call" }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.agents[0].eval.len(), 1);
        match &suite.agents[0].eval[0] {
            crate::suite::EvalMetric::Rubric(cfg) => {
                assert!(cfg.criteria.is_empty());
                let tree = cfg.tree.as_ref().expect("tree present");
                match tree {
                    crate::eval::rubric::RubricNode::Ask { question, no, .. } => {
                        assert_eq!(question, "did the agent call a weather tool?");
                        assert!(matches!(
                            no.as_ref(),
                            crate::eval::rubric::RubricNode::Leaf { score, .. } if *score == 0.0
                        ));
                    }
                    other => panic!("expected an Ask root, got {other:?}"),
                }
            }
            other => panic!("expected a rubric metric, got {other:?}"),
        }
    }

    #[test]
    fn rejects_eval_rubric_tree_node_without_ask_or_score() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      rubric:
        tree:
          reason: "this node has neither ask nor score"
"#,
        )
        .unwrap();
        assert!(
            parse(&yaml).is_err(),
            "a tree node must be an `ask` branch or a `score` leaf"
        );
    }

    #[test]
    fn rejects_eval_rubric_without_criteria() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      rubric:
        threshold: 0.8
"#,
        )
        .unwrap();
        assert!(
            parse(&yaml).is_err(),
            "a rubric needs at least one criterion"
        );
    }

    #[test]
    fn parses_mcp_task_completion_and_mcp_use_metrics() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      mcp_task_completion: { threshold: 0.6 }
      mcp_use: {}
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let metrics = &suite.agents[0].eval;
        assert_eq!(metrics.len(), 2);
        let has_tc = metrics.iter().any(|m| {
            matches!(m, crate::suite::EvalMetric::TaskCompletion { threshold, .. } if (*threshold - 0.6).abs() < 1e-9)
        });
        let has_mcp_use = metrics.iter().any(|m| {
            matches!(m, crate::suite::EvalMetric::McpUse { threshold, .. } if (*threshold - 0.5).abs() < 1e-9)
        });
        assert!(has_tc, "mcp_task_completion parsed with its threshold");
        assert!(has_mcp_use, "mcp_use parsed with the default 0.5 threshold");
    }

    #[test]
    fn parses_argument_correctness_and_plan_quality_metrics() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      argument_correctness: { threshold: 0.9 }
      plan_quality: {}
"#,
        )
        .unwrap();
        let metrics = &parse(&yaml).unwrap().agents[0].eval;
        assert_eq!(metrics.len(), 2);
        assert!(metrics.iter().any(|m| matches!(
            m,
            crate::suite::EvalMetric::ArgumentCorrectness { threshold, .. } if (*threshold - 0.9).abs() < 1e-9
        )));
        assert!(metrics.iter().any(|m| matches!(
            m,
            crate::suite::EvalMetric::PlanQuality { threshold, .. } if (*threshold - 0.5).abs() < 1e-9
        )));
    }

    #[test]
    fn parses_scripted_turns_without_a_prompt() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  flights: { command: ["./flights-server"] }
agents:
  - name: multi-step
    model: claude-sonnet-4-5
    servers: [flights]
    turns:
      - user: "Find a flight SFO to JFK next Friday"
      - user: "Now book a hotel near JFK"
    eval:
      multi_turn_mcp_use: { threshold: 0.6 }
"#,
        )
        .unwrap();
        let agent = &parse(&yaml).unwrap().agents[0];
        match agent.turns.as_ref().expect("turns present") {
            crate::suite::AgentTurns::Scripted(turns) => {
                assert_eq!(turns.len(), 2);
                assert_eq!(turns[0], "Find a flight SFO to JFK next Friday");
                assert_eq!(turns[1], "Now book a hotel near JFK");
            }
            other => panic!("expected scripted turns, got {other:?}"),
        }
        assert!(agent.eval.iter().any(|m| matches!(
            m,
            crate::suite::EvalMetric::MultiTurnMcpUse { threshold, .. } if (*threshold - 0.6).abs() < 1e-9
        )));
    }

    #[test]
    fn parses_simulated_user_turns() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  flights: { command: ["./flights-server"] }
agents:
  - name: simulated
    model: claude-sonnet-4-5
    servers: [flights]
    turns:
      simulate:
        goal: "Book a same-day round trip"
        persona: "terse traveler"
        max_turns: 5
"#,
        )
        .unwrap();
        let agent = &parse(&yaml).unwrap().agents[0];
        match agent.turns.as_ref().expect("turns present") {
            crate::suite::AgentTurns::Simulated(sim) => {
                assert_eq!(sim.goal, "Book a same-day round trip");
                assert_eq!(sim.persona.as_deref(), Some("terse traveler"));
                assert_eq!(sim.max_turns, 5);
            }
            other => panic!("expected simulated turns, got {other:?}"),
        }
    }

    #[test]
    fn rejects_agent_with_neither_prompt_nor_turns() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  flights: { command: ["./flights-server"] }
agents:
  - name: empty
    model: claude-sonnet-4-5
    servers: [flights]
"#,
        )
        .unwrap();
        assert!(
            parse(&yaml).is_err(),
            "an agent needs a prompt or a turns block"
        );
    }

    #[test]
    fn rejects_empty_scripted_turns() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  flights: { command: ["./flights-server"] }
agents:
  - name: empty turns
    model: claude-sonnet-4-5
    servers: [flights]
    turns: []
"#,
        )
        .unwrap();
        assert!(parse(&yaml).is_err(), "an empty turns list is rejected");
    }

    #[test]
    fn rejects_unknown_eval_metric() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: graded
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    eval:
      not_a_metric: {}
"#,
        )
        .unwrap();
        assert!(
            parse(&yaml).is_err(),
            "unknown eval metric must be rejected"
        );
    }

    #[test]
    fn agent_parses_runs_and_tool_selection_floor() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: pass-k run
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: weather in Sacramento?
    runs: 5
    tool_selection:
      expected_tool: get_weather
      min_selection_rate: 0.8
      max_total_tokens: 2000
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.agents[0].runs, 5);
        let floor = suite.agents[0]
            .tool_selection
            .as_ref()
            .expect("tool_selection parsed");
        assert_eq!(floor.expected_tool, "get_weather");
        assert!((floor.min_selection_rate - 0.8).abs() < f64::EPSILON);
        assert_eq!(floor.max_total_tokens, Some(2000));
    }

    #[test]
    fn agent_runs_zero_is_clamped_to_one() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: clamp
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    runs: 0
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.agents[0].runs, 1);
    }

    #[test]
    fn tool_selection_without_min_rate_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: bad floor
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    tool_selection:
      expected_tool: get_weather
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::Missing { field, .. }) => assert_eq!(field, "min_selection_rate"),
            other => panic!("expected Missing min_selection_rate, got {other:?}"),
        }
    }

    #[test]
    fn tool_selection_omits_token_cap_when_absent() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: no token cap
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    tool_selection:
      expected_tool: get_weather
      min_selection_rate: 1.0
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let floor = suite.agents[0].tool_selection.as_ref().unwrap();
        assert!(floor.max_total_tokens.is_none());
    }

    // Within-session stability gate.

    #[test]
    fn agent_without_stability_gate_leaves_it_none() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: plain
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    runs: 3
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert!(suite.agents[0].stability.is_none());
    }

    #[test]
    fn stability_gate_with_empty_expect_parses_to_an_empty_assertion_list() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: stable run
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    runs: 3
    stability: {}
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let gate = suite.agents[0]
            .stability
            .as_ref()
            .expect("stability gate parsed");
        // An empty block carries no explicit assertions; the CLI applies the
        // default gate at run time.
        assert!(gate.expect.is_empty());
    }

    #[test]
    fn stability_gate_parses_explicit_target_assertions() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: stable run
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    runs: 5
    stability:
      expect:
        - target: stability.weakest_score
          matcher: { schema: { minimum: 0.8 } }
        - target: stability.variance
          matcher: { schema: { maximum: 0.05 } }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let gate = suite.agents[0].stability.as_ref().unwrap();
        let targets: Vec<&str> = gate.expect.iter().map(|a| a.target.as_str()).collect();
        assert_eq!(
            targets,
            vec!["stability.weakest_score", "stability.variance"]
        );
    }

    #[test]
    fn stability_gate_on_a_single_run_test_is_a_load_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: one shot
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
    stability: {}
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::StabilityGateArity { test, runs }) => {
                assert_eq!(test, "one shot");
                assert_eq!(runs, 1);
            }
            other => panic!("expected StabilityGateArity, got {other:?}"),
        }
    }

    // Matrix-level comparative assertions.

    #[test]
    fn agent_without_matrix_assert_leaves_it_none() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: plain
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: hi
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert!(suite.agents[0].matrix_assert.is_none());
    }

    #[test]
    fn matrix_assert_parses_max_score_and_select_best() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: matrix
    models: [claude-sonnet-4-5, gpt-4o-mini]
    servers: [weather]
    prompt: hi
    matrix_assert:
      max_score: 0.8
      select_best: true
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let m = suite.agents[0]
            .matrix_assert
            .as_ref()
            .expect("matrix_assert parsed");
        assert_eq!(m.max_score, Some(0.8));
        assert!(m.select_best);
        assert!(m.wants_score());
    }

    #[test]
    fn matrix_assert_select_best_only_omits_the_floor() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: report only
    models: [claude-sonnet-4-5, gpt-4o-mini]
    servers: [weather]
    prompt: hi
    matrix_assert:
      select_best: true
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let m = suite.agents[0].matrix_assert.as_ref().unwrap();
        assert!(m.max_score.is_none());
        assert!(m.select_best);
    }

    #[test]
    fn empty_matrix_assert_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: empty block
    models: [claude-sonnet-4-5]
    servers: [weather]
    prompt: hi
    matrix_assert: {}
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "matrix_assert"),
            other => panic!("expected BadShape matrix_assert, got {other:?}"),
        }
    }

    #[test]
    fn matrix_assert_out_of_range_max_score_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  weather: { command: ["./weather-server"] }
agents:
  - name: bad score
    models: [claude-sonnet-4-5]
    servers: [weather]
    prompt: hi
    matrix_assert:
      max_score: 5
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "max_score"),
            other => panic!("expected BadShape max_score, got {other:?}"),
        }
    }

    #[test]
    fn parser_round_trips_llm_judge_matcher() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: llm judge test
    server: fs
    tool: t
    expect:
      - target: result.content[0].text
        matcher:
          llm-judge:
            rubric: "Is the answer concise and accurate?"
            pass_threshold: 0.8
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        match &suite.tests[0].expectations[0].matcher {
            Matcher::LlmJudge(value) => {
                assert_eq!(
                    value["rubric"],
                    serde_json::json!("Is the answer concise and accurate?")
                );
                assert_eq!(value["pass_threshold"], serde_json::json!(0.8));
            }
            other => panic!("expected LlmJudge, got {other:?}"),
        }
    }

    // Suite-composition primitives.

    #[test]
    fn assertion_weight_and_name_default_then_parse() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: weights
    server: fs
    tool: t
    expect:
      - target: result.a
        matcher: { exact: 1 }
      - target: result.b
        matcher: { exact: 2 }
        weight: 3.0
        name: heavy
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let exps = &suite.tests[0].expectations;
        assert_eq!(exps[0].weight, 1.0);
        assert!(exps[0].name.is_none());
        assert_eq!(exps[1].weight, 3.0);
        assert_eq!(exps[1].name.as_deref(), Some("heavy"));
    }

    #[test]
    fn zero_weight_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: bad weight
    server: fs
    tool: t
    expect:
      - target: result.a
        matcher: { exact: 1 }
        weight: 0
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "weight"),
            other => panic!("expected BadShape weight, got {other:?}"),
        }
    }

    #[test]
    fn test_threshold_parses_and_out_of_range_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: scored
    server: fs
    tool: t
    threshold: 0.8
    expect:
      - target: result.a
        matcher: { exact: 1 }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(suite.tests[0].threshold, Some(0.8));

        let bad: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: scored
    server: fs
    tool: t
    threshold: 5
    expect:
      - target: result.a
        matcher: { exact: 1 }
"#,
        )
        .unwrap();
        match parse(&bad) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "threshold"),
            other => panic!("expected BadShape threshold, got {other:?}"),
        }
    }

    #[test]
    fn assert_set_parses_with_threshold_weight_and_nested_assertions() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: bundle
    server: fs
    tool: t
    expect:
      - target: result.x
        matcher: { exact: 1 }
      - assert-set:
          name: strings
          threshold: 0.5
          weight: 2.0
          assertions:
            - target: result.a
              matcher: { contains: foo }
            - target: result.b
              matcher: { contains: bar }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let test = &suite.tests[0];
        // The plain assertion stays in expectations; the assert-set is lifted out.
        assert_eq!(test.expectations.len(), 1);
        assert_eq!(test.assert_sets.len(), 1);
        let set = &test.assert_sets[0];
        assert_eq!(set.name.as_deref(), Some("strings"));
        assert_eq!(set.threshold, 0.5);
        assert_eq!(set.weight, 2.0);
        assert_eq!(set.assertions.len(), 2);
    }

    #[test]
    fn assert_set_without_threshold_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: bad set
    server: fs
    tool: t
    expect:
      - assert-set:
          assertions:
            - target: result.a
              matcher: { exact: 1 }
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::Missing { field, .. }) => assert_eq!(field, "threshold"),
            other => panic!("expected Missing threshold, got {other:?}"),
        }
    }

    #[test]
    fn default_test_merges_assertions_and_threshold_with_override() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
defaultTest:
  threshold: 0.5
  expect:
    - target: result.isError
      matcher: { exact: false }
tools:
  - name: inherits default
    server: fs
    tool: t
    expect:
      - target: result.a
        matcher: { exact: 1 }
  - name: overrides threshold
    server: fs
    tool: t
    threshold: 0.9
    expect:
      - target: result.b
        matcher: { exact: 2 }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        // The baseline assertion is appended after the test's own.
        let inherits = &suite.tests[0];
        assert_eq!(inherits.expectations.len(), 2);
        assert_eq!(inherits.expectations[0].target, "result.a");
        assert_eq!(inherits.expectations[1].target, "result.isError");
        // The test with no threshold inherits the default 0.5.
        assert_eq!(inherits.threshold, Some(0.5));
        // The test with its own threshold keeps it (override wins).
        assert_eq!(suite.tests[1].threshold, Some(0.9));
        assert_eq!(suite.tests[1].expectations.len(), 2);
    }

    #[test]
    fn derived_metrics_parse_into_weighted_aggregation() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: with metric
    server: fs
    tool: t
    threshold: 0.5
    expect:
      - target: result.a
        matcher: { exact: 1 }
        name: a
      - target: result.b
        matcher: { exact: 2 }
        name: b
    derivedMetrics:
      - name: blended
        threshold: 0.7
        value:
          weighted_average:
            - ref: a
              weight: 2.0
            - ref: b
              weight: 1.0
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let metrics = &suite.tests[0].derived_metrics;
        assert_eq!(metrics.len(), 1);
        assert_eq!(metrics[0].name, "blended");
        assert_eq!(metrics[0].threshold, Some(0.7));
        assert_eq!(metrics[0].value.kind, DerivedKind::WeightedAverage);
        assert_eq!(metrics[0].value.terms.len(), 2);
        assert_eq!(metrics[0].value.terms[0].reference, "a");
        assert_eq!(metrics[0].value.terms[0].weight, 2.0);
    }

    #[test]
    fn hooks_parse_scalar_and_list_forms() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
hooks:
  beforeAll: ./setup-db.sh
  afterAll:
    - ./teardown-db.sh
    - ./report.sh
  beforeEach: ./reset-session.sh
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(
            suite.hooks.before_all,
            vec![HookStep::Plain("./setup-db.sh".to_string())]
        );
        assert_eq!(
            suite.hooks.after_all,
            vec![
                HookStep::Plain("./teardown-db.sh".to_string()),
                HookStep::Plain("./report.sh".to_string())
            ]
        );
        assert_eq!(
            suite.hooks.before_each,
            vec![HookStep::Plain("./reset-session.sh".to_string())]
        );
        assert!(suite.hooks.after_each.is_empty());
        assert!(!suite.hooks.is_empty());
    }

    // Context-aware hook steps opt in with `mode: context`.

    #[test]
    fn bare_string_hook_parses_as_plain() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
hooks:
  beforeEach:
    - echo plain-still-works
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(
            suite.hooks.before_each,
            vec![HookStep::Plain("echo plain-still-works".to_string())]
        );
    }

    #[test]
    fn map_hook_with_mode_context_parses_as_context() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
hooks:
  beforeEach:
    - echo plain-still-works
    - command: node ./compute-vars.js
      mode: context
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert_eq!(
            suite.hooks.before_each,
            vec![
                HookStep::Plain("echo plain-still-works".to_string()),
                HookStep::Context {
                    command: TransformCommand {
                        program: "node".to_string(),
                        args: vec!["./compute-vars.js".to_string()],
                    },
                },
            ]
        );
    }

    #[test]
    fn map_hook_missing_mode_context_is_an_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
hooks:
  beforeEach:
    - command: node ./compute-vars.js
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("a map hook without mode: context must fail");
        let msg = err.to_string();
        assert!(msg.contains("mode: context"), "{msg}");
    }

    // Transform config.

    #[test]
    fn tool_test_parses_request_and_response_transform() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: normalized echo
    server: fs
    tool: echo
    transform:
      request: node ./transforms/wrap.js
      response: jq '.result.id = "fixed"'
    expect:
      - target: result.id
        matcher: { exact: fixed }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let spec = suite.tests[0].transform.as_ref().expect("transform parsed");
        let req = command_of(spec.request.as_ref().expect("request command"));
        assert_eq!(req.program, "node");
        assert_eq!(req.args, vec!["./transforms/wrap.js".to_string()]);
        let resp = command_of(spec.response.as_ref().expect("response command"));
        assert_eq!(resp.program, "jq");
        // shell-words keeps the quoted filter as one argument.
        assert_eq!(resp.args, vec![".result.id = \"fixed\"".to_string()]);
    }

    // Per-assertion transform.

    #[test]
    fn assertion_parses_per_assertion_transform_command() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: normalized assertion
    server: fs
    tool: echo
    expect:
      - target: result.name
        transform: jq ascii_downcase
        matcher: { exact: echo }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let assertion = &suite.tests[0].expectations[0];
        let cmd = assertion
            .transform
            .as_ref()
            .expect("assertion transform parsed");
        assert_eq!(cmd.program, "jq");
        assert_eq!(cmd.args, vec!["ascii_downcase".to_string()]);
    }

    #[test]
    fn assertion_without_transform_keeps_none() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: plain assertion
    server: fs
    tool: echo
    expect:
      - target: result.name
        matcher: { exact: echo }
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert!(suite.tests[0].expectations[0].transform.is_none());
    }

    #[test]
    fn unterminated_assertion_transform_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    expect:
      - target: result.name
        transform: "node \"unterminated"
        matcher: { exact: x }
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "transform"),
            other => panic!("expected BadShape transform, got {other:?}"),
        }
    }

    #[test]
    fn empty_transform_block_is_none() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    transform: {}
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert!(suite.tests[0].transform.is_none());
    }

    #[test]
    fn unterminated_transform_command_is_rejected() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
    transform:
      request: "node \"unterminated"
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "request"),
            other => panic!("expected BadShape request, got {other:?}"),
        }
    }

    // Inline jq transform form.

    #[test]
    fn tool_test_parses_inline_jq_transform() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: unwrap content
    server: fs
    tool: echo
    transform:
      response:
        jq: '.result.content[0].text'
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let spec = suite.tests[0].transform.as_ref().expect("transform parsed");
        assert!(spec.request.is_none());
        match spec.response.as_ref().expect("response step") {
            TransformStep::Jq(program) => assert_eq!(program, ".result.content[0].text"),
            other => panic!("expected an inline jq step, got {other:?}"),
        }
    }

    #[test]
    fn inline_jq_compile_error_is_a_load_time_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: broken jq
    server: fs
    tool: echo
    transform:
      response:
        jq: '.result.content['
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadJq { field, .. }) => assert_eq!(field, "response"),
            other => panic!("expected BadJq response, got {other:?}"),
        }
    }

    #[test]
    fn bare_string_transform_still_parses_as_a_command() {
        // Regression: the unchanged subprocess form must keep splitting into
        // program plus argv, not be mistaken for the inline jq form.
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: subprocess transform
    server: fs
    tool: echo
    transform:
      request: node ./wrap.js
      response: jq '.result'
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        let spec = suite.tests[0].transform.as_ref().expect("transform parsed");
        let req = command_of(spec.request.as_ref().expect("request command"));
        assert_eq!(req.program, "node");
        assert_eq!(req.args, vec!["./wrap.js".to_string()]);
        let resp = command_of(spec.response.as_ref().expect("response command"));
        assert_eq!(resp.program, "jq");
        assert_eq!(resp.args, vec![".result".to_string()]);
    }

    #[test]
    fn default_test_transform_merges_with_per_test_override() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
defaultTest:
  transform:
    response: jq '.'
tools:
  - name: inherits default transform
    server: fs
    tool: t
  - name: overrides transform
    server: fs
    tool: t
    transform:
      request: node ./wrap.js
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        // The test without its own transform inherits the default response.
        let inherited = suite.tests[0].transform.as_ref().expect("inherited");
        assert!(inherited.request.is_none());
        assert_eq!(
            command_of(inherited.response.as_ref().unwrap()).program,
            "jq"
        );
        // The test with its own transform overrides wholesale: it keeps only
        // its request and does NOT field-merge the default's response.
        let overridden = suite.tests[1].transform.as_ref().expect("own");
        assert_eq!(
            command_of(overridden.request.as_ref().unwrap()).program,
            "node"
        );
        assert!(overridden.response.is_none());
    }

    #[test]
    fn no_hooks_block_yields_empty_hooks() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: t
    server: fs
    tool: t
"#,
        )
        .unwrap();
        let suite = parse(&yaml).unwrap();
        assert!(suite.hooks.is_empty());
        assert!(suite.default_test.is_none());
    }

    // A `jury.*` target requires exactly one `llm-jury` assertion in
    // the same test.

    #[test]
    fn jury_target_with_one_jury_assertion_parses() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: jury-ok
    server: fs
    tool: t
    expect:
      - target: result.content[0].text
        matcher:
          llm-jury:
            rubric: grade
            jurors:
              - { model: a }
              - { model: b }
      - target: jury.agreement
        matcher: { schema: { minimum: 0.7 } }
"#,
        )
        .unwrap();
        parse(&yaml).expect("one jury plus a jury target parses");
    }

    #[test]
    fn jury_target_without_any_jury_is_a_load_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: jury-missing
    server: fs
    tool: t
    expect:
      - target: jury.agreement
        matcher: { schema: { minimum: 0.7 } }
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::JuryTargetArity { test, jury_count }) => {
                assert_eq!(test, "jury-missing");
                assert_eq!(jury_count, 0);
            }
            other => panic!("expected JuryTargetArity, got {other:?}"),
        }
    }

    #[test]
    fn jury_target_with_two_juries_is_a_load_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: jury-double
    server: fs
    tool: t
    expect:
      - target: result.content[0].text
        matcher:
          llm-jury:
            rubric: grade
            jurors: [{ model: a }]
      - target: result.content[0].text
        matcher:
          llm-jury:
            rubric: grade
            jurors: [{ model: b }]
      - target: jury.confidence
        matcher: { not: { exact: low } }
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::JuryTargetArity { jury_count, .. }) => assert_eq!(jury_count, 2),
            other => panic!("expected JuryTargetArity, got {other:?}"),
        }
    }

    #[test]
    fn parses_faults_block_inject_and_recovery_gate() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  faulty: { command: ["./server"] }
faults:
  - name: hung-search
    target: { tool: search }
    kind: hang
  - name: flaky-fetch
    target: { server: faulty }
    kind: recover_after
    failures: 2
agents:
  - name: recovers from hung search
    model: claude-sonnet-4-5
    servers: [faulty]
    inject: [hung-search]
    prompt: Search for the latest incident report.
    recovery:
      max_detection_ms: 3000
      max_recovery_ms: 5000
      require_clean_timeout: true
"#,
        )
        .unwrap();
        let suite = parse(&yaml).expect("parse");
        assert_eq!(suite.faults.len(), 2);
        assert_eq!(suite.faults[0].name, "hung-search");
        assert_eq!(suite.faults[0].kind, FaultKind::Hang);
        assert_eq!(
            suite.faults[1].kind,
            FaultKind::RecoverAfter { failures: 2 }
        );
        let agent = &suite.agents[0];
        assert_eq!(agent.inject, vec!["hung-search".to_string()]);
        let gate = agent.recovery.expect("recovery gate parsed");
        assert_eq!(gate.max_detection_ms, 3000);
        assert_eq!(gate.max_recovery_ms, Some(5000));
        assert!(gate.require_clean_timeout);
    }

    #[test]
    fn inject_referencing_unknown_fault_is_a_load_error() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  faulty: { command: ["./server"] }
agents:
  - name: bad inject
    model: claude-sonnet-4-5
    servers: [faulty]
    inject: [no-such-fault]
    prompt: go
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::UnknownFault { name, .. }) => assert_eq!(name, "no-such-fault"),
            other => panic!("expected UnknownFault, got {other:?}"),
        }
    }

    #[test]
    fn slow_fault_requires_a_delay() {
        let yaml: YamlValue = from_str(
            r#"
servers:
  faulty: { command: ["./server"] }
faults:
  - name: s
    target: { tool: search }
    kind: slow
agents:
  - name: a
    model: claude-sonnet-4-5
    servers: [faulty]
    prompt: go
"#,
        )
        .unwrap();
        match parse(&yaml) {
            Err(ParseError::BadShape { field, .. }) => assert_eq!(field, "delay_ms"),
            other => panic!("expected BadShape for delay_ms, got {other:?}"),
        }
    }
}
