Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-graph3d/tests/travels.rs

//! **TRAVELS integration proofs** — the guided-tour ghost driven THROUGH the
//! [`Graph3D`](facett_graph3d::Graph3D) Elm bridge (the host seam dwarves' navigator +
//! korp use).
//!
//! 1. **Default OFF** — a graph with no route reports the exact same `state_json` it
//!    always did (no `travel_*` keys) and renders identically (additive; goldens safe).
//! 2. **Ghost paints non-blank** — with a route set, the headless Elm render tessellates
//!    the ghost overlay (strictly MORE geometry than the no-tour render): the glowing
//!    ghost marker + spline actually draw (RED-when-broken: a no-op paint draws nothing).
//! 3. **Progress advances + camera flies** — driving `Tick` messages through the bridge
//!    climbs `travel_progress` 0→1, walks `travel_stop_index`, and moves the yaw to face
//!    the ghost (the camera fly).
//!
//! Robot/headless (no GPU) so it dodges the wgpu-teardown SIGSEGV; the wgpu light-proof
//! lives in `travels_snapshot.rs`.

use egui::Color32;

use facett_core::Facet;
use facett_core::harness;
use facett_graph3d::{Graph3D, Layout3D, Msg, RouteKind, TravelRoute};

/// A deterministic ring graph (frozen spin, pinned clock) so the projection + tour are
/// reproducible frame to frame.
fn graph() -> Graph3D {
    let nodes: Vec<(String, Color32)> = (0..10).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
    let edges: Vec<(usize, usize)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
    let mut g = Graph3D::new(nodes, edges, Layout3D::Sphere);
    let _ = g.update(Msg::SetSpin(false));
    let _ = g.update(Msg::SetYaw(0.0));
    g
}

#[test]
fn no_route_is_byte_stable_and_omits_travel_keys() {
    // Default OFF: no travel_* surface, and the render still draws the plain cloud.
    let mut g = graph().with_clock(0.0);
    let j = g.state_json();
    assert!(j.get("travel_active").is_none(), "no tour ⇒ no travel keys (goldens byte-stable)");
    let r = harness::headless_render(&mut g);
    assert!(r.drew(), "the plain cloud still paints");
}

#[test]
fn route_set_exposes_the_tour_surface_through_state_json() {
    let g = graph().with_travel(TravelRoute::latest_changes(&[0, 3, 6, 9]));
    let j = g.state_json();
    assert_eq!(j["travel_active"], true);
    assert_eq!(j["travel_kind"], "latest_changes");
    assert_eq!(j["travel_stops"], serde_json::json!([0, 3, 6, 9]));
    assert_eq!(j["travel_stop_index"], 0);
    assert_eq!(j["travel_node"], 0);
    // The spotlight + bugs constructors carry their kind too.
    assert_eq!(TravelRoute::spotlight(4).kind, RouteKind::Spotlight);
    assert_eq!(TravelRoute::bugs_top_down(&[2, 5]).kind, RouteKind::BugsTopDown);
}

#[test]
fn ghost_overlay_tessellates_more_geometry_than_the_plain_cloud() {
    // RED-when-broken: the ghost marker + spline + stop glows must add real geometry.
    // A tour frame draws strictly MORE vertices than the same graph with no tour; if
    // the overlay stopped painting, the two counts collapse to equal → RED.
    let mut plain = graph().with_clock(0.6);
    let mut toured = graph().with_travel(TravelRoute::new("t", vec![0, 4, 8])).with_clock(0.6);
    // Seat the tour mid-flight (between two stops) so the marker + trail are drawn.
    let _ = toured.update(Msg::SetClock(1.0));

    let rp = harness::headless_render(&mut plain);
    let rt = harness::headless_render(&mut toured);
    assert!(rp.drew() && rt.drew(), "both frames paint");
    assert!(
        rt.vertices > rp.vertices,
        "the ghost overlay adds geometry: toured={} plain={}",
        rt.vertices,
        rp.vertices
    );
}

#[test]
fn ticking_through_the_bridge_advances_progress_and_flies_the_camera() {
    // Drive the tour with real Tick messages through the Elm bridge and watch the
    // observable surface move: progress climbs, the stop index walks the route, the
    // yaw flies to face the ghost. RED-when-broken: a frozen tour keeps progress at 0.
    let mut g = graph().with_travel(TravelRoute::bugs_top_down(&[1, 5, 9]));
    let _ = g.update(Msg::SetClock(0.0));

    let p0 = g.state_json()["travel_progress"].as_f64().unwrap();
    let visited_first = g.state_json()["travel_node"].as_u64().unwrap();
    assert_eq!(visited_first, 1, "the tour starts at the first bug anchor");

    // Step the clock across the whole timeline; record progress + which stops we hit.
    let mut seen_last_stop = false;
    let mut last_p = p0;
    let mut monotone = true;
    for _ in 0..60 {
        let _ = g.update(Msg::Tick(0.1));
        let j = g.state_json();
        let p = j["travel_progress"].as_f64().unwrap();
        if p < last_p - 1e-9 {
            monotone = false;
        }
        last_p = p;
        if j["travel_stop_index"].as_u64().unwrap() == 2 {
            seen_last_stop = true;
        }
    }
    assert!(monotone, "progress never went backwards under forward ticks");
    assert!(last_p > p0, "progress advanced from the start");
    assert!((last_p - 1.0).abs() < 1e-3, "the tour reached the end (progress ~1.0)");
    assert!(seen_last_stop, "the stop index walked to the final bug anchor");

    // The camera flew: the yaw is the fly-to-ghost target for the final marker.
    let yaw = g.state_json()["yaw"].as_f64().unwrap();
    let want = g.travel_camera_yaw().unwrap() as f64;
    assert!((yaw - want).abs() < 1e-3, "the camera yaw faces the ghost: yaw={yaw} want={want}");

    // ── feed the matrix ──
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-graph3d::Graph3D::travel",
        "tour_progress_advances",
        last_p > p0 && seen_last_stop,
        &format!("p0={p0:.3} p_end={last_p:.3} reached_last_stop={seen_last_stop}"),
    );
}

#[test]
fn play_pause_and_step_controls_route_through_the_bridge() {
    // The control seams (Msg::) drive the tour: pause freezes progress, play resumes,
    // next/prev walk the stops.
    let mut g = graph().with_travel(TravelRoute::new("t", vec![0, 2, 4, 6]));
    let _ = g.update(Msg::TravelPause);
    let before = g.state_json()["travel_progress"].as_f64().unwrap();
    for _ in 0..20 {
        let _ = g.update(Msg::TravelTick(0.2));
    }
    assert_eq!(g.state_json()["travel_progress"].as_f64().unwrap(), before, "paused tour ignores ticks");

    // Step to the next stop without playing.
    let _ = g.update(Msg::TravelNextStop);
    assert_eq!(g.state_json()["travel_stop_index"], 1);
    let _ = g.update(Msg::TravelNextStop);
    assert_eq!(g.state_json()["travel_node"], 4);
    let _ = g.update(Msg::TravelPrevStop);
    assert_eq!(g.state_json()["travel_stop_index"], 1);

    // Resume + speed.
    let _ = g.update(Msg::TravelSpeed(2.0));
    let _ = g.update(Msg::TravelPlay);
    assert_eq!(g.state_json()["travel_speed"], 2.0);
    assert_eq!(g.state_json()["travel_playing"], true);
}