use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
use mpv_engine::{EndReason, Engine, PlaybackEvent, PropertyFormat, PropertyValue};
fn headless_engine() -> Option<Engine> {
match Engine::headless().property("ao", "null").build() {
Ok(e) => Some(e),
Err(e) => {
eprintln!("skipping: mpv engine unavailable: {e}");
None
}
}
}
fn video_engine() -> Option<Engine> {
match Engine::video().property("ao", "null").build() {
Ok(e) => Some(e),
Err(e) => {
eprintln!("skipping: mpv engine unavailable: {e}");
None
}
}
}
fn generate_audio(target: &Path) -> Option<()> {
let status = std::process::Command::new("ffmpeg")
.args([
"-y",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=mono",
"-t",
"1",
])
.arg(target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => Some(()),
_ => {
eprintln!("skipping: ffmpeg unavailable or failed");
None
}
}
}
fn generate_video(target: &Path) -> Option<()> {
let status = std::process::Command::new("ffmpeg")
.args([
"-y",
"-f",
"lavfi",
"-i",
"testsrc2=size=64x64:rate=10",
"-t",
"1",
"-c:v",
"rawvideo",
"-pix_fmt",
"yuv420p",
"-f",
"nut",
])
.arg(target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => Some(()),
_ => {
eprintln!("skipping: ffmpeg unavailable or failed");
None
}
}
}
fn pump_until(engine: &Engine, mut stop: impl FnMut(&PlaybackEvent) -> bool) -> bool {
for _ in 0..100 {
for ev in engine.pump_events() {
if stop(&ev) {
return true;
}
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
fn wait_until(mut pred: impl FnMut() -> bool) -> bool {
(0..100).any(|_| {
std::thread::sleep(Duration::from_millis(50));
pred()
})
}
fn pump_for(engine: &Engine, dur: Duration) -> Vec<PlaybackEvent> {
let mut out = Vec::new();
for _ in 0..dur.as_millis().div_ceil(50) {
std::thread::sleep(Duration::from_millis(50));
out.extend(engine.pump_events());
}
out
}
#[test]
fn loadfile_handles_awkward_filenames() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("Riley Cyriis_test \"q\" (copy).ogg");
if generate_audio(&source).is_none() {
return;
}
let Some(engine) = headless_engine() else {
return;
};
engine.load(source.to_str().unwrap()).unwrap();
let loaded = pump_until(&engine, |ev| match ev {
PlaybackEvent::Loaded => true,
PlaybackEvent::Failed { message, .. } => {
panic!("loadfile failed on awkward path: {message}")
}
_ => false,
});
assert!(loaded, "awkward-path file never reached Loaded");
}
#[test]
fn empty_file_surfaces_failed_event() {
let Some(engine) = headless_engine() else {
return;
};
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("empty.mkv");
std::fs::File::create(&bad).unwrap();
engine.load(bad.to_str().unwrap()).unwrap();
let failed = pump_until(&engine, |ev| match ev {
PlaybackEvent::Failed { code, .. } => {
assert!(*code < 0, "Failed event must carry a negative mpv code");
true
}
_ => false,
});
assert!(failed, "empty file must produce a Failed playback event");
}
#[test]
fn load_paused_holds_until_unpaused() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("tone.ogg");
if generate_audio(&source).is_none() {
return;
}
let Some(engine) = headless_engine() else {
return;
};
engine.load_paused(source.to_str().unwrap()).unwrap();
assert!(pump_until(&engine, |ev| matches!(
ev,
PlaybackEvent::Loaded
)));
assert!(engine.is_paused(), "engine must come up paused");
let mut held = None;
let settled = (0..25).any(|_| {
let a = engine.position();
std::thread::sleep(Duration::from_millis(200));
if a.is_some() && a == engine.position() {
held = a;
true
} else {
false
}
});
assert!(settled, "position must settle and hold while paused");
engine.set_paused(false).unwrap();
assert!(!engine.is_paused());
let advanced = wait_until(|| engine.position() > held);
assert!(advanced, "position must advance after unpausing");
}
#[test]
fn load_when_ready_plays_immediately_without_render_api() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("tone.ogg");
if generate_audio(&source).is_none() {
return;
}
let Some(engine) = headless_engine() else {
return;
};
engine.load_when_ready(source.to_str().unwrap()).unwrap();
assert!(pump_until(&engine, |ev| matches!(
ev,
PlaybackEvent::Loaded
)));
assert!(
!engine.is_paused(),
"no render attach is coming — playback must start immediately"
);
}
#[test]
fn load_when_ready_loads_on_attach() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("test.nut");
if generate_video(&source).is_none() {
return;
}
let Some(engine) = video_engine() else {
return;
};
engine.load_when_ready(source.to_str().unwrap()).unwrap();
for ev in pump_for(&engine, Duration::from_millis(200)) {
match ev {
PlaybackEvent::Failed { message, .. } => {
panic!("deferred load must not fail pre-attach: {message}")
}
PlaybackEvent::Loaded => panic!("load must wait for the attach"),
_ => {}
}
}
engine.attach_sw_render(|| {}).unwrap();
let loaded = pump_until(&engine, |ev| match ev {
PlaybackEvent::Loaded => true,
PlaybackEvent::Failed { message, .. } => {
panic!("deferred load failed after attach: {message}")
}
_ => false,
});
assert!(loaded, "attach must issue the deferred load");
assert!(!engine.is_paused(), "deferred load must come up playing");
}
#[test]
fn pause_before_attach_loads_deferred_file_paused() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("test.nut");
if generate_video(&source).is_none() {
return;
}
let Some(engine) = video_engine() else {
return;
};
engine.load_when_ready(source.to_str().unwrap()).unwrap();
engine.set_paused(true).unwrap();
engine.attach_sw_render(|| {}).unwrap();
assert!(pump_until(&engine, |ev| matches!(
ev,
PlaybackEvent::Loaded
)));
assert!(
engine.is_paused(),
"pause set before attach must hold through the deferred load"
);
}
#[test]
fn load_when_ready_follows_runtime_vo_change() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("test.nut");
if generate_video(&source).is_none() {
return;
}
let Some(engine) = video_engine() else {
return;
};
engine.set_property("vo", "null").unwrap();
engine.load_when_ready(source.to_str().unwrap()).unwrap();
assert!(
pump_until(&engine, |ev| matches!(ev, PlaybackEvent::Loaded)),
"with vo switched off the render API, the load must not defer"
);
assert!(!engine.is_paused(), "playback must start immediately");
}
#[test]
fn command_stop_discards_deferred_load() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("test.nut");
if generate_video(&source).is_none() {
return;
}
let Some(engine) = video_engine() else {
return;
};
engine.load_when_ready(source.to_str().unwrap()).unwrap();
engine.command("stop", &[]).unwrap();
engine.attach_sw_render(|| {}).unwrap();
for ev in pump_for(&engine, Duration::from_millis(200)) {
assert!(
!matches!(ev, PlaybackEvent::Loaded),
"stop before attach must discard the deferred load"
);
}
assert!(engine.is_idle(), "nothing may be loaded after the stop");
}
#[test]
fn attach_survives_failing_deferred_load() {
let Some(engine) = video_engine() else {
return;
};
engine.load_when_ready("/nonexistent/deferred.nut").unwrap();
engine
.attach_sw_render(|| {})
.expect("a failing deferred load must not fail the attach");
assert!(engine.has_render(), "the render context must be live");
assert!(
pump_until(&engine, |ev| matches!(ev, PlaybackEvent::Failed { .. })),
"the deferred load's failure must surface as a Failed event"
);
}
#[test]
fn stop_reports_stop_reason() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("tone.ogg");
if generate_audio(&source).is_none() {
return;
}
let Some(engine) = headless_engine() else {
return;
};
engine.load_paused(source.to_str().unwrap()).unwrap();
assert!(pump_until(&engine, |ev| matches!(
ev,
PlaybackEvent::Loaded
)));
engine.stop().unwrap();
let ended = pump_until(&engine, |ev| match ev {
PlaybackEvent::Ended { reason } => {
assert_eq!(*reason, EndReason::Stop, "stop must not look like EOF");
true
}
_ => false,
});
assert!(ended, "stop must produce an Ended event");
}
#[test]
fn observe_pushes_initial_value_and_changes() {
let Some(engine) = headless_engine() else {
return;
};
let observe_id = engine.observe("volume", PropertyFormat::Double).unwrap();
let initial = pump_until(&engine, |ev| {
matches!(
ev,
PlaybackEvent::PropertyChanged { id, name, .. }
if name == "volume" && *id == observe_id
)
});
assert!(initial, "observe must push the initial value with its id");
engine.set_volume(55.0).unwrap();
let changed = pump_until(&engine, |ev| {
matches!(
ev,
PlaybackEvent::PropertyChanged { name, value: PropertyValue::Double(v), .. }
if name == "volume" && *v == 55.0
)
});
assert!(changed, "volume change must arrive as PropertyChanged");
}
#[test]
fn sw_render_produces_opaque_rgba_frames() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("test.nut");
if generate_video(&source).is_none() {
return;
}
let Some(engine) = video_engine() else {
return;
};
assert!(!engine.render_update());
let frame_ready = Arc::new(AtomicBool::new(false));
let flag = frame_ready.clone();
engine
.attach_sw_render(move || flag.store(true, Ordering::SeqCst))
.unwrap();
frame_ready.store(false, Ordering::SeqCst);
engine.load(source.to_str().unwrap()).unwrap();
assert!(pump_until(&engine, |ev| matches!(
ev,
PlaybackEvent::Loaded
)));
let ready = wait_until(|| frame_ready.load(Ordering::SeqCst));
assert!(ready, "update callback must signal a frame");
let update_frame = wait_until(|| engine.render_update());
assert!(update_frame, "render_update must report the pending frame");
let mut buf = Vec::new();
engine.render_sw(64, 64, &mut buf).unwrap();
assert_eq!(buf.len(), 64 * 64 * 4);
assert!(
buf.chunks_exact(4).all(|px| px[3] == 0xFF),
"every pixel's alpha must be forced opaque"
);
assert!(
buf.chunks_exact(4)
.any(|px| px[..3].iter().any(|&b| b != 0)),
"a rendered test-pattern frame must contain non-black pixels"
);
assert!(engine.render_gl(0, 64, 64, false).is_err());
engine.detach_render();
assert!(!engine.has_render());
}
#[test]
fn wakeup_callback_fires_on_events() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("tone.ogg");
if generate_audio(&source).is_none() {
return;
}
let Some(engine) = headless_engine() else {
return;
};
let woke = Arc::new(AtomicBool::new(false));
let flag = woke.clone();
engine.set_wakeup_callback(move || flag.store(true, Ordering::SeqCst));
woke.store(false, Ordering::SeqCst);
engine.load(source.to_str().unwrap()).unwrap();
let signaled = wait_until(|| woke.load(Ordering::SeqCst));
assert!(signaled, "wakeup must fire when events queue");
assert!(
pump_until(&engine, |ev| matches!(ev, PlaybackEvent::Loaded)),
"the signaled events must include Loaded"
);
}
#[test]
fn gl_render_options_build_externally() {
let opts = mpv_engine::GlRenderOptions::default()
.block_for_target_time(false)
.advanced_control(true);
assert!(!opts.block_for_target_time);
assert!(opts.advanced_control);
}
#[test]
fn engine_drops_cleanly_without_render() {
let Some(engine) = headless_engine() else {
return;
};
assert!(!engine.has_render());
drop(engine);
}
#[test]
fn attached_render_reports_backend_kind() {
let Some(engine) = video_engine() else {
return;
};
assert_eq!(engine.attached_render(), None);
engine.attach_sw_render(|| {}).unwrap();
assert_eq!(
engine.attached_render(),
Some(mpv_engine::RenderKind::Software)
);
engine.detach_render();
assert_eq!(engine.attached_render(), None);
}
#[test]
fn render_update_callback_requires_attach() {
let Some(engine) = video_engine() else {
return;
};
assert!(matches!(
engine.set_render_update_callback(|| {}),
Err(mpv_engine::Error::NotAttached)
));
engine.attach_sw_render(|| {}).unwrap();
engine.set_render_update_callback(|| {}).unwrap();
engine.detach_render();
assert!(matches!(
engine.set_render_update_callback(|| {}),
Err(mpv_engine::Error::NotAttached)
));
}
#[test]
fn render_update_callback_replaces_attach_registration() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("test.nut");
if generate_video(&source).is_none() {
return;
}
let Some(engine) = video_engine() else {
return;
};
let attach_fires = Arc::new(AtomicU32::new(0));
let attach_counter = attach_fires.clone();
engine
.attach_sw_render(move || {
attach_counter.fetch_add(1, Ordering::SeqCst);
})
.unwrap();
assert!(
attach_fires.load(Ordering::SeqCst) >= 1,
"attach must raise its on_update synchronously"
);
let replacement_fires = Arc::new(AtomicU32::new(0));
let replacement_counter = replacement_fires.clone();
engine
.set_render_update_callback(move || {
replacement_counter.fetch_add(1, Ordering::SeqCst);
})
.unwrap();
assert!(
replacement_fires.load(Ordering::SeqCst) >= 1,
"registration must raise the replacement callback synchronously"
);
let attach_count_after_swap = attach_fires.load(Ordering::SeqCst);
engine.load(source.to_str().unwrap()).unwrap();
let before_frames = replacement_fires.load(Ordering::SeqCst);
let frames_signaled = wait_until(|| replacement_fires.load(Ordering::SeqCst) > before_frames);
assert!(
frames_signaled,
"frame updates must land on the replacement callback"
);
assert_eq!(
attach_fires.load(Ordering::SeqCst),
attach_count_after_swap,
"the replaced attach-time callback must not fire after the swap"
);
}
#[test]
fn render_update_callback_sync_fire_holds_no_engine_lock() {
let Some(engine) = video_engine() else {
return;
};
engine.attach_sw_render(|| {}).unwrap();
let engine = Arc::new(engine);
let probe = Arc::downgrade(&engine);
let saw_context = Arc::new(AtomicBool::new(false));
let saw = saw_context.clone();
engine
.set_render_update_callback(move || {
if let Some(e) = probe.upgrade() {
saw.store(e.has_render(), Ordering::SeqCst);
}
})
.unwrap();
assert!(
saw_context.load(Ordering::SeqCst),
"the sync fire must see the live context, without deadlocking"
);
}
#[test]
fn quit_surfaces_shutdown_event() {
let Some(engine) = headless_engine() else {
return;
};
engine.command("quit", &[]).unwrap();
assert!(pump_until(&engine, |ev| matches!(
ev,
PlaybackEvent::Shutdown
)));
}