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