plugins/native/znippy-plugin-media/src/tests.rs
//! 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");
}