avatarr-parser 0.1.0

Release-name parser ported from Sonarr v4.0.17.2952
Documentation
//! Language fixture harness. Runs every input from
//! `tests/sonarr-fixtures/languages.json` (168 cases) through
//! `avatarr_parser::parse_languages` and records the pass rate.
//!
//! T13 of the m50 Sonarr parser port. The language analog of T9
//! (`quality_fixtures.rs`). Comparator design ported from T9 with one
//! material difference: C# language assertions come in two flavours,
//! `Should().Contain(Language.X)` (set-inclusion) and
//! `Should().BeEquivalentTo(new[] { ... })` (set-equality). The sidecar
//! tags each input with `kind: "contains" | "equivalent"` so the
//! comparator can apply the matching semantics. See the
//! `_kind_semantics` field in `languages_expectations.json`.
//!
//! ## Fixture-format note
//!
//! The vendored `languages.json` was extracted by `extract.py` from
//! Sonarr's `[TestCase("input")]` attributes only. The Language list and
//! assertion kind live in the host test method (e.g.
//! `should_parse_language_german` calls
//! `LanguageParser.ParseLanguages(title).Should().Contain(Language.German)`),
//! not in the attribute args. To recover the `input -> (kind, Vec<Language>)`
//! mapping the comparator needs, this test reads a companion
//! `tests/sonarr-fixtures/languages_expectations.json` (co-located with
//! the input fixture, vendored from the same pinned Sonarr SHA),
//! hand-extracted by walking each test method body in
//! `LanguageParserFixture.cs`.
//!
//! Cases tested only by `ParseSubtitleLanguage` /
//! `ParseSubtitleLanguageInformation` host methods (12 + 17 + 4 = 33
//! `[TestCase]` rows in C#) are intentionally absent from the map.
//! They assert on the subtitle helpers, not on `ParseLanguages`, so
//! they route to `Undecidable` and are excluded from the pass-rate
//! denominator. (Some inputs appear in BOTH a subtitle method AND a
//! `ParseLanguages` method; those land in the map and route to
//! Pass/Fail per the `ParseLanguages` expectation.)
//!
//! ## Amendment 3a applicability
//!
//! Amendment 3a ("resolution-less Quality tokens are Undecidable") was
//! Quality-specific: bespoke parser-baseline can't disambiguate
//! resolution-less Sonarr Quality variants from one another. Languages
//! have no equivalent structural ambiguity. `Language::Unknown` is a
//! real expected value (the C# `should_parse_language_unknown` host
//! method asserts the `parse_languages` output `Contain(Language.Unknown)`
//! when no language is detected). So this harness does NOT route any
//! Language-side variant to Undecidable based on its identity; only
//! unmapped inputs (no host-method assertion to compare against) are
//! Undecidable.
//!
//! The test always passes; its purpose is to record the pass rate via
//! stderr for `docs/research/m50-port-baseline.md`. m51 owns the >=70%
//! gate.

use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;

use avatarr_parser::Language;

#[derive(Deserialize)]
struct Fixture {
    cases: Vec<Case>,
}

#[derive(Deserialize)]
struct Case {
    input: String,
}

#[derive(Deserialize)]
struct Expectations {
    mappings: HashMap<String, ExpectedEntry>,
}

#[derive(Deserialize)]
struct ExpectedEntry {
    kind: String,
    languages: Vec<String>,
}

enum Outcome {
    Pass,
    Fail,
    Undecidable,
}

#[test]
fn language_fixtures_pass_rate_recorded() {
    let fixture_path =
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/sonarr-fixtures/languages.json");
    let raw = std::fs::read_to_string(&fixture_path)
        .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
    let fx: Fixture = serde_json::from_str(&raw).expect("parse languages.json");

    // Companion mapping derived from the C# host methods (see module-level docs).
    let expectations: Expectations = serde_json::from_str(include_str!(
        "../../../tests/sonarr-fixtures/languages_expectations.json"
    ))
    .expect("parse languages_expectations.json");

    let mut pass = 0u32;
    let mut fail = 0u32;
    let mut undecidable = 0u32;
    let total = fx.cases.len() as u32;

    // Set `AVATARR_DEBUG_LANGUAGE_FAILURES=1` to print each FAIL with its
    // expected vs. parsed Languages. Useful when investigating the m51
    // gate or a regression. Off by default so the test is quiet on green runs.
    let debug_failures = std::env::var("AVATARR_DEBUG_LANGUAGE_FAILURES").is_ok();

    for case in &fx.cases {
        let actual = avatarr_parser::parse_languages(&case.input);
        match compare_against_expected(&case.input, &actual, &expectations) {
            Outcome::Pass => pass += 1,
            Outcome::Undecidable => undecidable += 1,
            Outcome::Fail => {
                fail += 1;
                if debug_failures {
                    let expected = expectations.mappings.get(&case.input);
                    eprintln!(
                        "  FAIL: input={:?} expected={:?} got={:?}",
                        case.input,
                        expected.map(|e| (e.kind.as_str(), &e.languages)),
                        actual,
                    );
                }
            }
        }
    }

    let denominator = total - undecidable;
    let pct = if denominator == 0 {
        0.0
    } else {
        100.0 * pass as f64 / denominator as f64
    };
    eprintln!(
        "language_fixtures: {pass}/{denominator} pass ({pct:.1}%); \
         {fail} fail, {undecidable} undecidable, {total} total cases",
    );

    // Sum invariant: every case is exactly one of pass / fail / undecidable.
    assert_eq!(
        pass + fail + undecidable,
        total,
        "outcome counts must sum to total cases"
    );

    // No assertion on pass rate; m51 owns that gate. T13 just records
    // the number for docs/research/m50-port-baseline.md.
}

