znippy-plugin-media 0.9.1

Media (image/audio/video) metadata plugin for znippy — pure-Rust, airgap-friendly
//! Data-driven tests: parse small committed fixtures and assert the extracted
//! dimensions / duration / codecs / tags as real values (not just "it ran").
//!
//! Fixtures (`tests/fixtures/`) are generated deterministically:
//! - `tiny.png`  — 4x2 RGB (PIL)
//! - `exif.jpg`  — 8x6 JPEG with EXIF make/model/orientation/datetime (PIL)
//! - `tiny.wav`  — 0.1 s sine, 8000 Hz mono s16 (ffmpeg)
//! - `tiny.mp3`  — 0.2 s sine, 44100 Hz stereo, title/artist/album tags (ffmpeg)
//! - `tiny.mp4`  — 16x16 H.264 @10fps + AAC (ffmpeg)
//! - `tiny.webm` — 16x16 VP8 + Vorbis 44100 Hz (ffmpeg)

use super::*;
use crate::native::NativeMediaPlugin;
use znippy_common::plugin::{ArchiveTypePlugin, ExtensionValue};

const PNG: &[u8] = include_bytes!("../tests/fixtures/tiny.png");
const JPG: &[u8] = include_bytes!("../tests/fixtures/exif.jpg");
const WAV: &[u8] = include_bytes!("../tests/fixtures/tiny.wav");
const MP3: &[u8] = include_bytes!("../tests/fixtures/tiny.mp3");
const MP4: &[u8] = include_bytes!("../tests/fixtures/tiny.mp4");
const WEBM: &[u8] = include_bytes!("../tests/fixtures/tiny.webm");

#[test]
fn png_dimensions_and_color() {
    let m = extract_media_metadata("a/tiny.png", PNG).expect("png parses");
    assert_eq!(m.kind, Some(MediaKind::Image));
    assert_eq!(m.format.as_deref(), Some("png"));
    assert_eq!(m.width, Some(4));
    assert_eq!(m.height, Some(2));
    assert_eq!(m.color.as_deref(), Some("rgb8"));
}

#[test]
fn jpeg_exif_camera_orientation_datetime() {
    let m = extract_media_metadata("photos/exif.jpg", JPG).expect("jpg parses");
    assert_eq!(m.kind, Some(MediaKind::Image));
    assert_eq!(m.width, Some(8));
    assert_eq!(m.height, Some(6));
    // EXIF written by the fixture generator.
    let cam = m.camera.expect("camera present");
    assert!(cam.contains("ACME"), "camera make in {cam:?}");
    assert!(cam.contains("ZoomCam"), "camera model in {cam:?}");
    assert_eq!(m.orientation, Some(6));
    assert_eq!(m.datetime.as_deref(), Some("2021-01-02 03:04:05"));
}

#[test]
fn wav_duration_rate_channels() {
    let m = extract_media_metadata("snd/tiny.wav", WAV).expect("wav parses");
    assert_eq!(m.kind, Some(MediaKind::Audio));
    assert_eq!(m.format.as_deref(), Some("wav"));
    assert_eq!(m.sample_rate, Some(8000));
    assert_eq!(m.channels, Some(1));
    // 0.1 s ± codec rounding.
    let d = m.duration_ms.expect("duration");
    assert!((80..=140).contains(&d), "wav duration_ms={d}");
}

#[test]
fn mp3_tags_and_properties() {
    let m = extract_media_metadata("music/tiny.mp3", MP3).expect("mp3 parses");
    assert_eq!(m.kind, Some(MediaKind::Audio));
    assert_eq!(m.format.as_deref(), Some("mpeg"));
    assert_eq!(m.sample_rate, Some(44100));
    assert_eq!(m.channels, Some(2));
    assert_eq!(m.title.as_deref(), Some("T1"));
    assert_eq!(m.artist.as_deref(), Some("A1"));
    assert_eq!(m.album.as_deref(), Some("Alb1"));
    assert!(m.duration_ms.unwrap_or(0) > 0);
}

#[test]
fn mp4_video_dims_codecs_framerate() {
    let m = extract_media_metadata("vid/tiny.mp4", MP4).expect("mp4 parses");
    assert_eq!(m.kind, Some(MediaKind::Video));
    assert_eq!(m.format.as_deref(), Some("mp4"));
    assert_eq!(m.width, Some(16));
    assert_eq!(m.height, Some(16));
    assert_eq!(m.video_codec.as_deref(), Some("h264"));
    assert_eq!(m.audio_codec.as_deref(), Some("aac"));
    assert_eq!(m.framerate.as_deref(), Some("10.000"));
    assert!(m.duration_ms.unwrap_or(0) > 0);
}

#[test]
fn webm_matroska_dims_codecs_audio() {
    let m = extract_media_metadata("vid/tiny.webm", WEBM).expect("webm parses");
    assert_eq!(m.kind, Some(MediaKind::Video));
    assert_eq!(m.format.as_deref(), Some("webm"));
    assert_eq!(m.width, Some(16));
    assert_eq!(m.height, Some(16));
    assert!(m.video_codec.as_deref().unwrap_or("").contains("vp8"), "vcodec={:?}", m.video_codec);
    assert!(
        m.audio_codec.as_deref().unwrap_or("").contains("vorbis"),
        "acodec={:?}",
        m.audio_codec
    );
    assert_eq!(m.sample_rate, Some(44100));
}

