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:
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&b<c>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&bA\" ><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>AB😀</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>�</colorspace></videoparams>")
|
||||
.is_err());
|
||||
assert!(vp
|
||||
.load_xml("<videoparams><colorspace>�</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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user