/// Compare a parsed `Vec<Language>` against the C#-derived expectation.
///
/// Inputs absent from the expectations map (e.g. host methods that only
/// call `ParseSubtitleLanguage` / `ParseSubtitleLanguageInformation`)
/// route to `Undecidable`; the comparator has no expected Language list
/// to test against. T13 keeps the harness focused on the
/// `ParseLanguages` axis only; m51's gate is on Languages pass rate, not
/// subtitle parser parity (T16's harness covers that).
///
/// For mapped inputs the comparator dispatches on the assertion kind:
///
/// - `contains`: Pass iff `actual` contains every Language in
///   `expected.languages`. Mirrors C#
///   `result.Should().Contain(Language.X)` (one element) /
///   `result.Should().Contain(Language.X)` repeated (multiple). Order
///   and superset behaviour both allowed.
///
/// - `equivalent`: Pass iff `actual` and `expected.languages` are equal
///   as sets (same elements, ignoring order and duplicates). Mirrors C#
///   `result.Should().BeEquivalentTo(new[] { ... })`.
fn compare_against_expected(
    input: &str,
    actual: &[Language],
    expectations: &Expectations,
) -> Outcome {
    // Look up the expected Language list + assertion kind from the
    // C#-derived mapping.
    let Some(entry) = expectations.mappings.get(input) else {
        return Outcome::Undecidable;
    };

    // Resolve every expected token to a `Language`. An unknown token
    // here would be a bug in the sidecar (typo in the hand-extract);
    // route to Undecidable to flag it without failing the harness.
    let expected: Vec<Language> = match entry
        .languages
        .iter()
        .map(|s| token_to_language(s))
        .collect::<Option<Vec<_>>>()
    {
        Some(v) => v,
        None => return Outcome::Undecidable,
    };

    let actual_set: std::collections::HashSet<i32> = actual.iter().map(|l| *l as i32).collect();
    let expected_set: std::collections::HashSet<i32> = expected.iter().map(|l| *l as i32).collect();

    let pass = match entry.kind.as_str() {
        "contains" => expected_set.is_subset(&actual_set),
        "equivalent" => actual_set == expected_set,
        // Unknown kind in the sidecar is a bug; flag as Undecidable to
        // be visible without failing the harness.
        _ => return Outcome::Undecidable,
    };

    if pass { Outcome::Pass } else { Outcome::Fail }
}

/// Map a sidecar string token (e.g. `"English"`, `"PortugueseBrazil"`,
/// `"SpanishLatino"`, `"Original"`) to the corresponding `Language`
/// variant. Returns `None` for tokens that don't match a known
/// variant. The comparator routes those to Undecidable.
///
/// Names match the Rust enum identifiers in
/// `crates/parser/src/language/mod.rs` verbatim, which themselves match
/// the C# `Language.X` static names. Compound names are PascalCase
/// (`PortugueseBrazil`, `SpanishLatino`) per the Rust port; the sidecar
/// emits identical tokens.
fn token_to_language(token: &str) -> Option<Language> {
    Some(match token {
        "Unknown" => Language::Unknown,
        "English" => Language::English,
        "French" => Language::French,
        "Spanish" => Language::Spanish,
        "German" => Language::German,
        "Italian" => Language::Italian,
        "Danish" => Language::Danish,
        "Dutch" => Language::Dutch,
        "Japanese" => Language::Japanese,
        "Icelandic" => Language::Icelandic,
        "Chinese" => Language::Chinese,
        "Russian" => Language::Russian,
        "Polish" => Language::Polish,
        "Vietnamese" => Language::Vietnamese,
        "Swedish" => Language::Swedish,
        "Norwegian" => Language::Norwegian,
        "Finnish" => Language::Finnish,
        "Turkish" => Language::Turkish,
        "Portuguese" => Language::Portuguese,
        "Flemish" => Language::Flemish,
        "Greek" => Language::Greek,
        "Korean" => Language::Korean,
        "Hungarian" => Language::Hungarian,
        "Hebrew" => Language::Hebrew,
        "Lithuanian" => Language::Lithuanian,
        "Czech" => Language::Czech,
        "Arabic" => Language::Arabic,
        "Hindi" => Language::Hindi,
        "Bulgarian" => Language::Bulgarian,
        "Malayalam" => Language::Malayalam,
        "Ukrainian" => Language::Ukrainian,
        "Slovak" => Language::Slovak,
        "Thai" => Language::Thai,
        "PortugueseBrazil" => Language::PortugueseBrazil,
        "SpanishLatino" => Language::SpanishLatino,
        "Romanian" => Language::Romanian,
        "Latvian" => Language::Latvian,
        "Persian" => Language::Persian,
        "Catalan" => Language::Catalan,
        "Croatian" => Language::Croatian,
        "Serbian" => Language::Serbian,
        "Bosnian" => Language::Bosnian,
        "Estonian" => Language::Estonian,
        "Tamil" => Language::Tamil,
        "Indonesian" => Language::Indonesian,
        "Macedonian" => Language::Macedonian,
        "Slovenian" => Language::Slovenian,
        "Original" => Language::Original,
        _ => return None,
    })
}