test(oak-codec, oak-core): boundary and branch coverage

Fixture-backed unit and contract tests for FFmpeg helpers and state
machines, OCIO color factories, wgpu backend fallbacks, and the safe
parts of the platform import module (review report section 10.2).
This commit is contained in:
2026-09-22 20:54:04 +08:00
parent df3957b0dc
commit 12ca9d1d7a
10 changed files with 3491 additions and 72 deletions
+168 -8
View File
@@ -460,13 +460,30 @@ pub fn create_from_id(id: &str) -> Option<Arc<dyn Decoder>> {
/// not injected, in which case the built-in list below is used.
static TEST_DECODERS: OnceLock<Mutex<Vec<Arc<dyn Decoder>>>> = OnceLock::new();
/// Serializes every test that reads the built-in decoder registry. Tests
/// inject through [`set_test_decoders`] under [`crate::lock_tests`] (the
/// shared test lock), so the registry assertions below take that same lock
/// to never race with an injected list.
/// Serializes every test that reads the built-in decoder registry and
/// clears the test injection on drop: a panicking assertion must not leak
/// fake decoders into the process-wide registry used by later tests. The
/// held [`crate::TestLock`] is released only after the clear.
#[cfg(test)]
fn registry_guard() -> crate::TestLock {
crate::lock_tests()
struct RegistryGuard {
/// Held for the test's duration; never read, only dropped.
_lock: crate::TestLock,
}
#[cfg(test)]
impl Drop for RegistryGuard {
fn drop(&mut self) {
set_test_decoders(Vec::new());
}
}
/// Take the shared test lock and clear any injection left behind by an
/// earlier panicking test, then return the guard.
#[cfg(test)]
fn registry_guard() -> RegistryGuard {
let lock = crate::lock_tests();
set_test_decoders(Vec::new());
RegistryGuard { _lock: lock }
}
/// Replace the decoder registry with `list`; pass an empty list to restore
@@ -785,6 +802,149 @@ mod tests_unimplemented {
assert_eq!(off.numerator(), 0);
assert_eq!(off.denominator(), 1);
}
/// A decoder that relies on every trait default (no overrides), so the
/// conservative default bodies are covered.
struct DefaultsOnly;
impl Decoder for DefaultsOnly {
fn id(&self) -> String {
"defaults".to_string()
}
fn probe(&self, _f: &str, _c: Option<&CancelAtom>) -> Option<FootageDescription> {
None
}
fn open(&self, _s: &CodecStream) -> crate::error::Result<()> {
Err(crate::error::Error::Invalid)
}
fn close(&self) -> crate::error::Result<()> {
Ok(())
}
fn stream(&self) -> CodecStream {
CodecStream::new()
}
fn retrieve_video_frame(
&self,
_p: &RetrieveVideoParams,
) -> crate::error::Result<Arc<Frame>> {
Err(crate::error::Error::Invalid)
}
fn retrieve_video(
&self,
_p: &RetrieveVideoParams,
) -> crate::error::Result<OakRenderTexture> {
Err(crate::error::Error::Invalid)
}
fn retrieve_audio(
&self,
_d: &mut [f32],
_r: &TimeRange,
_s: i32,
_l: u64,
) -> crate::error::Result<RetrieveAudioStatus> {
Ok(RetrieveAudioStatus::Unsupported)
}
fn conform_audio(
&self,
_o: &[String],
_s: i32,
_l: u64,
_sf: i32,
_c: Option<&CancelAtom>,
) -> crate::error::Result<()> {
Ok(())
}
}
fn test_params() -> RetrieveVideoParams {
RetrieveVideoParams {
stream: CodecStream::new(),
time: Rational::new(0, 1),
length: TimeRange::default(),
force_range: K_COLOR_RANGE_DEFAULT,
is_image_sequence: false,
image_sequence_digits: 0,
image_sequence_number: 0,
mode: RenderMode::Offline,
alpha_is_premultiplied: false,
target_size: None,
}
}
#[test]
fn decoder_trait_defaults_are_conservative() {
let d = DefaultsOnly;
assert!(!d.supports_video());
assert!(!d.supports_audio());
assert!(!d.hardware_decoding());
assert_eq!(d.get_audio_start_offset(), Rational::new(0, 1));
assert!(d.retrieve_video_frame_gpu(&test_params()).unwrap().is_none());
}
#[test]
fn oiio_placeholder_reports_unimplemented() {
let _g = registry_guard();
let d = builtin("oiio");
assert!(d.probe("x.exr", None).is_none());
assert!(d
.open(&CodecStream::with_block("x.exr".to_string(), 0, None))
.is_err());
assert!(d.close().is_err());
assert_eq!(d.stream().filename(), "");
let p = test_params();
assert!(d.retrieve_video_frame(&p).is_err());
assert!(d.retrieve_video(&p).is_err());
let mut dest = [0f32; 4];
assert!(d
.retrieve_audio(
&mut dest,
&TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
48000,
0x3
)
.is_err());
assert!(d
.conform_audio(&["a.pcm".to_string()], 48000, 0x3, 10, None)
.is_err());
// The placeholder keeps the conservative defaults too.
assert!(!d.hardware_decoding());
assert_eq!(d.get_audio_start_offset(), Rational::new(0, 1));
assert!(d.retrieve_video_frame_gpu(&p).unwrap().is_none());
}
#[test]
fn decoder_registry_injection_wins_and_restores() {
let _g = registry_guard();
set_test_decoders(vec![Arc::new(DefaultsOnly)]);
assert_eq!(
create_from_id("defaults").map(|d| d.id()).as_deref(),
Some("defaults")
);
assert!(create_from_id("ffmpeg").is_none());
set_test_decoders(Vec::new());
assert!(create_from_id("ffmpeg").is_some());
}
/// The injection restore is panic-safe: the guard clears the registry
/// on unwind, so a failing assertion cannot leave the fake decoder
/// installed for later tests in the same process.
#[test]
fn decoder_registry_clears_after_a_panicking_injection() {
let panicked = std::panic::catch_unwind(|| {
let _g = registry_guard();
set_test_decoders(vec![Arc::new(DefaultsOnly)]);
assert!(
create_from_id("defaults").is_some(),
"the fake decoder is installed"
);
panic!("simulated assertion failure after injection");
});
assert!(panicked.is_err(), "the closure must panic");
let _g = registry_guard();
assert!(
create_from_id("ffmpeg").is_some(),
"no fake decoder may leak into the next test"
);
assert!(create_from_id("defaults").is_none());
}
}
+ /// Held for the test's duration; never read, only dropped.
+ set_test_decoders(Vec::new());
+85 -3
View File
@@ -121,6 +121,31 @@ pub fn set_test_encoders(list: Vec<Arc<dyn Encoder>>) {
*store.lock().unwrap() = list;
}
/// Serializes every test that reads the built-in encoder registry and
/// clears the test injection on drop: a panicking assertion must not leak
/// fake encoders into the process-wide registry used by later tests. The
/// held [`crate::TestLock`] is released only after the clear.
#[cfg(test)]
struct RegistryGuard {
/// Held for the test's duration; never read, only dropped.
_lock: crate::TestLock,
}
#[cfg(test)]
impl Drop for RegistryGuard {
fn drop(&mut self) {
set_test_encoders(Vec::new());
}
}
/// Take the shared test lock and return the guard.
#[cfg(test)]
fn registry_guard() -> RegistryGuard {
RegistryGuard {
_lock: crate::lock_tests(),
}
}
/// `Encoder::create_from_params` — instantiate an encoder for `params`.
///
/// # CPP-PARITY
@@ -251,6 +276,7 @@ mod tests {
#[test]
fn create_from_params_maps_formats() {
let _guard = registry_guard();
let mut p = EncodingParams::default();
// FFmpeg-backed containers.
@@ -346,7 +372,63 @@ mod tests {
String::new()
}
}
#[test]
fn encoder_trait_defaults_are_conservative() {
let e = UnimplementedDummy;
assert!(!e.supports_video());
assert!(!e.supports_audio());
assert!(!e.supports_subtitles());
assert!(!e.supports_image_sequences());
assert!(!e.is_configurable());
}
#[test]
fn encoder_registry_injection_wins_and_restores() {
let _guard = registry_guard();
// Unknown format: the built-in mapping returns None.
let p = EncodingParams {
format: 999,
..EncodingParams::default()
};
assert!(create_from_params(&p).is_none());
set_test_encoders(vec![Arc::new(UnimplementedDummy)]);
let injected = create_from_params(&p).expect("injected encoder wins");
assert_eq!(injected.id(), "dummy");
set_test_encoders(Vec::new());
assert!(create_from_params(&p).is_none());
}
/// The injection restore is panic-safe: the guard clears the registry
/// on unwind, so a failing assertion cannot leave the fake encoder
/// installed for later tests in the same process.
#[test]
fn encoder_registry_clears_after_a_panicking_injection() {
let panicked = std::panic::catch_unwind(|| {
let _guard = registry_guard();
set_test_encoders(vec![Arc::new(UnimplementedDummy)]);
let p = EncodingParams {
format: 999,
..EncodingParams::default()
};
assert_eq!(
create_from_params(&p).map(|e| e.id()).as_deref(),
Some("dummy"),
"the fake encoder is installed"
);
panic!("simulated assertion failure after injection");
});
assert!(panicked.is_err(), "the closure must panic");
let _guard = registry_guard();
let p = EncodingParams {
format: 999,
..EncodingParams::default()
};
assert!(
create_from_params(&p).is_none(),
"no fake encoder may leak into the next test"
);
}
}
+ /// Held for the test's duration; never read, only dropped.
+ set_test_encoders(Vec::new());
+ _lock: crate::lock_tests(),
+62 -1
View File
@@ -1118,4 +1118,65 @@ mod tests {
assert_eq!(offset_of!(EncodingParams, color_range), 1548);
}
}
assert_eq!(offset_of!(EncodingParams, color_range), 1548);
#[cfg(test)]
mod tests_coverage {
use super::*;
#[test]
fn load_rejects_malformed_and_foreign_documents() {
let mut p = EncodingParams::default();
for doc in [
"",
"<export",
"<export x=1></export>",
"<export x=\"1></export>",
"<export></other>",
"<export><video/>text</export>",
"<not-export/>",
] {
assert!(p.load(doc).is_err(), "must reject: {doc}");
}
}
#[test]
fn load_maps_video_audio_and_falls_back_on_unknown_codes() {
let mut p = EncodingParams::default();
p.load(
r#"<export><filename>out.mov</filename><format>2</format>
<video enabled="1"><codec>4</codec><width>1920</width><height>1080</height>
<format>4</format><timebase>30000/1001</timebase>
<pixelaspect>1/1</pixelaspect><vscale>2</vscale></video>
<audio enabled="0"><format>5</format><bitrate>192000</bitrate></audio>
</export>"#,
)
.unwrap();
assert_eq!(p.extension(), "mp4", "format 2 is MPEG-4 video");
assert_eq!(p.video_width, 1920);
assert_eq!(p.video_height, 1080);
assert_eq!(p.video_pixel_format, PixelFormat::F32);
assert_eq!(p.video_time_base_num, 30000);
assert_eq!(p.video_time_base_den, 1001);
assert_eq!(p.video_scaling_method, VideoScalingMethod::Crop);
assert_eq!(p.audio_sample_format, SampleFormat::F64Planar);
assert_eq!(p.audio_bit_rate, 192000);
// Unknown numeric codes fall back to Invalid / Stretch.
let mut p = EncodingParams::default();
p.load(
r#"<export><video><format>99</format><vscale>99</vscale></video>
<audio><format>99</format></audio></export>"#,
)
.unwrap();
assert_eq!(p.video_pixel_format, PixelFormat::Invalid);
assert_eq!(p.video_scaling_method, VideoScalingMethod::Stretch);
assert_eq!(p.audio_sample_format, SampleFormat::Invalid);
// -1 is the explicit Invalid code; vscale 0 is Fit.
let mut p = EncodingParams::default();
p.load(r#"<export><video><format>-1</format><vscale>0</vscale></video></export>"#)
.unwrap();
assert_eq!(p.video_pixel_format, PixelFormat::Invalid);
assert_eq!(p.video_scaling_method, VideoScalingMethod::Fit);
}
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -214,4 +214,12 @@ mod tests {
mgr.clear();
assert_eq!(mgr.pool.lock().unwrap().len(), 0);
}
#[test]
fn instance_is_a_process_singleton() {
let a = FrameManager::instance();
let b = FrameManager::instance();
assert!(std::ptr::eq(a, b));
a.clear();
}
}
+37 -13
View File
@@ -72,12 +72,37 @@ pub const CONFIG_KEY_HARDWARE_DECODING: &str = "HardwareDecoding";
static UNAVAILABLE_DEVICES: [std::sync::atomic::AtomicBool; 64] =
[const { std::sync::atomic::AtomicBool::new(false) }; 64];
/// Test-only counter of `open_hw_accel` device-context creation attempts
/// (incremented at the top of the function, before any FFmpeg call). Lets
/// tests prove the negative cache short-circuits before FFmpeg is
/// involved.
#[cfg(test)]
static CREATE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
thread_local! {
/// Test-only counter of `open_hw_accel` device-context creation
/// attempts on the calling thread (incremented at the top of the
/// function, before any FFmpeg call). Lets tests prove the negative
/// cache short-circuits before FFmpeg is involved.
///
/// Thread-local on purpose: the real-media tests decode on other
/// threads *without* the shared test lock, so a process-wide counter
/// would let their attempts perturb the negative-cache assertion on
/// this thread.
static CREATE_ATTEMPTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// The calling thread's creation-attempt count (tests only).
#[cfg(test)]
fn creation_attempts() -> u64 {
CREATE_ATTEMPTS.with(std::cell::Cell::get)
}
/// Count one creation attempt on the calling thread (tests only).
#[cfg(test)]
fn note_creation_attempt() {
CREATE_ATTEMPTS.with(|count| count.set(count.get() + 1));
}
/// Reset the calling thread's creation-attempt count (tests only).
#[cfg(test)]
fn reset_creation_attempts() {
CREATE_ATTEMPTS.with(|count| count.set(0));
}
/// Whether `device_type` is known unavailable — a device-context
/// creation failed once earlier in this process.
@@ -178,9 +203,7 @@ pub fn open_hw_accel(
return None;
}
#[cfg(test)]
{
CREATE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
note_creation_attempt();
let mut context = ffmpeg::codec::Context::from_parameters(params.clone()).ok()?;
let mut device: *mut sys::AVBufferRef = std::ptr::null_mut();
// SAFETY: `device` is a valid out-pointer; on success it owns the
@@ -329,6 +352,10 @@ mod tests {
/// boxes without the driver).
#[test]
fn negative_cache_skips_marked_device() {
// Serialize with the media tests that hold the shared test lock
// (the counter itself is thread-local, but the negative cache is
// process-wide).
let _lock = crate::lock_tests();
// VDPAU is not a candidate on any supported platform, so marking
// it cannot disturb the platform tests in this process (e.g. the
// macOS VideoToolbox test above).
@@ -342,11 +369,11 @@ mod tests {
let params = fstream.parameters();
let codec = ffmpeg::decoder::find(params.id()).expect("software h264 codec");
CREATE_ATTEMPTS.store(0, std::sync::atomic::Ordering::Relaxed);
reset_creation_attempts();
let opened = open_hw_accel(&params, codec, dev);
assert!(opened.is_none(), "marked device must not open");
assert_eq!(
CREATE_ATTEMPTS.load(std::sync::atomic::Ordering::Relaxed),
creation_attempts(),
0,
"marked device must be skipped before any creation attempt"
);
@@ -365,6 +392,3 @@ mod tests {
assert!(!device_unavailable(dev));
}
}
+ /// would let their attempts perturb the negative-cache assertion on
+#[cfg(test)]
+#[cfg(test)]
+83 -1
View File
@@ -769,4 +769,86 @@ mod tests_extra {
);
}
}
);
#[cfg(test)]
mod tests_coverage {
use super::*;
#[test]
fn proxy_state_numeric_and_string_conversions() {
assert_eq!(ProxyState::try_from(0), Ok(ProxyState::Missing));
assert_eq!(ProxyState::try_from(1), Ok(ProxyState::Generating));
assert_eq!(ProxyState::try_from(2), Ok(ProxyState::Ready));
assert_eq!(ProxyState::try_from(3), Ok(ProxyState::Failed));
assert_eq!(ProxyState::try_from(4), Err(()));
assert_eq!(ProxyState::try_from(-1), Err(()));
for state in [
ProxyState::Missing,
ProxyState::Generating,
ProxyState::Ready,
ProxyState::Failed,
] {
let name = ProxyManager::proxy_state_to_string(state);
assert_eq!(ProxyManager::proxy_state_from_string(&name), state);
assert_eq!(
ProxyManager::proxy_state_from_string(&(state as i32).to_string()),
state
);
}
// Whitespace is trimmed; unknown names fall back to Missing.
assert_eq!(
ProxyManager::proxy_state_from_string(" generating "),
ProxyState::Generating
);
assert_eq!(
ProxyManager::proxy_state_from_string("bogus"),
ProxyState::Missing
);
assert_eq!(ProxyManager::proxy_state_from_string(""), ProxyState::Missing);
}
#[test]
fn proxy_filename_audio_tag_detection() {
assert!(ProxyManager::proxy_filename_has_audio("/c/shot.a1.mp4"));
assert!(ProxyManager::proxy_filename_has_audio("shot.a1.mov"));
assert!(!ProxyManager::proxy_filename_has_audio("/c/shot.a2.mp4"));
assert!(!ProxyManager::proxy_filename_has_audio("/c/shot.mp4"));
assert!(!ProxyManager::proxy_filename_has_audio(""));
}
#[test]
fn get_proxy_state_reads_the_filesystem() {
assert_eq!(ProxyManager::get_proxy_state(""), ProxyState::Missing);
let dir = std::env::temp_dir().join(format!("oakcodec-proxy-coverage-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A finished proxy exists on disk.
let proxy = dir.join("clip.mp4");
std::fs::write(&proxy, b"proxy").unwrap();
assert_eq!(
ProxyManager::get_proxy_state(&proxy.to_string_lossy()),
ProxyState::Ready
);
// Only the working file exists: still generating.
let pending = dir.join("pending.mp4");
let working =
ProxyManager::get_working_filename(&pending.to_string_lossy()).unwrap();
std::fs::write(&working, b"partial").unwrap();
assert_eq!(
ProxyManager::get_proxy_state(&pending.to_string_lossy()),
ProxyState::Generating
);
// Neither file exists.
assert_eq!(
ProxyManager::get_proxy_state(&dir.join("none.mp4").to_string_lossy()),
ProxyState::Missing
);
std::fs::remove_dir_all(&dir).ok();
}
}
+900 -2
View File
@@ -2899,11 +2899,38 @@ mod tests {
);
}
/// Restores an environment variable on drop, so a test that toggles
/// process-wide configuration cannot leak the value into parallel
/// tests or into a later test when an assertion panics.
struct EnvVarGuard {
key: &'static str,
saved: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let saved = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, saved }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn user_config_env_override() {
std::env::set_var("OAK_RENDER_BACKEND", "vulkan");
// The tests that let the user config pick the shared context read
// the same variable; hold their lock while it is overridden.
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarGuard::set("OAK_RENDER_BACKEND", "vulkan");
assert_eq!(BackendKind::from_user_config(), BackendKind::Vulkan);
std::env::remove_var("OAK_RENDER_BACKEND");
}
#[test]
@@ -2966,6 +2993,9 @@ mod tests {
/// that has created a texture is no longer replaceable.
#[test]
fn shared_slot_replaces_an_unused_engine_context() {
// The same shared-state lock as the other slot tests
// (`shared_slot_host_marking_and_queries`): the slot is process-wide.
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(base) = any_gpu() else {
return;
};
@@ -3325,6 +3355,42 @@ mod tests {
ctx.destroy_texture(dst);
}
/// The bit-depth-aware YUV transform: the 8-bit variant matches the
/// exact 8-bit code-value expansion (proved against ffmpeg/swscale on
/// real media), and the 16-bit variant is bit-identical with the
/// original `from_matrix` the M2 pass was validated with.
#[test]
fn yuv_transform_depth_matches_reference_math() {
use crate::colormath::YuvMatrix;
let t8 = YuvTransform::from_matrix_depth(YuvMatrix::Bt709, false, 8);
// demo.mp4 @ (960,540): Y=145, U=117, V=164 (limited BT.709).
let (y, u, v) = (145.0f32 / 255.0, 117.0 / 255.0, 164.0 / 255.0);
let rgb = [
t8.matrix[0][0] * y + t8.matrix[0][2] * v + t8.offset[0],
t8.matrix[1][0] * y + t8.matrix[1][1] * u + t8.matrix[1][2] * v + t8.offset[1],
t8.matrix[2][0] * y + t8.matrix[2][1] * u + t8.offset[2],
];
let expect = [0.8421f32, 0.5230, 0.4979];
for c in 0..3 {
assert!(
(rgb[c] - expect[c]).abs() < 1e-3,
"channel {c}: {} vs {}",
rgb[c],
expect[c]
);
}
// The depth-16 constructor is exactly the original formula.
for matrix in [YuvMatrix::Bt601, YuvMatrix::Bt709, YuvMatrix::Bt2020] {
for full in [false, true] {
assert_eq!(
YuvTransform::from_matrix(matrix, full),
YuvTransform::from_matrix_depth(matrix, full, 16)
);
}
}
}
/// The generic color LUT pass (graph `ColorTransformJob`): trilinear
/// LUT application into an `Rgba32Float` texture, GPU→GPU.
#[test]
@@ -3507,4 +3573,836 @@ mod tests {
let mut other = r.create_texture(&pod2, None).unwrap();
assert!(r.blit_color_managed(Some(&src), &mut other, None).is_err());
}
// ---- Branch-coverage fill-ins ------------------------------------------
#[test]
fn backend_kind_strings_cover_every_variant() {
for (kind, s) in [
(BackendKind::Auto, "auto"),
(BackendKind::Metal, "metal"),
(BackendKind::Vulkan, "vulkan"),
(BackendKind::Gl, "opengl"),
(BackendKind::Cpu, "cpu"),
] {
assert_eq!(kind.to_config_string(), s);
assert_eq!(BackendKind::from_config_string(s), kind);
}
// The parser trims whitespace and folds case.
assert_eq!(
BackendKind::from_config_string(" VULKAN "),
BackendKind::Vulkan
);
// `Auto` and `Metal` share the same fallback list; `Cpu` has none.
assert_eq!(
BackendKind::Auto.wgpu_fallbacks(),
BackendKind::Metal.wgpu_fallbacks()
);
assert_eq!(
BackendKind::Vulkan.wgpu_fallbacks().first(),
Some(&wgpu::Backends::VULKAN)
);
assert_eq!(
BackendKind::Gl.wgpu_fallbacks().first(),
Some(&wgpu::Backends::GL)
);
assert!(BackendKind::Cpu.wgpu_fallbacks().is_empty());
}
/// The `OAK_REQUIRE_GPU` policy is a pure parser plus two handling
/// arms. Driving both from arguments (instead of flipping the real
/// environment variable) keeps this test from racing the GPU
/// acceptance tests, which read the variable from parallel threads and
/// would panic if they observed a transient `1`.
#[test]
fn require_gpu_adapter_parses_env_values() {
for (value, expected) in [
(None, false), // unset means "skipping is allowed"
(Some("0"), false),
(Some("false"), false),
(Some("FALSE"), false),
(Some("1"), true),
(Some("yes"), true),
(Some(""), true),
] {
assert_eq!(
require_gpu_from_value(value),
expected,
"value {value:?}"
);
}
// The soft-skip arm logs and returns instead of failing.
skip_or_fail_gpu_with(false, "a coverage probe");
// The hard-fail arm panics when a GPU is required.
let panicked =
std::panic::catch_unwind(|| skip_or_fail_gpu_with(true, "a coverage probe"));
assert!(
panicked.is_err(),
"a required GPU must hard-fail a missing adapter"
);
}
#[test]
fn shared_slot_host_marking_and_queries() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// `mark_host_context` creates the process context on first use and
// marks it as the host's render device; the three query helpers
// then agree on it.
let marked = GpuContext::mark_host_context();
assert_eq!(marked, GpuContext::shared().is_some());
assert_eq!(GpuContext::host_gpu_installed(), marked);
assert_eq!(GpuContext::shared_is_installed(), marked);
}
#[test]
fn future_executor_drives_pending_futures() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
/// Pending once (forcing the executor's yield arm), then ready;
/// also clones the waker to exercise the no-op vtable.
struct CloneWakerThenYield {
yielded: bool,
}
impl Future for CloneWakerThenYield {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
let waker = cx.waker().clone();
assert!(waker.will_wake(cx.waker()));
drop(waker);
if self.yielded {
Poll::Ready(42)
} else {
self.yielded = true;
Poll::Pending
}
}
}
assert_eq!(
pollster_block_on(CloneWakerThenYield { yielded: false }),
42
);
// The packing helper is little-endian f32 bytes.
assert_eq!(f32_uniform_bytes(&[1.0f32]), vec![0x00, 0x00, 0x80, 0x3f]);
}
#[test]
fn frame_from_pixels_for_upload_validates_arguments() {
let mut frame = Frame::new();
let pod = VideoParamsPod {
width: 2,
height: 2,
..Default::default()
};
frame.set_video_params(pod);
frame.allocate();
let stride = frame.linesize_bytes();
// Null pointer / non-positive geometry.
assert!(unsafe { frame_from_pixels_for_upload((2, 2), std::ptr::null(), stride) }.is_err());
assert!(
unsafe { frame_from_pixels_for_upload((0, 2), frame.data.as_ptr(), stride) }.is_err()
);
assert!(
unsafe { frame_from_pixels_for_upload((2, -1), frame.data.as_ptr(), stride) }.is_err()
);
// Stride must match the F32 line size.
assert!(unsafe { frame_from_pixels_for_upload((2, 2), frame.data.as_ptr(), 3) }.is_err());
// The valid shape copies the pixels.
let built =
unsafe { frame_from_pixels_for_upload((2, 2), frame.data.as_ptr(), stride) }.unwrap();
assert_eq!(built.width, 2);
assert_eq!(built.height, 2);
assert_eq!(built.data.len(), frame.data.len());
}
#[test]
fn gpu_context_accessors_and_registry_errors() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
// Engine-created contexts host a non-CPU adapter and are unused
// until the first texture.
assert!(ctx.is_gpu());
assert_ne!(ctx.kind(), BackendKind::Cpu);
assert!(!ctx.is_adopted());
assert!(!ctx.is_used());
let _ = ctx.is_filterable();
let (device, queue) = ctx.device_queue();
assert!(Arc::strong_count(&device) >= 1);
assert!(Arc::strong_count(&queue) >= 1);
// Invalid create parameters are rejected before any GPU work.
assert_eq!(
ctx.create_texture(0, 4).unwrap_err().code(),
Error::Invalid.code()
);
assert_eq!(
ctx.create_texture(4, -1).unwrap_err().code(),
Error::Invalid.code()
);
assert_eq!(
ctx.create_texture_format(
4,
4,
0,
wgpu::TextureFormat::R8Unorm,
wgpu::TextureUsages::TEXTURE_BINDING,
)
.unwrap_err()
.code(),
Error::Invalid.code()
);
let token = ctx.create_texture(4, 3).unwrap();
assert!(ctx.is_used());
assert_eq!(ctx.texture_size(token), Some((4, 3)));
assert_eq!(
ctx.texture_format(token),
Some(wgpu::TextureFormat::Rgba32Float)
);
assert!(ctx.texture_handle(token).is_some());
assert_eq!(ctx.texture_size(999999), None);
assert_eq!(ctx.texture_format(999999), None);
assert!(!ctx.has_texture(999999));
// The placeholder is created once and cached.
let placeholder = ctx.placeholder_texture().unwrap();
assert_eq!(ctx.placeholder_texture().unwrap(), placeholder);
assert!(ctx.has_texture(placeholder));
// Clearing an existing texture succeeds; a missing token reports
// NotFound.
ctx.clear_texture(token).unwrap();
assert_eq!(
ctx.clear_texture(999999).unwrap_err().code(),
Error::NotFound.code()
);
// The trait-object surface used by fakes/hardware import.
let like: &dyn GpuContextLike = ctx.as_ref();
assert_eq!(like.kind(), ctx.kind());
assert!(like.as_any().is_some());
assert!(like.texture_handle(placeholder).is_some());
}
#[test]
fn gpu_plane_upload_formats_and_bounds() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST;
// Every single-channel format the bpp table accepts (R16Float
// covers the 2-byte arm; R16Unorm requires an optional device
// feature the context does not enable).
let r8 = ctx
.create_texture_format(4, 2, 1, wgpu::TextureFormat::R8Unorm, usage)
.unwrap();
ctx.upload_plane(r8, &[7u8; 8]).unwrap();
let r16f = ctx
.create_texture_format(4, 2, 1, wgpu::TextureFormat::R16Float, usage)
.unwrap();
ctx.upload_plane(r16f, &[0u8; 16]).unwrap();
let r32f = ctx
.create_texture_format(4, 2, 1, wgpu::TextureFormat::R32Float, usage)
.unwrap();
ctx.upload_plane(r32f, &[0u8; 32]).unwrap();
// A short buffer and an unsupported format are rejected.
assert_eq!(
ctx.upload_plane(r8, &[0u8; 7]).unwrap_err().code(),
Error::Invalid.code()
);
let rgba = ctx
.create_texture_format(4, 2, 1, wgpu::TextureFormat::Rgba8Unorm, usage)
.unwrap();
assert_eq!(
ctx.upload_plane(rgba, &[0u8; 32]).unwrap_err().code(),
Error::Invalid.code()
);
// Missing tokens report NotFound.
assert_eq!(
ctx.upload_plane(999999, &[0u8; 8]).unwrap_err().code(),
Error::NotFound.code()
);
assert_eq!(
ctx.download(999999).unwrap_err().code(),
Error::NotFound.code()
);
// Only Rgba32Float/Rgba16Float are readable back.
let err = ctx.download(r8).unwrap_err();
assert!(
err.to_string().contains("unsupported format"),
"the format error is named: {err}"
);
}
#[test]
fn gpu_lut_lifecycle_cache_and_validation() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
let lut = crate::lut::Lut3d::build(3, [0.0; 3], [1.0; 3], |c| [c[1], c[2], c[0]]);
// Installing twice replaces (and destroys) the previous LUT texture.
ctx.set_display_lut(&lut).unwrap();
assert!(ctx.has_display_lut());
ctx.set_display_lut(&lut).unwrap();
assert!(ctx.has_display_lut());
let src = ctx.create_texture(2, 2).unwrap();
// Two present calls: the first builds the Rgba16Float pipeline, the
// second reuses the cached one.
let presented = ctx.present_texture(src).unwrap();
let presented2 = ctx.present_texture(src).unwrap();
assert_ne!(presented, presented2);
// The caller-keyed cache uploads once per key.
reset_gpu_transfer_counters();
let first = ctx.apply_color_lut(src, "swap", &lut).unwrap();
assert_eq!(gpu_transfer_counters(), (1, 0));
reset_gpu_transfer_counters();
let again = ctx.apply_color_lut(src, "swap", &lut).unwrap();
assert_eq!(gpu_transfer_counters(), (0, 0));
assert_ne!(first, again);
// Nine more keys exceed the 8-entry cache: the oldest is evicted.
for i in 0..9 {
ctx.apply_color_lut(src, &format!("key-{i}"), &lut).unwrap();
}
// The original key was evicted and re-uploads.
reset_gpu_transfer_counters();
ctx.apply_color_lut(src, "swap", &lut).unwrap();
assert_eq!(gpu_transfer_counters(), (1, 0));
// A malformed LUT is rejected before any upload.
let mut malformed = lut.clone();
malformed.data.truncate(3);
assert_eq!(
ctx.upload_lut(&malformed).unwrap_err().code(),
Error::Invalid.code()
);
// An unknown source texture is NotFound.
assert_eq!(
ctx.apply_color_lut(999999, "swap", &lut)
.unwrap_err()
.code(),
Error::NotFound.code()
);
}
#[test]
fn gpu_upload_download_and_blit_validate_arguments() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
let token = ctx.create_texture(4, 4).unwrap();
let other = ctx.create_texture(4, 4).unwrap();
let mut frame = Frame::new();
let pod = VideoParamsPod {
width: 4,
height: 4,
..Default::default()
};
frame.set_video_params(pod);
frame.allocate();
ctx.upload(token, &frame).unwrap();
// Non-F32 input.
let mut wrong_format = frame.clone();
wrong_format.format = PixelFormat::U8;
assert_eq!(
ctx.upload(token, &wrong_format).unwrap_err().code(),
Error::Invalid.code()
);
// Geometry mismatch with the texture.
let mut wrong_size = Frame::new();
let small = VideoParamsPod {
width: 2,
height: 4,
..Default::default()
};
wrong_size.set_video_params(small);
wrong_size.allocate();
assert_eq!(
ctx.upload(token, &wrong_size).unwrap_err().code(),
Error::Invalid.code()
);
// Declared F32 geometry but truncated pixels.
let mut truncated = frame.clone();
truncated.data.truncate(frame.linesize_bytes() * 4 - 1);
assert_eq!(
ctx.upload(token, &truncated).unwrap_err().code(),
Error::Invalid.code()
);
// Unknown tokens.
assert_eq!(
ctx.upload(999999, &frame).unwrap_err().code(),
Error::NotFound.code()
);
// The trait-object upload/download/blit paths used by callers that
// only know `GpuContextLike`.
let like: &dyn GpuContextLike = ctx.as_ref();
like.upload(token, &frame).unwrap();
let out = like.download(token).unwrap();
assert_eq!(out.data, frame.data);
like.blit(token, other, None).unwrap();
assert!(like
.blit(
token,
other,
Some(&crate::color::ColorProcessor::pass_through())
)
.is_err());
// Unknown blit endpoints are NotFound.
assert_eq!(
ctx.blit(999999, other, None).unwrap_err().code(),
Error::NotFound.code()
);
assert_eq!(
ctx.blit(token, 999999, None).unwrap_err().code(),
Error::NotFound.code()
);
}
#[test]
fn gpu_compile_and_run_shader_pass() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
let wgsl = r#"
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_smp: sampler;
@fragment
fn main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
return textureLoad(src_tex, vec2<i32>(i32(frag.x), i32(frag.y)), 0);
}
"#;
let program = ctx
.compile_shader_pass("test/pass", wgsl, 1, false, false)
.unwrap();
assert_eq!(program.texture_count, 1);
assert!(!program.has_uniforms);
assert!(!program.filtering);
// The program cache returns the same compiled pass.
let cached = ctx
.compile_shader_pass("test/pass", wgsl, 1, false, false)
.unwrap();
assert!(Arc::ptr_eq(&program, &cached));
let src = ctx.create_texture(2, 2).unwrap();
let dst = ctx.create_texture(2, 2).unwrap();
// The input texture count must match the compiled layout.
assert_eq!(
ctx.run_shader_pass(&program, &[], &[], dst)
.unwrap_err()
.code(),
Error::Invalid.code()
);
// Missing input/destination textures are NotFound.
assert_eq!(
ctx.run_shader_pass(&program, &[], &[999999], dst)
.unwrap_err()
.code(),
Error::NotFound.code()
);
assert_eq!(
ctx.run_shader_pass(&program, &[], &[src], 999999)
.unwrap_err()
.code(),
Error::NotFound.code()
);
ctx.run_shader_pass(&program, &[], &[src], dst).unwrap();
// A uniform-declaring, filtering pass; an empty uniform block is
// still uploaded as the minimum 16-byte binding.
let with_uniform = r#"
struct U { v: vec4<f32> };
@group(0) @binding(0) var<uniform> u: U;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_smp: sampler;
@fragment
fn main() -> @location(0) vec4<f32> { return u.v; }
"#;
let uniform_program = ctx
.compile_shader_pass("test/uniform", with_uniform, 1, true, true)
.unwrap();
assert!(uniform_program.has_uniforms);
assert!(uniform_program.filtering);
ctx.run_shader_pass(&uniform_program, &[], &[src], dst)
.unwrap();
ctx.run_shader_pass(&uniform_program, &[0u8; 16], &[src], dst)
.unwrap();
// A pipeline that fails device validation is a fallible result (the
// validation error scope), not a panic: this shader parses but has
// no `main` fragment entry point.
let bad = ctx.compile_shader_pass(
"test/bad",
"@fragment fn not_main() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }",
0,
false,
false,
);
assert!(bad.is_err(), "a missing entry point must fail the compile");
}
#[test]
fn gpu_yuv_and_planar_passes_cover_error_arms() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST;
let (w, h) = (4u32, 2u32);
// Planar pass: R16Float luma + interleaved RG8 chroma.
let y = ctx
.create_texture_format(w as i32, h as i32, 1, wgpu::TextureFormat::R16Float, usage)
.unwrap();
let uv = ctx
.create_texture_format(w as i32, h as i32, 1, wgpu::TextureFormat::Rg8Unorm, usage)
.unwrap();
let dst = ctx.create_texture(w as i32, h as i32).unwrap();
let y_data: Vec<u8> = (0..w * h)
.flat_map(|_| half::f16::from_f32(0.5).to_bits().to_le_bytes())
.collect();
ctx.upload_plane(y, &y_data).unwrap();
// upload_plane has no Rg8 entry; write the chroma plane directly
// (neutral 128 = limited-range zero chroma).
let uv_data = vec![128u8; (w * h * 2) as usize];
ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &ctx.texture_handle(uv).unwrap(),
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&uv_data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(w * 2),
rows_per_image: None,
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
let transform = YuvTransform::bt709_limited();
// Two runs reuse the cached planar pipeline.
ctx.run_planar_yuv_to_rgb(y, uv, dst, &transform).unwrap();
ctx.run_planar_yuv_to_rgb(y, uv, dst, &transform).unwrap();
let out = ctx.download(dst).unwrap();
assert_eq!(out.data.len(), (w * h) as usize * 16);
assert!(
out.data.iter().any(|&b| b != 0),
"the planar pass writes pixels"
);
// Missing plane/destination tokens are NotFound.
assert_eq!(
ctx.run_planar_yuv_to_rgb(999999, uv, dst, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
assert_eq!(
ctx.run_planar_yuv_to_rgb(y, 999999, dst, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
assert_eq!(
ctx.run_planar_yuv_to_rgb(y, uv, 999999, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
// The 3-plane entry point validates every token too.
assert_eq!(
ctx.run_yuv_to_rgb(999999, uv, uv, dst, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
assert_eq!(
ctx.run_yuv_to_rgb(y, 999999, uv, dst, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
assert_eq!(
ctx.run_yuv_to_rgb(y, uv, 999999, dst, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
assert_eq!(
ctx.run_yuv_to_rgb(y, uv, uv, 999999, &transform)
.unwrap_err()
.code(),
Error::NotFound.code()
);
}
#[test]
fn display_renderer_gpu_texture_paths() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(ctx) = any_gpu() else {
return;
};
let mut renderer = DisplayRenderer::new(BackendKind::Cpu);
// The tests inject the context directly; the public accessors must
// report it.
renderer.ctx = Some(ctx.clone());
assert!(renderer.is_initialized());
assert_eq!(renderer.backend(), BackendKind::Cpu);
assert_eq!(renderer.context().map(|c| c.kind()), Some(ctx.kind()));
let pod = VideoParamsPod {
width: 4,
height: 4,
..Default::default()
};
let mut frame = Frame::new();
frame.set_video_params(pod);
frame.allocate();
frame.data[0] = 0x5a;
// A pixel-initialized GPU texture uploads through the constructor.
let src = renderer
.create_texture(&pod, Some((frame.data.as_ptr(), frame.linesize_bytes())))
.unwrap();
assert!(matches!(src, Texture::Gpu { .. }));
let Texture::Gpu { token, .. } = &src else {
unreachable!()
};
let token = *token;
assert_eq!(crate::backend::texture_id_of(&src), token as i32);
// A mismatched initializing linesize destroys the new token and
// fails.
assert_eq!(
renderer
.create_texture(&pod, Some((frame.data.as_ptr(), 1)))
.unwrap_err()
.code(),
Error::Invalid.code()
);
// upload_texture: the GPU branch requires the frame line size.
let mut target = renderer.create_texture(&pod, None).unwrap();
unsafe {
renderer.upload_texture(&mut target, frame.data.as_ptr(), frame.linesize_bytes())
}
.unwrap();
let _ =
unsafe { renderer.upload_texture(&mut target, frame.data.as_ptr(), 1) }.unwrap_err();
// download_texture: the stride must match the frame line size.
let mut buf = vec![0u8; frame.linesize_bytes() * 4];
unsafe { renderer.download_texture(&target, buf.as_mut_ptr(), frame.linesize_bytes()) }
.unwrap();
assert_eq!(buf[0], 0x5a);
let _ = unsafe { renderer.download_texture(&target, buf.as_mut_ptr(), 1) }.unwrap_err();
// GPU→GPU blit through the renderer (same context).
let mut other = renderer.create_texture(&pod, None).unwrap();
renderer
.blit_color_managed(Some(&target), &mut other, None)
.unwrap();
// Planar textures are never uploadable and have no display id.
let y = ctx
.create_texture_format(
4,
2,
1,
wgpu::TextureFormat::R8Unorm,
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
)
.unwrap();
let uv = ctx
.create_texture_format(
4,
2,
1,
wgpu::TextureFormat::Rg8Unorm,
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
)
.unwrap();
let planar = crate::texture::PlanarTexture::new(
ctx.clone(),
crate::texture::PlanarFormat::Nv12,
(4, 2),
(y, uv),
YuvTransform::bt709_limited(),
(1, 1),
);
let mut planar_texture = Texture::wrap_planar(planar);
assert_eq!(crate::backend::texture_id_of(&planar_texture), 0);
assert!(planar_texture.is_planar());
assert!(planar_texture.to_frame().is_err());
let _ = unsafe { renderer.upload_texture(&mut planar_texture, std::ptr::null(), 0) }
.unwrap_err();
// Mixed CPU/GPU blits are rejected in both directions.
let cpu_renderer = DisplayRenderer::new(BackendKind::Cpu);
let mut cpu_src = cpu_renderer.create_texture(&pod, None).unwrap();
assert_eq!(
renderer
.blit_color_managed(Some(&cpu_src), &mut other, None)
.unwrap_err()
.code(),
crate::error::OAKCORE_E_FAILED
);
assert!(renderer
.blit_color_managed(Some(&target), &mut cpu_src, None)
.is_err());
// Cross-backend readback: CPU renderers have no GPU registry.
let mut readback = vec![0u8; frame.linesize_bytes() * 4];
assert_eq!(
unsafe {
cpu_renderer.download_from_texture(
token as i32,
&pod,
readback.as_mut_ptr(),
frame.linesize_bytes(),
)
}
.unwrap_err()
.code(),
crate::error::OAKCORE_E_FAILED
);
// The GPU renderer downloads by id; the stride must be F32 RGBA.
unsafe {
renderer.download_from_texture(
token as i32,
&pod,
readback.as_mut_ptr(),
frame.linesize_bytes(),
)
}
.unwrap();
assert_eq!(readback[0], 0x5a);
assert!(unsafe {
renderer.download_from_texture(token as i32, &pod, readback.as_mut_ptr(), 4)
}
.is_err());
}
#[test]
fn display_renderer_cpu_pixel_initialization() {
let renderer = DisplayRenderer::new(BackendKind::Cpu);
let pod = VideoParamsPod {
width: 2,
height: 2,
..Default::default()
};
let mut frame = Frame::new();
frame.set_video_params(pod);
frame.allocate();
frame.data[3] = 0x33;
// CPU textures accept pixel data with a matching line size.
let texture = renderer
.create_texture(&pod, Some((frame.data.as_ptr(), frame.linesize_bytes())))
.unwrap();
let Texture::Cpu(cpu) = &texture else {
unreachable!()
};
assert_eq!(cpu.data[3], 0x33);
// A mismatched line size is rejected (nothing was allocated).
assert_eq!(
renderer
.create_texture(&pod, Some((frame.data.as_ptr(), 3)))
.unwrap_err()
.code(),
Error::Invalid.code()
);
}
#[test]
fn display_bit_depth_from_user_config_reads_the_store() {
// The store defaults to 10-bit when the key is missing; the call
// must never panic and always resolve a depth.
let depth = DisplayBitDepth::from_user_config();
assert!(matches!(
depth,
DisplayBitDepth::Bit8 | DisplayBitDepth::Bit10
));
assert_eq!(
depth.to_config_string(),
if depth == DisplayBitDepth::Bit8 {
"8"
} else {
"10"
}
);
}
/// A minimal trait-only context: the default `as_any`/`texture_handle`
/// methods must return `None` (callers then fall back to CPU delivery).
struct FakeContext;
impl GpuContextLike for FakeContext {
fn kind(&self) -> BackendKind {
BackendKind::Cpu
}
fn destroy_texture(&self, _token: u64) {}
fn upload(&self, _token: u64, _frame: &Frame) -> Result<()> {
Ok(())
}
fn download(&self, _token: u64) -> Result<Frame> {
Ok(Frame::dummy())
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&crate::color::ColorProcessor>,
) -> Result<()> {
Ok(())
}
}
#[test]
fn trait_only_context_uses_the_default_downcast_hooks() {
let fake = FakeContext;
assert_eq!(fake.kind(), BackendKind::Cpu);
assert!(fake.as_any().is_none());
assert!(fake.texture_handle(1).is_none());
let texture = Texture::gpu(Arc::new(FakeContext), 7, 2, 2, PixelFormat::F32);
assert_eq!(texture_id_of(&texture), 7);
assert!(format!("{texture:?}").contains("Texture::Gpu"));
}
#[test]
fn display_renderer_init_uses_the_shared_context_for_the_user_backend() {
let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A renderer whose backend equals the user's configured choice
// adopts the process-wide shared context (one device per process).
let kind = BackendKind::from_user_config();
let mut renderer = DisplayRenderer::new(kind);
let result = renderer.init(std::ptr::null_mut());
assert_eq!(
result.is_ok(),
GpuContext::shared().is_some(),
"init follows the shared slot"
);
assert_eq!(renderer.is_initialized(), result.is_ok());
}
}
+659 -44
View File
@@ -765,6 +765,22 @@ mod tests {
CONFIG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// A display-class ICC profile from the usual system locations, if any
/// (macOS ColorSync, Linux colord/ghostscript, Debian/Ubuntu
/// `icc-profiles-free` — the CI runner installs the latter).
fn system_icc() -> Option<&'static str> {
[
"/System/Library/ColorSync/Profiles/sRGB Profile.icc",
"/System/Library/ColorSync/Profiles/Display P3.icc",
"/usr/share/color/icc/colord/sRGB.icc",
"/usr/share/color/icc/ghostscript/srgb.icc",
"/usr/share/color/icc/sRGB.icc",
"/usr/local/share/color/icc/colord/sRGB.icc",
]
.into_iter()
.find(|p| std::path::Path::new(p).exists())
}
#[test]
fn lut_extensions_case_insensitive_and_dot_optional() {
assert!(is_supported_lut_extension("cube"));
@@ -801,7 +817,7 @@ mod tests {
if set_up_default_config().is_err() {
return;
}
let valid = ColorProcessor::create("scene_linear", "sdr-video", Direction::Normal)
let valid = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal)
.and_then(|p| p.is_valid().then_some(p));
if let Some(valid) = valid {
let mut f = Frame::new();
@@ -893,6 +909,17 @@ mod tests {
let id = display_transform(&display, &view);
assert!(id.is_some());
assert!(!id.unwrap().is_empty());
// Querying a later view iterates past the non-matching ones
// (the loop's pass-through arm); an unknown view is Ok(None).
let last = config
.view_by_reference_space(
ocio_rs::SearchReferenceSpaceType::Scene,
&display,
n - 1,
)
.unwrap();
assert!(display_transform_result(&display, &last).is_ok());
assert_eq!(display_transform_result(&display, "no-such-view").unwrap(), None);
}
assert!(display_transform("no-such-display", "x").is_none());
}
@@ -942,15 +969,9 @@ mod tests {
if set_up_default_config().is_err() {
return;
}
// A display-class ICC is required; the macOS system profiles always
// have one, CI Linux/Windows runners may not — skip then.
let icc = [
"/System/Library/ColorSync/Profiles/sRGB Profile.icc",
"/System/Library/ColorSync/Profiles/Display P3.icc",
]
.into_iter()
.find(|p| std::path::Path::new(p).exists());
let Some(icc) = icc else {
// A display-class ICC is required; the usual system profile
// locations are probed (CI runners may have none — skip then).
let Some(icc) = system_icc() else {
eprintln!("no system ICC profile; skipping");
return;
};
@@ -986,18 +1007,8 @@ mod tests {
if set_up_default_config().is_err() {
return;
}
// Any display-class ICC; probe the usual macOS + Linux system profile
// locations (CI runners may have none — skip then).
let icc = [
"/System/Library/ColorSync/Profiles/sRGB Profile.icc",
"/System/Library/ColorSync/Profiles/Display P3.icc",
"/usr/share/color/icc/colord/sRGB.icc",
"/usr/share/color/icc/ghostscript/srgb.icc",
"/usr/local/share/color/icc/colord/sRGB.icc",
]
.into_iter()
.find(|p| std::path::Path::new(p).exists());
let Some(icc) = icc else {
// Any display-class ICC (CI runners may have none — skip then).
let Some(icc) = system_icc() else {
eprintln!("no system ICC profile; skipping");
return;
};
@@ -1040,20 +1051,27 @@ mod tests {
/// The exact chain the viewers use (BGRA8, display-class ICC from
/// `OAK_DISPLAY_ICC`): a mid-grey frame must NOT collapse to black —
/// the viewer-black-screen regression guard. Skipped without the env
/// var (point it at the display profile under investigation); an empty
/// value is treated as unset, same as `displayicc::env_override_icc`.
/// the viewer-black-screen regression guard. Falls back to a system
/// profile when the env var is unset (point it at the display profile
/// under investigation to override); skipped only when neither exists.
/// An empty env value is treated as unset, same as
/// `displayicc::env_override_icc`.
#[test]
fn display_icc_bgra8_never_outputs_black() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let icc = std::env::var("OAK_DISPLAY_ICC").unwrap_or_default();
if icc.is_empty() {
eprintln!("OAK_DISPLAY_ICC unset or empty; skipping");
return;
}
let env_icc = std::env::var("OAK_DISPLAY_ICC").unwrap_or_default();
let icc = if env_icc.is_empty() {
let Some(system) = system_icc() else {
eprintln!("OAK_DISPLAY_ICC unset and no system ICC profile; skipping");
return;
};
system.to_string()
} else {
env_icc
};
let p = ColorProcessor::create_display_icc_bgra8("sRGB Encoded Rec.709 (sRGB)", &icc)
.expect("handle always returned");
assert!(p.is_valid(), "BGRA8 ICC processor builds from {icc}");
@@ -1147,22 +1165,24 @@ mod tests {
fn grading_primary_and_from_processor() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping grading-primary/from-processor assertions");
return;
}
if let Some(p) = ColorProcessor::create_grading_primary(GradingStyle::Log) {
// A grading-primary processor is valid and converts.
if p.is_valid() {
let out = p.convert_color([0.18, 0.5, 0.7, 1.0]);
assert!(out.iter().all(|v| v.is_finite()));
}
}
if let Some(config) = default_config() {
if let Ok(proc) = config.processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)") {
let p = ColorProcessor::from_processor(proc);
assert!(p.is_valid());
assert!(!p.cache_id().is_empty(), "OCIO cache id present");
}
}
// A grading-primary processor is valid and converts on the live
// config (the shader test below relies on the same construction).
let p = ColorProcessor::create_grading_primary(GradingStyle::Log)
.expect("grading-primary processor on a live config");
assert!(p.is_valid(), "grading-primary processor must be valid");
let out = p.convert_color([0.18, 0.5, 0.7, 1.0]);
assert!(out.iter().all(|v| v.is_finite()));
let config = default_config().expect("config set up");
let proc = config
.processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)")
.expect("ACEScg→sRGB Encoded processor on the default config");
let p = ColorProcessor::from_processor(proc);
assert!(p.is_valid());
assert!(!p.cache_id().is_empty(), "OCIO cache id present");
}
#[test]
@@ -1249,4 +1269,599 @@ mod tests {
"analytic processor still generates"
);
}
// ---- Branch-coverage fill-ins ------------------------------------------
#[test]
fn rb_swap_matrix_builds_from_ocio() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping");
return;
}
assert!(rb_swap_matrix().is_some(), "matrix transform available");
}
#[test]
fn pass_through_processor_accessors_and_conversions() {
let p = ColorProcessor::pass_through();
assert!(p.processor().is_none());
assert_eq!(p.cache_id(), "", "pass-through has no cache id");
// BGRA8 and F32 conversions are no-ops without a CPU processor.
let mut data = [1u8, 2, 3, 4];
p.convert_bgra8(&mut data, 1).unwrap();
assert_eq!(data, [1, 2, 3, 4]);
let mut samples = [0.25f32, 0.5, 0.75, 1.0];
p.convert_f32_rgba(&mut samples, 1).unwrap();
assert_eq!(samples, [0.25, 0.5, 0.75, 1.0]);
// Negative/zero pixel counts stay harmless on the no-op path too.
p.convert_bgra8(&mut data, -3).unwrap();
}
#[test]
fn create_lut_on_config_and_inverse_direction() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping");
return;
}
let config = default_config().expect("config set up");
let lut = "TITLE oak create_lut_on test\nLUT_1D_SIZE 2\n0.0 0.0 0.0\n1.0 1.0 1.0\n";
let path = std::env::temp_dir().join(format!("oak-color-lut-on-{}.cube", std::process::id()));
std::fs::write(&path, lut).unwrap();
let path = path.to_string_lossy().into_owned();
let fwd = ColorProcessor::create_lut_on(&config, &path, Direction::Normal)
.expect("processor handle always returned");
assert!(fwd.is_valid(), "readable LUT on a specific config");
// Direction::Inverse exercises the inverse mapping (`to_ocio`) on
// the transform and the processor.
let inv = ColorProcessor::create_lut_on(&config, &path, Direction::Inverse)
.expect("processor handle always returned");
assert!(inv.is_valid(), "inverse LUT processor");
// create_lut (default config) with the inverse direction too.
let inv_default = ColorProcessor::create_lut(&path, Direction::Inverse)
.expect("processor handle always returned");
assert!(inv_default.is_valid());
// An unreadable LUT yields a pass-through handle, not None.
let missing = ColorProcessor::create_lut_on(&config, "/nonexistent/oak.cube", Direction::Normal)
.expect("processor handle always returned");
assert!(!missing.is_valid(), "unreadable LUT → pass-through");
let _ = std::fs::remove_file(&path);
}
#[test]
fn valid_processor_accessor_and_cache_id() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping processor accessor assertions");
return;
}
let config = default_config().expect("config set up");
let proc = config
.processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)")
.expect("ACEScg→sRGB Encoded processor on the default config");
let p = ColorProcessor::from_processor(proc);
assert!(p.processor().is_some(), "valid processor exposes its handle");
assert!(!p.cache_id().is_empty());
}
#[test]
fn convert_frame_rejects_non_f32_and_unaligned_buffers() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping");
return;
}
let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal)
.expect("handle always returned");
if !p.is_valid() {
eprintln!("processor unavailable in this config; skipping");
return;
}
// A valid processor only accepts the F32 pipeline format.
let mut f = Frame::new();
f.format = PixelFormat::U8;
assert_eq!(
p.convert_frame(&mut f).unwrap_err().code(),
crate::error::OAKCORE_E_INVALID
);
// F32 but a byte length that is not a multiple of 4.
let mut f = Frame::new();
f.format = PixelFormat::F32;
f.data = vec![0u8; 3];
assert!(p.convert_frame(&mut f).is_err(), "unaligned buffer rejected");
// A well-formed F32 buffer converts.
let mut f = Frame::new();
f.width = 1;
f.height = 1;
f.channels = 4;
f.format = PixelFormat::F32;
f.data = vec![0u8; 16];
p.convert_frame(&mut f).unwrap();
}
#[test]
fn display_icc_bgra8_and_xyz_bgra8_chains() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping");
return;
}
let Some(icc) = system_icc() else {
eprintln!("no system ICC profile; skipping");
return;
};
let p = ColorProcessor::create_display_icc_bgra8("sRGB Encoded Rec.709 (sRGB)", icc)
.expect("handle always returned");
assert!(p.is_valid(), "BGRA8 ICC chain builds from {icc}");
let mut data: Vec<u8> = vec![128, 128, 128, 255, 0, 0, 191, 255];
assert!(p.convert_bgra8(&mut data, 2).is_ok());
assert_eq!(data[3], 255, "alpha preserved");
assert!(data[..3].iter().any(|&b| b != 0), "grey survives");
// A short buffer is rejected before converting anything.
let mut short = vec![0u8; 3];
assert_eq!(
p.convert_bgra8(&mut short, 2).unwrap_err().code(),
crate::error::OAKCORE_E_INVALID
);
// Zero/negative counts clamp to nothing to convert.
let mut unchanged = vec![7u8; 4];
let _ = p.convert_bgra8(&mut unchanged, 0);
let _ = p.convert_bgra8(&mut unchanged, -3);
// The F32 entry point on the same chain.
let mut samples: Vec<f32> = vec![0.5, 0.5, 0.5, 1.0];
assert!(p.convert_f32_rgba(&mut samples, 1).is_ok());
assert!((samples[3] - 1.0).abs() < 1e-3, "alpha preserved");
// The XYZ BGRA8 wrapper (both R/B swizzles baked in).
let xyz = ColorProcessor::create_display_icc_xyz_bgra8(icc).expect("handle");
assert!(xyz.is_valid(), "XYZ BGRA8 ICC chain builds from {icc}");
}
/// Captures the process-wide pipeline color settings and restores them
/// on drop, so a panicking assertion cannot leak the legacy setting
/// into the tests that run after it.
struct PipelineColorGuard {
working: crate::colormath::WorkingColorSpace,
output: crate::colormath::OutputColorSpec,
}
impl PipelineColorGuard {
fn capture() -> Self {
Self {
working: pipeline_working_space(),
output: pipeline_output_spec(),
}
}
fn restore(&mut self) {
set_pipeline_color_settings(self.working, self.output);
}
}
impl Drop for PipelineColorGuard {
fn drop(&mut self) {
self.restore();
}
}
#[test]
fn pipeline_working_ofx_name_legacy_mode() {
use crate::colormath::WorkingColorSpace;
// The pipeline settings are process-global: take the config lock
// like the other tests that touch global color state.
let _lock = config_lock();
let mut restore = PipelineColorGuard::capture();
set_pipeline_color_settings(WorkingColorSpace::SrgbLegacy, restore.output);
assert_eq!(pipeline_working_ofx_name(), "sRGB");
restore.restore();
let expected = match restore.working {
WorkingColorSpace::AcesCg => "ACEScg",
WorkingColorSpace::SrgbLegacy => "sRGB",
};
assert_eq!(pipeline_working_ofx_name(), expected, "settings restored");
}
#[test]
fn config_path_env_and_bundled_fallback() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping");
return;
}
let saved = std::env::var_os("OCIO");
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("OCIO", v),
None => std::env::remove_var("OCIO"),
}
}
}
let _env_guard = EnvGuard(saved);
std::env::set_var("OCIO", "/tmp/oak-config-path-test.ocio");
assert_eq!(
config_path().as_deref(),
Some("/tmp/oak-config-path-test.ocio"),
"a non-empty $OCIO wins"
);
// An empty $OCIO is treated as unset and falls back to the bundled
// extraction location (a config must exist for that branch).
std::env::set_var("OCIO", "");
let fallback = config_path().expect("bundled fallback when a config exists");
assert!(fallback.ends_with("ocioconf/config.ocio"), "got {fallback}");
}
#[test]
fn set_up_default_config_from_bad_path_errors_and_keeps_config() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let before = default_config();
let err = set_up_default_config_from(Some("/nonexistent/oak-config.ocio"));
assert!(err.is_err(), "a bad config path is a load error");
assert!(
default_config().is_some(),
"the previous default config survives a failed load"
);
assert_eq!(
default_config().map(|c| c.cache_id()),
before.map(|c| c.cache_id())
);
}
#[test]
fn display_transform_scene_linear_role_fallback() {
let _lock = config_lock();
// A minimal config with a scene_linear role but no reference /
// aces_interchange bindings: the last-resort fallback chain.
let cfg_text = r#"
ocio_profile_version: 2
name: oak-fallback-test
search_path: ""
roles:
scene_linear: Raw
displays:
oakdisplay:
- !<View> {name: oakview, colorspace: Raw}
colorspaces:
- !<ColorSpace>
name: Raw
family: ""
isdata: false
allocation: uniform
"#;
let Ok(cfg) = ocio_rs::Config::from_stream(cfg_text) else {
eprintln!("OCIO config-from-stream unavailable; skipping");
return;
};
// The fallback chain only runs when the first three lookups fail.
assert!(cfg
.color_space("reference")
.and_then(|cs| cs.name())
.filter(|s| !s.is_empty())
.is_none());
assert!(cfg
.role_color_space("reference")
.filter(|s| !s.is_empty())
.is_none());
assert!(cfg
.role_color_space("aces_interchange")
.filter(|s| !s.is_empty())
.is_none());
assert_eq!(cfg.role_color_space("scene_linear").as_deref(), Some("Raw"));
let saved = default_config();
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) =
Some(std::sync::Arc::new(SafeConfig(cfg)));
let found = display_transform_result("oakdisplay", "oakview");
let unknown_view = display_transform_result("oakdisplay", "nope");
let unknown_display = display_transform_result("nope", "oakview");
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = saved;
assert!(
found.is_ok(),
"scene_linear fallback resolves the reference space: {found:?}"
);
assert_eq!(unknown_view.unwrap(), None);
assert_eq!(unknown_display.unwrap(), None);
}
// ---- Additional branch coverage: no-config and explicit-config paths ----
/// Temporarily clear the process-wide default config and restore it on
/// drop (used by the no-config tests below; the caller must hold
/// `config_lock`).
struct DefaultConfigGuard(Option<std::sync::Arc<SafeConfig>>);
impl DefaultConfigGuard {
fn clear() -> Self {
let saved = default_config();
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
Self(saved)
}
}
impl Drop for DefaultConfigGuard {
fn drop(&mut self) {
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = self.0.take();
}
}
/// Temporarily set `$OCIO`, restoring the previous value on drop.
struct OcioEnvGuard(Option<std::ffi::OsString>);
impl OcioEnvGuard {
fn set(value: &str) -> Self {
let saved = std::env::var_os("OCIO");
std::env::set_var("OCIO", value);
Self(saved)
}
}
impl Drop for OcioEnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("OCIO", v),
None => std::env::remove_var("OCIO"),
}
}
}
/// Every `default_config()?` guard's `None` arm: without a process
/// default config each factory returns `None` instead of panicking,
/// and `display_transform_result` reports the missing-config state.
#[test]
fn factories_without_a_default_config_return_none() {
let _lock = config_lock();
let _env = OcioEnvGuard::set("");
let _guard = DefaultConfigGuard::clear();
assert!(ColorProcessor::create("scene_linear", "sdr-video", Direction::Normal).is_none());
assert!(ColorProcessor::create_lut("/tmp/oak-none.cube", Direction::Normal).is_none());
assert!(ColorProcessor::create_grading_primary(GradingStyle::Lin).is_none());
assert!(ColorProcessor::create_grading_primary(GradingStyle::Log).is_none());
assert!(ColorProcessor::create_display_icc("scene_linear", "/tmp/oak-none.icc").is_none());
assert!(
ColorProcessor::create_display_icc_bgra8("scene_linear", "/tmp/oak-none.icc").is_none()
);
assert!(ColorProcessor::create_display_icc_xyz("/tmp/oak-none.icc").is_none());
assert!(ColorProcessor::create_display_icc_xyz_bgra8("/tmp/oak-none.icc").is_none());
assert!(ocio_function_shader("oak_fn", "a", "b").is_none());
assert!(grading_primary_function_shader(GradingStyle::Lin).is_none());
assert!(grading_primary_function_shader(GradingStyle::Log).is_none());
assert_eq!(
display_transform_result("display", "view")
.unwrap_err()
.code(),
crate::error::OAKCORE_E_STATE
);
assert!(display_transform("display", "view").is_none());
assert!(config_path().is_none(), "no config and empty $OCIO");
}
/// `set_up_default_config_from(Some(path))` loads an explicit config
/// file and installs it as the process default (the project-properties
/// OCIO override path), replacing the previous default.
#[test]
fn set_up_default_config_from_explicit_file_replaces_default() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no bundled OCIO config; skipping");
return;
}
let path =
std::env::temp_dir().join(format!("oak-color-explicit-{}.ocio", std::process::id()));
let cfg_text = r#"
ocio_profile_version: 2
name: oak-explicit-test
search_path: ""
roles:
scene_linear: Raw
default: Raw
displays:
oakdisplay:
- !<View> {name: oakview, colorspace: Raw}
colorspaces:
- !<ColorSpace>
name: Raw
family: ""
isdata: false
allocation: uniform
"#;
std::fs::write(&path, cfg_text).unwrap();
let result = set_up_default_config_from(Some(path.to_string_lossy().as_ref()));
let installed_name = default_config().and_then(|c| c.name());
let _ = std::fs::remove_file(&path);
// Restore the bundled default config installed above.
let restored = set_up_default_config_from(None);
assert!(result.is_ok(), "an explicit valid config loads: {result:?}");
assert_eq!(installed_name.as_deref(), Some("oak-explicit-test"));
assert!(
restored.is_ok(),
"the bundled config restores: {restored:?}"
);
}
/// The XYZ display chain's build failure surfaces as `None` (not a
/// pass-through), so the caller can fall back to the sRGB chain: a
/// config that cannot produce the processor (unreadable ICC file)
/// yields `Some(invalid)` from the classic builder but `None` from
/// the XYZ wrapper.
#[test]
fn display_icc_xyz_returns_none_when_chain_is_invalid() {
let _lock = config_lock();
let cfg_text = r#"
ocio_profile_version: 2
name: oak-xyz-test
search_path: ""
roles:
scene_linear: Raw
cie_xyz_d65_interchange: Raw
displays:
oakdisplay:
- !<View> {name: oakview, colorspace: Raw}
colorspaces:
- !<ColorSpace>
name: Raw
family: ""
isdata: false
allocation: uniform
- !<ColorSpace>
name: Linear Rec.709 (sRGB)
family: ""
isdata: false
allocation: uniform
"#;
let Ok(cfg) = ocio_rs::Config::from_stream(cfg_text) else {
eprintln!("OCIO config-from-stream unavailable; skipping");
return;
};
// An unreadable ICC file must fail processor creation but not the
// transform-chain assembly.
let icc = std::env::temp_dir().join(format!("oak-xyz-junk-{}.icc", std::process::id()));
std::fs::write(&icc, b"this is not an ICC profile").unwrap();
let icc = icc.to_string_lossy().into_owned();
let _guard = DefaultConfigGuard::clear();
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) =
Some(std::sync::Arc::new(SafeConfig(cfg)));
let classic =
ColorProcessor::create_display_icc("cie_xyz_d65_interchange", &icc).expect("handle");
let classic_bgra8 =
ColorProcessor::create_display_icc_bgra8("cie_xyz_d65_interchange", &icc)
.expect("handle");
let xyz = ColorProcessor::create_display_icc_xyz(&icc);
let xyz_bgra8 = ColorProcessor::create_display_icc_xyz_bgra8(&icc);
assert!(
!classic.is_valid(),
"the ICC leg fails against a junk profile (non-fatal pass-through)"
);
assert!(!classic_bgra8.is_valid());
assert!(
xyz.is_none(),
"an invalid chain must surface as None for the XYZ wrapper"
);
assert!(xyz_bgra8.is_none());
let _ = std::fs::remove_file(&icc);
}
/// A config whose display/view exist but whose roles do not resolve
/// makes `display_transform_result` report `Error::State` (the
/// `ok_or` arm of the role fallback chain).
#[test]
fn display_transform_result_errors_when_no_reference_role_resolves() {
let _lock = config_lock();
let cfg_text = r#"
ocio_profile_version: 2
name: oak-no-roles-test
search_path: ""
displays:
oakdisplay:
- !<View> {name: oakview, colorspace: Raw}
colorspaces:
- !<ColorSpace>
name: Raw
family: ""
isdata: false
allocation: uniform
"#;
let Ok(cfg) = ocio_rs::Config::from_stream(cfg_text) else {
eprintln!("OCIO config-from-stream unavailable; skipping");
return;
};
let _guard = DefaultConfigGuard::clear();
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) =
Some(std::sync::Arc::new(SafeConfig(cfg)));
let err = display_transform_result("oakdisplay", "oakview")
.expect_err("no reference role resolves to a source space");
assert_eq!(err.code(), crate::error::OAKCORE_E_STATE);
assert!(display_transform("oakdisplay", "oakview").is_none());
}
/// `set_up_default_config` honours `$OCIO` (non-empty → that file;
/// empty → the bundled config) and swaps the process default.
#[test]
fn set_up_default_config_honours_the_ocio_environment() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no bundled OCIO config; skipping");
return;
}
let path = std::env::temp_dir().join(format!("oak-color-env-{}.ocio", std::process::id()));
let cfg_text = r#"
ocio_profile_version: 2
name: oak-env-test
search_path: ""
roles:
scene_linear: Raw
default: Raw
displays:
oakdisplay:
- !<View> {name: oakview, colorspace: Raw}
colorspaces:
- !<ColorSpace>
name: Raw
family: ""
isdata: false
allocation: uniform
"#;
std::fs::write(&path, cfg_text).unwrap();
let path_str = path.to_string_lossy().into_owned();
let loaded = {
let _env = OcioEnvGuard::set(&path_str);
set_up_default_config()
};
let installed_name = default_config().and_then(|c| c.name());
// An empty $OCIO is treated as unset: the bundled config loads.
let bundled = {
let _env = OcioEnvGuard::set("");
set_up_default_config()
};
let _ = std::fs::remove_file(&path);
assert!(loaded.is_ok(), "a non-empty $OCIO loads: {loaded:?}");
assert_eq!(installed_name.as_deref(), Some("oak-env-test"));
assert!(bundled.is_ok(), "an empty $OCIO falls back: {bundled:?}");
}
/// A valid processor rejects a short F32 buffer (the `try_apply`
/// error mapping) and the `convert_bgra8` entry point guards its
/// staging buffer length.
#[test]
fn conversion_error_arms_from_buffer_size_mismatches() {
let _lock = config_lock();
if set_up_default_config().is_err() {
eprintln!("no default OCIO config; skipping");
return;
}
let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal)
.expect("handle always returned");
if !p.is_valid() {
eprintln!("processor unavailable in this config; skipping");
return;
}
// Fewer samples than pixels * 4: the OCIO apply fails and is
// mapped to a named error.
let mut short = [0f32; 4];
let err = p.convert_f32_rgba(&mut short, 2).unwrap_err();
assert!(
err.to_string().contains("OCIO f32 apply"),
"the apply error is named: {err}"
);
// BGRA8: the staging buffer must cover pixels * 4 bytes.
let mut bytes = [9u8; 4];
assert_eq!(
p.convert_bgra8(&mut bytes, 2).unwrap_err().code(),
crate::error::OAKCORE_E_INVALID
);
}
}
+239
View File
@@ -2326,4 +2326,243 @@ mod tests {
assert_eq!(vp.time_in_timebase_units(n, d), Some(expected));
}
}
// ---- Branch-coverage fill-ins ------------------------------------------
#[test]
fn new_with_time_base_null_par_defaults_to_square() {
// C++ validate_pixel_aspect_ratio(): a null PAR falls back to 1/1.
let vp = VideoParams::new_with_time_base(
640,
480,
1,
25,
PixelFormat::U16,
3,
0,
7,
2,
1,
);
assert_eq!(vp.pixel_aspect_ratio(), (1, 1));
// The BottomFirst interlacing code passes through the ctor too.
assert_eq!(vp.interlacing(), Interlacing::BottomFirst);
assert_eq!(vp.time_base(), (1, 25));
}
#[test]
fn square_pixel_width_with_zero_par_denominator() {
// A (x, 0) PAR is a NaN rational no setter can produce (setters
// validate); the defensive denominator check must return the raw
// width instead of propagating NaN.
let mut vp = default_vp();
vp.pixel_aspect_ratio = (4, 0);
assert_eq!(vp.square_pixel_width(), 1920);
}
#[test]
fn load_xml_bad_values_error_for_interlacing_and_colorrange() {
let mut vp = VideoParams::new();
// stoi_field failures inside these two arms propagate as errors.
assert!(vp
.load_xml("<videoparams><interlacing>notanumber</interlacing></videoparams>")
.is_err());
assert!(vp
.load_xml("<videoparams><colorrange>notanumber</colorrange></videoparams>")
.is_err());
}
#[test]
fn pixel_format_code_round_trip_all_arms() {
for (code, expected) in [
(-1, PixelFormat::Invalid),
(0, PixelFormat::U8),
(1, PixelFormat::U10),
(2, PixelFormat::U16),
(3, PixelFormat::F16),
(4, PixelFormat::F32),
(5, PixelFormat::Count),
(6, PixelFormat::Invalid),
(99, PixelFormat::Invalid),
] {
assert_eq!(pf_from_code(code), expected, "code {code}");
}
for pf in [
PixelFormat::U8,
PixelFormat::U10,
PixelFormat::U16,
PixelFormat::F16,
PixelFormat::F32,
] {
assert_eq!(pf_from_code(pf_code(pf)), pf);
}
}
#[test]
fn enum_i32_mapping_all_arms() {
assert_eq!(interlacing_from_i32(1), Interlacing::TopFirst);
assert_eq!(interlacing_from_i32(2), Interlacing::BottomFirst);
assert_eq!(interlacing_from_i32(3), Interlacing::None);
assert_eq!(video_type_from_i32(1), VideoType::Still);
assert_eq!(video_type_from_i32(2), VideoType::ImageSequence);
assert_eq!(video_type_from_i32(9), VideoType::Video);
assert_eq!(color_range_from_i32(1), ColorRange::Full);
assert_eq!(color_range_from_i32(0), ColorRange::Limited);
}
#[test]
fn save_xml_escapes_reserved_characters() {
let mut vp = default_vp();
vp.set_colorspace("a&b<c>d");
let xml = vp.save_xml().unwrap();
assert!(
xml.contains("<colorspace>a&amp;b&lt;c&gt;d</colorspace>"),
"escaped text: {xml}"
);
let mut loaded = VideoParams::new();
loaded.load_xml(&xml).unwrap();
assert_eq!(loaded.colorspace(), "a&b<c>d");
}
#[test]
fn gcd_and_rational_helpers_sign_arms() {
// Negative second operand exercises the `b = -b` arm.
assert_eq!(i64_gcd(4, -6), 2);
assert_eq!(i64_gcd(-4, -6), 2);
assert_eq!(i64_gcd(0, 5), 5);
assert_eq!(i64_gcd(7, 0), 7);
assert_eq!(i64_gcd(0, 0), 0);
// A negative numerator flows through the reduce path.
assert_eq!(make_rational(-6, 4), (-3, 2));
}
#[test]
fn rational_flipped_sign_and_degenerate_inputs() {
// Negative denominator after the swap is sign-fixed.
assert_eq!(rational_flipped((3, -2)), (-2, 3));
assert_eq!(rational_flipped((-3, 2)), (-2, 3));
// Null rational is returned untouched.
assert_eq!(rational_flipped((0, 5)), (0, 5));
assert_eq!(rational_flipped((0, 0)), (0, 0));
// Non-null numerator with a zero denominator swaps to 0/1.
assert_eq!(rational_flipped((1, 0)), (0, 1));
}
#[test]
fn xml_nested_field_text_and_attributes() {
let mut vp = VideoParams::new();
// Nested elements inside a known field: depth-1 text only.
vp.load_xml("<videoparams><width><inner>9</inner>640</width></videoparams>")
.unwrap();
assert_eq!(vp.width(), 640);
// Whitespace after the tag name, around '=', and single-quoted values.
vp.load_xml("<videoparams version = \"2.0\" xmlns:x='y' ><height>480</height></videoparams>")
.unwrap();
assert_eq!(vp.height(), 480);
// Entities (and plain runs) inside attribute values are validated.
vp.load_xml("<videoparams note=\"a&amp;b&#65;\" ><depth>2</depth></videoparams>")
.unwrap();
assert_eq!(vp.depth(), 2);
// Unknown entities in an attribute value are parse errors.
assert!(vp
.load_xml("<videoparams note=\"a&nope;b\"><depth>2</depth></videoparams>")
.is_err());
}
#[test]
fn xml_doctype_paths() {
let mut vp = VideoParams::new();
vp.load_xml("<!DOCTYPE videoparams><videoparams><width>1</width></videoparams>")
.unwrap();
assert_eq!(vp.width(), 1);
// An internal subset keeps the '>' inside the brackets from ending
// the declaration.
vp.load_xml(
"<!DOCTYPE videoparams [ <!ENTITY x \"y\"> ]><videoparams><width>2</width></videoparams>",
)
.unwrap();
assert_eq!(vp.width(), 2);
// A closing bracket with no open one saturates at zero.
vp.load_xml("<!DOCTYPE videoparams ]><videoparams><width>3</width></videoparams>")
.unwrap();
assert_eq!(vp.width(), 3);
// An unterminated declaration is a parse error.
assert!(vp.load_xml("<!DOCTYPE videoparams").is_err());
}
#[test]
fn xml_structural_malformations_fail() {
let mut vp = VideoParams::new();
// Empty tag name.
assert!(vp.load_xml("<videoparams>< ></videoparams>").is_err());
// Unterminated start tag (no '>').
assert!(vp.load_xml("<videoparams><width").is_err());
// '/' not followed by '>'.
assert!(vp.load_xml("<videoparams><width/ x></videoparams>").is_err());
// Attribute without '='.
assert!(vp
.load_xml("<videoparams foo bar=\"x\"><width>1</width></videoparams>")
.is_err());
// Attribute value without an opening quote.
assert!(vp
.load_xml("<videoparams foo=bar><width>1</width></videoparams>")
.is_err());
// Unterminated attribute value.
assert!(vp
.load_xml("<videoparams foo=\"bar><width>1</width></videoparams>")
.is_err());
// Junk after an end-element name.
assert!(vp
.load_xml("<videoparams><width>1</width x></videoparams>")
.is_err());
// An end tag that never closes errors too.
assert!(vp.load_xml("<videoparams></videoparams").is_err());
// Whitespace before the end tag's '>' is fine.
vp.load_xml("<videoparams><width>5</width ></videoparams>")
.unwrap();
assert_eq!(vp.width(), 5);
}
#[test]
fn xml_numeric_entities() {
let mut vp = VideoParams::new();
vp.load_xml("<videoparams><colorspace>&#x41;&#66;&#x1F600;</colorspace></videoparams>")
.unwrap();
assert_eq!(vp.colorspace(), "AB\u{1F600}");
// Invalid hex digits / out-of-range code points are parse errors.
assert!(vp
.load_xml("<videoparams><colorspace>&#xZZ;</colorspace></videoparams>")
.is_err());
assert!(vp
.load_xml("<videoparams><colorspace>&#x110000;</colorspace></videoparams>")
.is_err());
assert!(vp
.load_xml("<videoparams><colorspace>&#1114112;</colorspace></videoparams>")
.is_err());
}
#[test]
fn xml_cursor_handles_unclosed_event_streams() {
// Defensive: a truncated event list reaches EndDocument inside
// read_element_text.
let mut cur = XmlCursor::new(vec![XmlEvent::StartElement("a".to_string())]);
assert!(cur.next_start_element());
assert_eq!(cur.read_element_text(), "");
// ... and inside skip_current_element.
let mut cur = XmlCursor::new(vec![XmlEvent::StartElement("a".to_string())]);
assert!(cur.next_start_element());
cur.skip_current_element();
// Nested elements count depth; only depth-1 characters accumulate.
let mut cur = XmlCursor::new(vec![
XmlEvent::StartElement("a".to_string()),
XmlEvent::Characters("x".to_string()),
XmlEvent::StartElement("b".to_string()),
XmlEvent::Characters("ignored".to_string()),
XmlEvent::EndElement("b".to_string()),
XmlEvent::Characters("y".to_string()),
XmlEvent::EndElement("a".to_string()),
]);
assert!(cur.next_start_element());
assert_eq!(cur.read_element_text(), "xy");
}
}