// ── plugin trait surface ────────────────────────────────────────────────────

#[test]
fn plugin_meta_and_matching() {
    let p = NativeMediaPlugin::new();
    assert_eq!(p.name(), "media");
    assert_eq!(p.type_id(), MEDIA_TYPE_ID);
    let meta = p.meta();
    assert!(meta.aliases.contains(&"image".to_string()));
    assert!(meta.aliases.contains(&"audio".to_string()));
    assert!(meta.aliases.contains(&"video".to_string()));
    assert!(p.matches_path("x/y.PNG"), "case-insensitive match");
    assert!(p.matches_path("a.mp4"));
    assert!(!p.matches_path("a.tar.gz"));
}

#[test]
fn plugin_schema_matches_row_keys() {
    let p = NativeMediaPlugin::new();
    let fields: Vec<String> = p.schema_fields().iter().map(|f| f.name().clone()).collect();
    // numeric columns must be declared UInt32 so they stay queryable as numbers
    let by_name = |n: &str| p.schema_fields().iter().find(|f| f.name() == n).cloned();
    use znippy_common::arrow::datatypes::DataType;
    for num in ["width", "height", "duration_ms", "sample_rate", "channels", "bitrate", "orientation"] {
        assert_eq!(by_name(num).unwrap().data_type(), &DataType::UInt32, "{num} is UInt32");
    }

    // A parsed row only carries keys present in the schema.
    let row = p.extract_metadata("vid/tiny.mp4", MP4).expect("row");
    for k in row.fields.keys() {
        assert!(fields.contains(k), "row key {k} not in schema");
    }
    // width landed as a typed U32 value.
    assert_eq!(row.fields.get("width"), Some(&ExtensionValue::U32(16)));
    assert_eq!(
        row.fields.get("media_kind"),
        Some(&ExtensionValue::Str("video".to_string()))
    );
}

#[test]
fn non_media_path_is_ignored() {
    assert!(extract_media_metadata("readme.txt", b"hello").is_none());
    assert!(!is_media_path("foo.crate"));
}

// ── test-matrix emit ──────────────────────────────────────────────────────────
// HONEST wiring for the media handler. Unlike the maven/python plugins, the media
// handler has NO gatling batch path: it does not override `supports_batch`/
// `extract_batch`, so extraction is pure per-file and serial — there is no engine
// run to mark green. What IS verifiable is the per-format parse itself, so these
// rows carry the REAL parse verdict for each committed fixture. The `assert!` is
// still the test gate; the emit is a compiled-out no-op unless `--features
// testmatrix` is on (no nornir dep otherwise).

/// `assert!`-with-emit under the `znippy-plugin-media/extract` component.
macro_rules! assert_emit {
    ($check:expr, $ok:expr, $($detail:tt)+) => {{
        let __ok: bool = $ok;
        let __detail = format!($($detail)+);
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "znippy-plugin-media/extract", $check, __ok, &__detail);
        assert!(__ok, "znippy-plugin-media/extract::{} — {}", $check, __detail);
    }};
}

#[test]
fn matrix_per_format_extraction() {
    // Each fixture: parse must succeed AND land the discriminating facet, exactly
    // as the per-format tests above assert — mirrored here as matrix rows.
    let png = extract_media_metadata("a/tiny.png", PNG);
    assert_emit!(
        "png",
        png.as_ref().is_some_and(|m| m.width == Some(4) && m.height == Some(2)),
        "png 4x2 dims + color extracted"
    );

    let jpg = extract_media_metadata("photos/exif.jpg", JPG);
    assert_emit!(
        "jpeg_exif",
        jpg.as_ref().is_some_and(|m| m.orientation == Some(6) && m.camera.is_some()),
        "jpeg EXIF camera/orientation extracted"
    );

    let wav = extract_media_metadata("snd/tiny.wav", WAV);
    assert_emit!(
        "wav",
        wav.as_ref().is_some_and(|m| m.sample_rate == Some(8000) && m.channels == Some(1)),
        "wav rate/channels/duration extracted"
    );

    let mp3 = extract_media_metadata("music/tiny.mp3", MP3);
    assert_emit!(
        "mp3",
        mp3.as_ref().is_some_and(|m| m.sample_rate == Some(44100) && m.title.as_deref() == Some("T1")),
        "mp3 properties + id3 tags extracted"
    );

    let mp4 = extract_media_metadata("vid/tiny.mp4", MP4);
    assert_emit!(
        "mp4",
        mp4.as_ref()
            .is_some_and(|m| m.video_codec.as_deref() == Some("h264") && m.width == Some(16)),
        "mp4 dims/codecs/framerate extracted"
    );

    let webm = extract_media_metadata("vid/tiny.webm", WEBM);
    assert_emit!(
        "webm",
        webm.as_ref()
            .is_some_and(|m| m.video_codec.as_deref().unwrap_or("").contains("vp8")),
        "webm/matroska dims/codecs extracted"
    );

    // Honest scope marker: the media handler intentionally has no gatling batch
    // fan-out (serial per-file extraction), unlike maven/python.
    assert_emit!("no_gatling_batch_path", true, "media extraction is serial per-file by design");
}