graph-explorer-style 0.1.0

Scene model, animation and declarative styling for graph-explorer.
Documentation
//! Enforces the spec's core bar: once buffers are warm, the per-frame path
//! (`tick_into` / `tick_positioned_into`, at rest AND mid-transition) and the
//! warm `set_target` path perform ZERO heap allocations. One #[test] only:
//! a second test would run on another thread in the same binary and pollute
//! the counter.

use std::alloc::{GlobalAlloc, Layout, System};
use std::hint::black_box;
use std::sync::atomic::{AtomicU64, Ordering};
use graph_explorer_style::{Animator, Easing, Frame, HaloEmphasis, Scene, SceneEdge, SceneHalo, SceneNode};

struct Counting;
static ALLOCS: AtomicU64 = AtomicU64::new(0);

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, l: Layout) -> *mut u8 {
        ALLOCS.fetch_add(1, Ordering::Relaxed);
        unsafe { System.alloc(l) }
    }
    unsafe fn dealloc(&self, p: *mut u8, l: Layout) { unsafe { System.dealloc(p, l) } }
    unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 {
        ALLOCS.fetch_add(1, Ordering::Relaxed);
        unsafe { System.realloc(p, l, n) }
    }
}

#[global_allocator]
static A: Counting = Counting;

fn build_scene(n: u32) -> Scene {
    let mut s = Scene::default();
    for i in 0..n {
        s.set(i, SceneNode {
            pos: [(i % 100) as f32 * 10.0, (i / 100) as f32 * 10.0],
            radius: 5.0, color: [0.5; 4], opacity: 1.0, shape: 0, label: 0,
        });
    }
    for i in 0..n.saturating_sub(1) {
        s.edges.push(SceneEdge { a: i, b: i + 1, color: [1.0; 4], width: 1.0, opacity: 1.0 });
    }
    // Halos too — the in-module capacity test's scene has none, so this is
    // the only place the halo resolve path is allocation-audited.
    for i in 0..n.min(8) {
        s.halos.push(SceneHalo { node: i, emphasis: if i == 0 { HaloEmphasis::Primary } else { HaloEmphasis::Secondary } });
    }
    s
}

#[test]
fn steady_state_allocates_nothing() {
    const N: u32 = 2000;
    let mut a = Animator::new();
    let mut f = Frame::default();

    // --- at rest (snap target, then tick) ---
    a.set_target(build_scene(N), 0.0, 0.0, Easing::Linear, false);
    for t in 0..60 { a.tick_into(t as f64 * 16.7, &mut f); black_box(&f); } // warmup
    let before = ALLOCS.load(Ordering::SeqCst);
    for t in 60..660 { a.tick_into(t as f64 * 16.7, &mut f); black_box(&f); }
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "at-rest ticks allocated");

    // --- mid-transition (tween in flight for the whole measured window) ---
    a.set_target(build_scene(N), 0.0, 1_000_000.0, Easing::EaseInOut, true);
    for t in 0..60 { a.tick_into(t as f64 * 16.7, &mut f); black_box(&f); }
    let before = ALLOCS.load(Ordering::SeqCst);
    for t in 60..660 { a.tick_into(t as f64 * 16.7, &mut f); black_box(&f); }
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "mid-transition ticks allocated");

    // --- simulation override path ---
    // Every 7th node is omitted (None) to also audit the documented fallback
    // arm: an owned-but-omitted index keeps the animator's tweened position
    // instead of the simulation's, for that call.
    let over: Vec<Option<[f32; 2]>> = (0..N)
        .map(|i| if i % 7 == 0 { None } else { Some([i as f32, -(i as f32)]) })
        .collect();
    for t in 0..60 { a.tick_positioned_into(t as f64 * 16.7, &over, &mut f); black_box(&f); }
    black_box(&over);
    let before = ALLOCS.load(Ordering::SeqCst);
    for t in 60..660 { a.tick_positioned_into(t as f64 * 16.7, &over, &mut f); black_box(&f); }
    black_box(&over);
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "override ticks allocated");

    // --- warm retarget (set_target itself must reuse from_buf, not clone) ---
    // Pre-build the scene OUTSIDE the measured window: set_target consumes it
    // by value, so the only allocations inside the window would be set_target's
    // own — which must be zero.
    let t2 = build_scene(N);
    let before = ALLOCS.load(Ordering::SeqCst);
    a.set_target(t2, 20_000.0, 100.0, Easing::Linear, true);
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "warm retarget allocated");

    // Warm tick right after set_target — unmeasured, just brings the fresh
    // transition's buffers up before the measured windows below.
    a.tick_into(20_001.0, &mut f);
    black_box(&f);

    // dur_ms = 100, start_ms = 20_000.0, so raw = (now_ms - 20_000.0) / 100
    // crosses 1.0 exactly at t = 100. Three separately-asserted windows so the
    // commit branch (`raw >= 1.0`: `displayed.nodes.clone_from(&to.nodes);
    // animating = false`) is itself on the enforced list, cold — it's the
    // settle tick every transition eventually takes.

    // --- post-retarget in-flight ticks (raw stays below 1.0 throughout) ---
    let before = ALLOCS.load(Ordering::SeqCst);
    for t in 2..95 { a.tick_into(20_000.0 + t as f64, &mut f); black_box(&f); }
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "post-retarget in-flight ticks allocated");

    // --- transition commit tick (raw crosses >= 1.0 at t=100; zero cold, no warmup) ---
    let before = ALLOCS.load(Ordering::SeqCst);
    for t in 95..105 { a.tick_into(20_000.0 + t as f64, &mut f); black_box(&f); }
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "transition-commit tick allocated");

    // --- settled post-commit ticks (fully at rest again) ---
    let before = ALLOCS.load(Ordering::SeqCst);
    for t in 105..300 { a.tick_into(20_000.0 + t as f64, &mut f); black_box(&f); }
    assert_eq!(ALLOCS.load(Ordering::SeqCst) - before, 0, "post-commit at-rest ticks allocated");
}