pixelflow-test-support 0.1.0

Deterministic test helpers for PixelFlow filters, plugins, and embedders.
Documentation
//! In-memory synthetic clip sources for render tests.

use std::sync::Arc;

use pixelflow_core::{
    Clip, ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, Frame, FrameCount,
    FrameExecutor, FrameRate, FrameRequest, Graph, GraphBuilder, PixelFlowError, Rational,
    RenderExecutorMap, Result,
};

/// Renderable in-memory synthetic clip plus its executor map.
pub struct SyntheticClip {
    graph: Graph,
    clip: Clip,
    executors: RenderExecutorMap,
}

impl SyntheticClip {
    /// Returns graph containing this synthetic source as final output.
    #[must_use]
    pub const fn graph(&self) -> &Graph {
        &self.graph
    }

    /// Returns clip handle for synthetic source node.
    #[must_use]
    pub const fn clip(&self) -> Clip {
        self.clip
    }

    /// Returns executor map needed to render synthetic source.
    #[must_use]
    pub fn executors(&self) -> RenderExecutorMap {
        self.executors.clone()
    }
}

struct FrameSequenceExecutor {
    frames: Arc<[Frame]>,
}

impl FrameExecutor for FrameSequenceExecutor {
    fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
        self.frames
            .get(request.frame_number())
            .cloned()
            .ok_or_else(|| {
                PixelFlowError::new(
                    ErrorCategory::Core,
                    ErrorCode::new("render.frame_out_of_range"),
                    format!("synthetic frame {} is out of range", request.frame_number()),
                )
            })
    }
}

/// Builds graph source backed by in-memory frames.
pub fn synthetic_clip_from_frames(
    name: &str,
    frames: Vec<Frame>,
    frame_rate: Rational,
) -> Result<SyntheticClip> {
    let Some(first) = frames.first() else {
        return Err(PixelFlowError::new(
            ErrorCategory::Core,
            ErrorCode::new("test.empty_synthetic_clip"),
            "synthetic clip requires at least one frame",
        ));
    };

    for frame in &frames {
        if frame.format() != first.format()
            || frame.width() != first.width()
            || frame.height() != first.height()
        {
            return Err(PixelFlowError::new(
                ErrorCategory::Core,
                ErrorCode::new("test.inconsistent_synthetic_clip"),
                "all synthetic clip frames must share format and dimensions",
            ));
        }
    }

    let media = ClipMedia::new(
        ClipFormat::Fixed(first.format().clone()),
        ClipResolution::Fixed {
            width: first.width(),
            height: first.height(),
        },
        FrameCount::Finite(frames.len()),
        FrameRate::Cfr(frame_rate),
    );
    let mut builder = GraphBuilder::new();
    let clip = builder.source(name, media);
    builder.set_output(clip);
    let graph = builder.build();

    let mut executors = RenderExecutorMap::new();
    executors.insert(
        clip.node_id(),
        Arc::new(FrameSequenceExecutor {
            frames: Arc::from(frames),
        }),
    );

    Ok(SyntheticClip {
        graph,
        clip,
        executors,
    })
}

#[cfg(test)]
mod tests {
    use pixelflow_core::{Rational, RenderEngine, RenderOptions, WorkerPoolConfig};

    use crate::{
        EXACT_GOLDEN_TOLERANCE, assert_plane_u8_near, synthetic_clip_from_frames,
        synthetic_u8_frame,
    };

    #[test]
    fn synthetic_clip_renders_in_memory_frames_without_media_files() {
        let frames = vec![
            synthetic_u8_frame("gray8", 2, 1, |_plane, x, _y| {
                u8::try_from(x).expect("fixture sample fits u8")
            })
            .expect("frame 0"),
            synthetic_u8_frame("gray8", 2, 1, |_plane, x, _y| {
                u8::try_from(x + 10).expect("fixture sample fits u8")
            })
            .expect("frame 1"),
        ];
        let clip = synthetic_clip_from_frames(
            "synthetic",
            frames,
            Rational {
                numerator: 24,
                denominator: 1,
            },
        )
        .expect("clip should build");

        let rendered = RenderEngine::new(WorkerPoolConfig::new(1))
            .render_ordered(
                clip.graph().clone(),
                clip.executors(),
                RenderOptions::new(0, None),
            )
            .expect("render starts")
            .collect::<pixelflow_core::Result<Vec<_>>>()
            .expect("render succeeds");

        assert_eq!(rendered.len(), 2);
        let first = rendered.first().expect("first frame exists");
        let second = rendered.get(1).expect("second frame exists");
        assert_plane_u8_near(first, 0, &[&[0, 1]], EXACT_GOLDEN_TOLERANCE);
        assert_plane_u8_near(second, 0, &[&[10, 11]], EXACT_GOLDEN_TOLERANCE);
    }

    #[test]
    fn synthetic_clip_rejects_empty_inputs() {
        let Err(error) = synthetic_clip_from_frames(
            "synthetic",
            Vec::new(),
            Rational {
                numerator: 24,
                denominator: 1,
            },
        ) else {
            panic!("empty synthetic clip should fail");
        };

        assert_eq!(error.code().as_str(), "test.empty_synthetic_clip");
    }

    #[test]
    fn synthetic_clip_rejects_mismatched_frame_shapes() {
        let frames = vec![
            synthetic_u8_frame("gray8", 2, 1, |_plane, _x, _y| 0).expect("frame 0"),
            synthetic_u8_frame("gray8", 3, 1, |_plane, _x, _y| 1).expect("frame 1"),
        ];

        let Err(error) = synthetic_clip_from_frames(
            "synthetic",
            frames,
            Rational {
                numerator: 24,
                denominator: 1,
            },
        ) else {
            panic!("mismatched synthetic clip should fail");
        };

        assert_eq!(error.code().as_str(), "test.inconsistent_synthetic_clip");
    }
}