📄 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, SplatRenderer};
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));
    // The full pipeline: raw synthetic depth → preprocessing → surfels.
    let raw = synth::dev_source_with(&rig, synth::SUBJECT, 30.0);
    let source = splatgen::SplatSource::new(rig.clone(), raw, splatgen::SplatGen::default());
    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()
    );
}

#[test]
fn custom_config_flows_to_the_viewfinder() {
    // A rig from a config file must reach the renderer: wider baseline,
    // moved subject, renamed camera. Parsed through the real config
    // pipeline (file loading), not test constructors.
    let config_dir = std::env::temp_dir().join(format!("kinect-view-test-{}", std::process::id()));
    std::fs::create_dir_all(&config_dir).expect("create temp dir");
    let config_path = config_dir.join("rig.toml");
    std::fs::write(
        &config_path,
        r#"
# A wider-baseline rig looking at a subject further away.
rig_left_position = [-0.6, 0.0, 0.0]
rig_right_position = [0.6, 0.0, 0.0]
rig_left_id = " hallway-cam "
rig_center_resolution = [64, 53]
rig_center_intrinsics = [45.704, 45.900, 32.0, 26.5]
rig_left_resolution = [80, 60]
rig_left_intrinsics = [69.282, 51.962, 40.0, 30.0]
rig_right_resolution = [80, 60]
rig_right_intrinsics = [69.282, 51.962, 40.0, 30.0]
scene_subject_position = [0.0, 0.0, 2.5]
"#,
    )
    .expect("write config");

    let resolved = sink::config::load_from([
        "kinect-view",
        "--config-file",
        config_path.to_str().expect("utf8 path"),
    ])
    .and_then(|args| args.resolve())
    .expect("config resolves");

    // The file settings all landed.
    assert_eq!(resolved.rig.left.pose.position, glam::vec3(-0.6, 0.0, 0.0));
    assert_eq!(resolved.rig.left.id, " hallway-cam ");
    assert_eq!(resolved.subject, glam::vec3(0.0, 0.0, 2.5));
    assert_eq!(resolved.rig.center.resolution, uvec2(64, 53));

    // And the rig actually renders through the whole pipeline.
    let raw = synth::dev_source_with(&resolved.rig, resolved.subject, 30.0);
    let source =
        splatgen::SplatSource::new(resolved.rig.clone(), raw, splatgen::SplatGen::default());
    let model = ViewerModel::new(resolved.rig, Box::new(source), SplatRenderer::default());
    let harness = harness_for(model, false);
    let image = harness.state().last_image().expect("rendered");
    // The virtual camera follows the (configured) webcam resolution.
    assert_eq!((image.width, image.height), (80, 60));
    assert!(
        skin_centroid_x(image).is_some(),
        "subject must be visible from the configured rig"
    );

    std::fs::remove_file(&config_path).ok();
    std::fs::remove_dir(&config_dir).ok();
}

#[test]
fn recording_source_renders() {
    // The `--source recording` path end to end: record a few synthetic
    // frames to disk, load them back through the real config pipeline,
    // and render through the viewer.

    let dir = std::env::temp_dir().join(format!("kinect-view-ui-rec-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);

    let rig = synth::dev_rig_with_resolutions(uvec2(32, 24), uvec2(80, 60));
    let mut raw =
        synth::dev_source_with(&rig, synth::SUBJECT, 30.0).with_noise(synth::NoiseParams::OFF);
    replay::recording::Recording::write(&dir, &mut raw, 30.0, 3).expect("write recording");

    // Load through the real config path.
    let resolved = sink::config::load_from([
        "kinect-view",
        "--source",
        "recording",
        "--recording-path",
        dir.to_str().expect("utf8 path"),
    ])
    .and_then(|args| args.resolve())
    .expect("config resolves");
    assert_eq!(
        resolved.source,
        sink::config::SourceSelection::Recording(dir.clone())
    );

    let player = replay::recording::RecordingSource::load(&dir).expect("load");
    let rig = player.rig().clone();
    let source = splatgen::SplatSource::new(rig.clone(), player, splatgen::SplatGen::default());
    let model = ViewerModel::new(rig, Box::new(source), SplatRenderer::default());
    let harness = harness_for(model, false);
    let model = harness.state();
    assert!(model.stats().splats > 0, "recorded frames produced splats");
    assert!(model.last_image().is_some());

    let _ = std::fs::remove_dir_all(&dir);
}

/// 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)
}