facett-demo/tests/demo_frame.rs
//! **facett-demo — the frame-budget / lag-fix proof** (headless, no window).
//!
//! The user reported `facett-demo` *lags and stutters* on a discrete GPU + 12 cores
//! while the sibling `sigtuna_demo` was buttery smooth. Root cause: the eframe binary
//! called [`DemoState::dump_state`] **every native frame** — it deep-serialises the
//! WHOLE ~27-facet deck's [`state_json`](DemoState::state_json) and `fs::write`s it to
//! disk each frame. That tree is not static: the live sim accrues an Iceberg snapshot
//! (and grows the warehouse table) on every tick, so the per-frame serialise+write
//! cost GROWS without bound — a discrete GPU idles while the CPU frame-serialises and
//! fsyncs. `main.rs` now only pays that when an external driver actually asked for the
//! `$FACETT_DEMO_STATE` channel (a robot-UI seam), so a human interactive run does
//! ZERO per-frame serialise + disk write.
//!
//! This test measures the removed cost as DATA (the before → after ms/frame the report
//! quotes), proves it GROWS with sim ticks (the stutter mechanism), and checks the new
//! [`FrameClock`] readout — all RED-when-broken if the meter or the growth regress.
use facett_demo::{DemoState, FrameClock};
use std::time::Instant;
/// Advance the sim `n` ticks and re-feed the live facets (the same wiring
/// `pump_sim` drives, minus the frame throttle) — grows the warehouse table + the
/// accruing Iceberg snapshot list, so the aggregated `state_json` tree grows too.
fn ticked_demo(n: usize) -> DemoState {
let mut demo = DemoState::new();
for _ in 0..n {
demo.sim.tick();
demo.feed_facets_from_sim();
}
demo
}
/// The size (bytes) of the aggregated `state_json` a single `dump_state` serialises —
/// the per-frame payload the old hot path built AND wrote to disk every frame.
fn state_bytes(demo: &DemoState) -> usize {
serde_json::to_string(&demo.state_json()).map(|s| s.len()).unwrap_or(0)
}
#[test]
fn per_frame_state_dump_is_a_real_growing_cost_now_gated_out() {
// The payload grows as the live sim accrues snapshots — so the per-frame dump got
// steadily heavier the longer the demo ran (the "starts okay, then stutters" shape).
let light = state_bytes(&ticked_demo(0));
let heavy = state_bytes(&ticked_demo(40));
eprintln!("[demo-frame] state_json bytes: tick0={light} tick40={heavy}");
assert!(light > 2_000, "the whole-deck state_json is a non-trivial per-frame payload ({light} B)");
assert!(heavy > light, "the per-frame dump payload GROWS as the sim accrues snapshots ({light} → {heavy} B)");
// Time the work the OLD hot path did every frame (build the full tree + serialise)
// on a heavily-run demo — the ms/frame the fix removes from a plain interactive run.
let demo = ticked_demo(60);
let iters = 300;
// warm-up
for _ in 0..20 {
std::hint::black_box(state_bytes(&demo));
}
let t = Instant::now();
for _ in 0..iters {
std::hint::black_box(serde_json::to_vec(&demo.state_json()).unwrap());
}
let per_frame_ms = t.elapsed().as_secs_f64() * 1000.0 / iters as f64;
eprintln!(
"[demo-frame] removed per-frame dump cost ≈ {per_frame_ms:.3} ms/frame (build+serialise the whole {}-byte deck tree; the OLD binary ALSO fs::write'd this to disk EVERY frame)",
state_bytes(&demo)
);
// It is real, measurable per-frame work (not free) — which is exactly why paying it
// unconditionally every frame stuttered, and why gating it out of the interactive
// loop is the fix. (A generous floor so the assertion is stable across machines.)
assert!(per_frame_ms > 0.005, "the per-frame dump is real work ({per_frame_ms:.3} ms) — gating it out is the win");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-demo",
"demo_frame_dump_gated",
heavy > light && per_frame_ms > 0.005,
&format!("state_bytes_tick0={light} tick40={heavy} removed_ms_per_frame={per_frame_ms:.3}"),
);
}
#[test]
fn frame_clock_meter_tracks_ms_and_fps() {
// The on-screen / state_json frame-budget readout: a steady 60 fps (16.6 ms) reads
// back as ~60 fps; a heavy 100 ms frame reads back as ~10 fps. RED if the EWMA or
// the reciprocal is wrong (a broken meter would hide a regression from the user).
let mut fc = FrameClock::default();
for _ in 0..120 {
fc.tick(1.0 / 60.0);
}
assert!((fc.ms - 16.6).abs() < 1.0, "steady 60 fps reads ~16.6 ms/frame (got {:.2})", fc.ms);
assert!((fc.fps() - 60.0).abs() < 4.0, "…and ~60 fps (got {:.1})", fc.fps());
let mut slow = FrameClock::default();
for _ in 0..200 {
slow.tick(0.1); // 100 ms frames → a hard 10 fps stutter
}
assert!(slow.fps() < 15.0, "a 100 ms/frame stutter reads as a low fps ({:.1})", slow.fps());
}