avatarr-parser 0.1.0

Release-name parser ported from Sonarr v4.0.17.2952
Documentation
//! Quality fixture harness. Runs every input from
//! `tests/sonarr-fixtures/quality_parsing.json` (278 cases) through
//! `avatarr_parser::parse_quality` and records the pass rate.
//!
//! T9 of the m50 Sonarr parser port. Comparator design ported from
//! `crates/parser-baseline/src/lib.rs::compare_quality` with the same
//! Amendment 3a Undecidable classification: resolution-less Quality
//! tokens (SDTV, DVD, RAWHD, Unknown) are excluded from the pass-rate
//! denominator.
//!
//! ## Fixture-format note
//!
//! The vendored `quality_parsing.json` was extracted by `extract.py`
//! from Sonarr's `[TestCase("input", proper)]` attributes only. The
//! Quality variant lives in the host test method (e.g.
//! `should_parse_sdtv_quality` calls `ParseAndVerifyQuality(title,
//! Quality.SDTV, proper)`), not in the attribute args. To recover the
//! `input -> Quality` mapping the comparator needs, this test reads a
//! companion `tests/sonarr-fixtures/quality_parsing_expectations.json`
//! (co-located with the input fixture, vendored from the same pinned
//! Sonarr SHA) generated by walking each test method's
//! `ParseAndVerifyQuality` call. Cases where no Quality is asserted
//! (`should_parse_resolution_from_name`, `should_be_able_to_parse_repack`,
//! etc.) are intentionally absent from the map and route to 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::{Quality, QualityModel};

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

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

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

enum Outcome {
    Pass,
    Fail,
    Undecidable,
}

#[test]
fn quality_fixtures_pass_rate_recorded() {
    let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../tests/sonarr-fixtures/quality_parsing.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 quality_parsing.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/quality_parsing_expectations.json"
    ))
    .expect("parse quality_parsing_expectations.json");

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

    // Set `AVATARR_DEBUG_QUALITY_FAILURES=1` to print each FAIL with its
    // expected vs. parsed Quality. 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_QUALITY_FAILURES").is_ok();

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

    let denominator = total - undecidable;
    let pct = if denominator == 0 {
        0.0
    } else {
        100.0 * pass as f64 / denominator as f64
    };
    eprintln!(
        "quality_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"
    );

    assert!(
        pct >= 100.0,
        "quality fixture pass rate {pct:.1}% is below the 100% gate ({fail} failures)"
    );
}

/// Compare a parsed `QualityModel` against the C#-derived expectation.
///
/// Ported from `parser-baseline::compare_quality` with Amendment 3a:
/// resolution-less Sonarr Quality tokens (SDTV, DVD, RAWHD, Unknown)
/// route to `Undecidable` rather than Pass/Fail because the bespoke
/// parser's structured `QualityModel` cannot disambiguate them from
/// other resolution-less classes (e.g. an HDTV-without-resolution
/// release vs. SDTV vs. raw broadcast). Excluded from the pass-rate
/// denominator.
///
/// Inputs absent from the expectations map (e.g. `should_parse_repack`
/// host methods that assert only `Revision.Version`, not `Quality`)
/// also route to `Undecidable`; the comparator has no expected Quality
/// to test against. m50 keeps T9 focused on the Quality axis only;
/// m51's gate is on Quality pass rate, not Revision parity.
fn compare_against_expected(
    input: &str,
    model: &QualityModel,
    expectations: &Expectations,
) -> Outcome {
    // Look up the expected Quality from the C#-derived mapping.
    let Some(expected_token) = expectations.mappings.get(input) else {
        return Outcome::Undecidable;
    };

    // Amendment 3a: resolution-less Sonarr Quality tokens excluded from
    // pass/fail to mirror parser-baseline's Undecidable bucket.
    let lower = expected_token.to_ascii_lowercase();
    if matches!(lower.as_str(), "sdtv" | "dvd" | "rawhd" | "unknown") {
        return Outcome::Undecidable;
    }

    // Map the Sonarr Quality.X token to our Rust Quality enum. The Rust
    // identifiers differ from C# (BLURAY_1080P vs Bluray1080p, WEBDL vs
    // Webdl, RAWHD vs RawHd); match by lowercase string.
    let expected = match lower.as_str() {
        "webdl1080p" => Quality::Webdl1080p,
        "hdtv720p" => Quality::Hdtv720p,
        "webdl720p" => Quality::Webdl720p,
        "bluray720p" => Quality::Bluray720p,
        "bluray1080p" => Quality::Bluray1080p,
        "webdl480p" => Quality::Webdl480p,
        "hdtv1080p" => Quality::Hdtv1080p,
        "webrip480p" => Quality::Webrip480p,
        "bluray480p" => Quality::Bluray480p,
        "webrip720p" => Quality::Webrip720p,
        "webrip1080p" => Quality::Webrip1080p,
        "hdtv2160p" => Quality::Hdtv2160p,
        "webrip2160p" => Quality::Webrip2160p,
        "webdl2160p" => Quality::Webdl2160p,
        "bluray2160p" => Quality::Bluray2160p,
        "bluray1080premux" => Quality::Bluray1080pRemux,
        "bluray2160premux" => Quality::Bluray2160pRemux,
        "bluray576p" => Quality::Bluray576p,
        // Anything else is a token we haven't seen. Flag for explicit
        // attention rather than silently routing to Fail. Matches
        // parser-baseline's "cannot infer" Undecidable arm.
        _ => return Outcome::Undecidable,
    };

    if model.quality == expected {
        Outcome::Pass
    } else {
        Outcome::Fail
    }
}