📄 sink/tests/ui.rs
//! Headless UI tests for the viewer, driven with `egui-kittest`.
//!
//! These exercise the real UI code path: each harness step runs
//! [`sink::ViewerModel::ui`], which pulls the next synthetic frame and
//! renders it from the virtual camera via Brush (on software Vulkan in
//! CI). Interactions go through the actual widgets — the play/pause
//! button and the steering slider.

use egui::{Event, pos2, vec2};
use egui_kittest::{Harness, kittest};
use glam::uvec2;

use kittest::Queryable;
use render::RenderedImage;
use replay::synth;
use sink::ViewerModel;

/// Small but complete: the dev rig and scene at low resolutions, so every
/// pipeline stage (synth source → surfels → Brush render → texture)
/// runs for real without slowing the suite down.
fn small_model() -> ViewerModel {
    let rig = synth::dev_rig_with_resolutions(uvec2(64, 53), uvec2(80, 60));
    let source = synth::dev_source_with_center_camera(rig.center, 30.0);
    ViewerModel::new(rig, Box::new(source), render::SplatRenderer::default())
}

fn harness_for<'a>(model: ViewerModel, wgpu: bool) -> Harness<'a, ViewerModel> {
    let mut builder = Harness::builder().with_size(vec2(400.0, 300.0));
    if wgpu {
        builder = builder.wgpu();
    }
    builder.build_ui_state(|ui, model| model.ui(ui), model)
}

#[test]
fn pipeline_runs_and_stats_update() {
    let harness = harness_for(small_model(), false);
    // The harness runs the ui closure at build time; that alone must have
    // pulled frames and rendered them.
    let model = harness.state();
    assert!(model.state().playing);
    assert!(model.stats().frame_index >= 1);
    assert!(model.stats().splats > 0);
    assert!(model.stats().visible_splats > 0);
    assert!(model.stats().render_ms >= 0.0);

    // The virtual camera renders at webcam resolution: 80x60.
    let image = model.last_image().expect("a rendered image");
    assert_eq!((image.width, image.height), (80, 60));
    assert_eq!(image.rgba.len(), 80 * 60 * 4);
}

#[test]
fn play_pause_button_toggles_playback() {
    let mut harness = harness_for(small_model(), false);
    harness.run();

    harness.get_by_label("Pause").click();
    harness.run();
    assert!(!harness.state().state().playing, "Pause must stop playback");
    let frame_paused = harness.state().stats().frame_index;

    // While paused, further harness steps must not advance the source…
    harness.run_steps(3);
    assert_eq!(harness.state().stats().frame_index, frame_paused);
    // …but they still re-render (scrubbing the slider stays live).
    assert!(harness.state().last_image().is_some());

    // And the button label flips back.
    harness.get_by_label("Play").click();
    harness.run();
    assert!(harness.state().state().playing);
    assert!(harness.state().stats().frame_index > frame_paused);
}

#[test]
fn slider_steers_virtual_camera() {
    let mut harness = harness_for(small_model(), false);
    harness.run();
    assert!((harness.state().state().t - 0.5).abs() < 1e-6);

    // Drag the slider handle from the middle to near the right end. The
    // slider query must be role-scoped: egui sliders nest a spin-button
    // with the same label.
    let slider = harness
        .query(
            kittest::by()
                .role(egui::accesskit::Role::Slider)
                .label("Virtual camera position"),
        )
        .expect("steering slider");
    let rect = slider.rect();
    let start = rect.center();
    let end = pos2(rect.right() - rect.width() * 0.02, rect.center().y);
    harness.drag_at(start);
    harness.event(Event::PointerMoved(end));
    harness.drop_at(end);
    harness.run();

    let model = harness.state();
    let t = model.state().t;
    assert!(
        t > 0.7,
        "dragging the slider right must steer right, t = {t}"
    );
    // The virtual camera moved along the baseline with it (the rig's
    // side cameras sit at ±0.4 m).
    let position = model.virtual_camera().pose.position.x;
    assert!(position > 0.0, "virtual camera at x = {position}");
}

#[test]
fn steering_rerenders_the_virtual_view() {
    // End-to-end: steering the slider must change the rendered image.
    // (The rig converges on the subject, so the subject stays framed at
    // any t — the *backdrop* parallaxes. Both are asserted.)
    let mut harness = harness_for(small_model(), false);
    harness.run();

    // Pause playback so the subject's sway cannot contaminate the
    // comparison.
    harness.get_by_label("Pause").click();
    harness.run();

    let image_at = |harness: &mut Harness<ViewerModel>, t: f32| {
        harness.state_mut().set_t(t);
        harness.run();
        harness.state().last_image().expect("rendered").clone()
    };

    let left = image_at(&mut harness, 0.0);
    let right = image_at(&mut harness, 1.0);

    // The subject stays framed from both ends (converged rig)…
    let left_head = skin_centroid_x(&left).expect("subject from left");
    let right_head = skin_centroid_x(&right).expect("subject from right");
    assert!(
        (left_head - right_head).abs() < 4.0,
        "subject must stay framed: x={left_head} vs x={right_head}"
    );

    // …while the backdrop parallaxes: a good fraction of the frame
    // changes across the baseline.
    let mut changed = 0;
    let mut total = 0;
    let (left_px, _) = left.rgba.as_chunks::<4>();
    let (right_px, _) = right.rgba.as_chunks::<4>();
    for (a, b) in left_px.iter().zip(right_px) {
        total += 1;
        let diff = (a[0] as i32 - b[0] as i32)
            .abs()
            .max((a[1] as i32 - b[1] as i32).abs());
        if diff > 25 {
            changed += 1;
        }
    }
    assert!(
        changed * 10 > total,
        "only {changed}/{total} pixels changed between t=0 and t=1"
    );
}

#[test]
fn ui_paints_headlessly() {
    // Full paint pass on the wgpu renderer (software Vulkan): the UI must
    // produce a non-empty, non-uniform image.
    let mut harness = harness_for(small_model(), true);
    harness.run();
    harness.set_render_every_step(true);
    let image = harness.render().expect("wgpu render");
    assert!(image.width() > 0 && image.height() > 0);
    // More than one distinct colour: the viewfinder really painted.
    let colors: std::collections::HashSet<_> = image.pixels().map(|p| p.0).take(1 << 16).collect();
    assert!(
        colors.len() > 8,
        "ui rendered only {} distinct colors",
        colors.len()
    );
}

/// The mean x of pixels close to the synthetic skin colour, or `None` if
/// the subject is not visible.
fn skin_centroid_x(image: &RenderedImage) -> Option<f32> {
    const SKIN: [f32; 3] = [0.85, 0.62, 0.52];
    let mut sum = 0.0;
    let mut count = 0.0;
    for y in 0..image.height {
        for x in 0..image.width {
            let [r, g, b, a] = image.pixel(x, y);
            if a == 0 {
                continue;
            }
            let px = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
            let dist = (px[0] - SKIN[0]).abs() + (px[1] - SKIN[1]).abs() + (px[2] - SKIN[2]).abs();
            if dist < 0.2 {
                sum += x as f32;
                count += 1.0;
            }
        }
    }
    (count > 5.0).then(|| sum / count)
}