Skip to main content

facett_core/
harness.rs

1//! Headless test harness — **fire up a `Facet`, inject data, render it offscreen,
2//! and capture what it drew**: its `state_json` + a vertex count (a "it drew
3//! something" proxy) + a stderr activity trail. No display, no GPU. This is the
4//! basis of facett's auto test matrix, and mirrors nornir viz's
5//! `NORNIR_VIZ_STATE` introspection — every component is observable from outside.
6
7use crate::Facet;
8
9/// What a headless render of one facet looked like.
10#[derive(Debug, Clone)]
11pub struct RenderReport {
12    pub title: String,
13    /// The component's observable state (its `Facet::state_json`).
14    pub state: serde_json::Value,
15    /// Tessellated mesh vertices — a proxy for "it actually drew something".
16    pub vertices: usize,
17}
18
19impl RenderReport {
20    pub fn drew(&self) -> bool {
21        self.vertices > 0
22    }
23}
24
25/// Render `facet` once into the given context at `size`, capturing its state +
26/// a vertex count. A panic in `ui` propagates — that's the point of the test.
27#[allow(deprecated)] // ctx.run / CentralPanel::show are the headless-render path
28fn capture(ctx: &egui::Context, facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
29    let title = facet.title().to_string();
30    // Structured trace IN: which facet + at what size this render was handed
31    // (the typed sibling of the `trail`/`log` lines below — see `trace`).
32    crate::trace::emit_in(
33        "facet.render",
34        &serde_json::json!({ "title": title, "size": [size.0, size.1] }),
35    );
36    let input = egui::RawInput {
37        screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(size.0, size.1))),
38        ..Default::default()
39    };
40    let output = ctx.run(input, |ctx| {
41        egui::CentralPanel::default().show(ctx, |ui| facet.ui(ui));
42    });
43    let prims = ctx.tessellate(output.shapes, output.pixels_per_point);
44    let vertices = prims
45        .iter()
46        .map(|p| match &p.primitive {
47            egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
48            _ => 0,
49        })
50        .sum();
51    let report = RenderReport { title, state: facet.state_json(), vertices };
52    log(&report);
53    trail(Kind::Render, format!("{} size={}x{} → {} verts", report.title, size.0 as i32, size.1 as i32, vertices));
54    dump_state(&report);
55    // Structured trace OUT: the real data the facet rendered — its full
56    // observable state + the vertex proof — so an agent reads back exactly what
57    // was drawn, no screenshot. (`state` is the same Value `dump_state` prints.)
58    crate::trace::emit_out(
59        "facet.render",
60        &serde_json::json!({
61            "title": report.title,
62            "vertices": report.vertices,
63            "drew": report.drew(),
64            "state": report.state,
65        }),
66    );
67    report
68}
69
70/// Headless render at `size` (default theme).
71pub fn render_sized(facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
72    capture(&egui::Context::default(), facet, size)
73}
74
75/// `render_sized` at a default 800×600.
76pub fn headless_render(facet: &mut dyn Facet) -> RenderReport {
77    render_sized(facet, (800.0, 600.0))
78}
79
80/// Headless render with a theme applied (asserts the themed paint path works).
81pub fn render_themed(facet: &mut dyn Facet, theme: crate::Theme) -> RenderReport {
82    let ctx = egui::Context::default();
83    crate::set_theme(&ctx, theme);
84    capture(&ctx, facet, (800.0, 600.0))
85}
86
87/// **Test/host hook (additive).** Headless render at `size` WITH `theme` applied —
88/// the themed + sized paint path the graph-skin call-chain matrix sweeps (theme ×
89/// canvas is a distinct painter path). Same `capture` the other helpers use, so it
90/// IS the render the pixels come from; additive, no signature change to the existing
91/// helpers.
92pub fn render_themed_sized(facet: &mut dyn Facet, theme: crate::Theme, size: (f32, f32)) -> RenderReport {
93    let ctx = egui::Context::default();
94    crate::set_theme(&ctx, theme);
95    capture(&ctx, facet, size)
96}
97
98/// Stderr activity trail (one line per render), like nornir viz's. The state is
99/// capped so a large component can't flood the log.
100pub fn log(r: &RenderReport) {
101    let full = r.state.to_string();
102    let shown: String = if full.chars().count() > 160 {
103        full.chars().take(159).chain(std::iter::once('…')).collect()
104    } else {
105        full
106    };
107    eprintln!("facett: {:<14} {:>7} verts · {}", r.title, r.vertices, shown);
108}
109
110// ── action-log-style trail (mirrors nornir viz `action_log`) ─────────────────
111//
112// Nornir's viz emits a timestamped, kinded, sequenced trail
113// (`HH:MM:SS.mmm  <seq> [KIND] detail`) on stderr + a greppable file so a human
114// can follow "what the headless run did". These give facett's matrices the same
115// observability. Dep-free: the stamp is derived from `SystemTime` and the seq
116// from a process-global atomic — no chrono, no extra crate.
117
118/// Coarse, greppable category for a trail entry — facett's analogue of nornir's
119/// `action_log::Kind`.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Kind {
122    /// A facet was rendered headlessly.
123    Render,
124    /// A facet's full observable state was captured.
125    State,
126    /// A per-case matrix summary (component × theme × size).
127    Case,
128}
129
130impl Kind {
131    pub fn tag(self) -> &'static str {
132        match self {
133            Kind::Render => "RENDER",
134            Kind::State => "STATE",
135            Kind::Case => "CASE",
136        }
137    }
138}
139
140/// Process-global monotonic sequence — stable ordering even within one ms.
141fn next_seq() -> u64 {
142    use std::sync::atomic::{AtomicU64, Ordering};
143    static SEQ: AtomicU64 = AtomicU64::new(0);
144    SEQ.fetch_add(1, Ordering::Relaxed) + 1
145}
146
147/// `HH:MM:SS.mmm` local-ish wall stamp from `SystemTime` (UTC, no tz dep). Only
148/// the time-of-day matters for following a trail, so this is intentionally
149/// dependency-free rather than chrono-accurate.
150fn now_stamp() -> String {
151    let now = std::time::SystemTime::now()
152        .duration_since(std::time::UNIX_EPOCH)
153        .unwrap_or_default();
154    let total_ms = now.as_millis();
155    let ms = (total_ms % 1000) as u64;
156    let secs = (total_ms / 1000) as u64;
157    let h = (secs / 3600) % 24;
158    let m = (secs / 60) % 60;
159    let s = secs % 60;
160    format!("{h:02}:{m:02}:{s:02}.{ms:03}")
161}
162
163/// Emit one greppable, timestamped, kinded trail line — facett's analogue of
164/// nornir viz's `action_log` stderr sink:
165///   `facett ACTION HH:MM:SS.mmm  <seq> [KIND] detail`
166/// If `$FACETT_TRAIL` is set, the same line is appended to that file (greppable,
167/// externally observable — mirrors how nornir mirrors `$NORNIR_VIZ_ACTIONLOG`).
168pub fn trail(kind: Kind, detail: impl AsRef<str>) {
169    let stamp = now_stamp();
170    let seq = next_seq();
171    let detail = detail.as_ref();
172    let line = format!("facett ACTION {stamp} {seq:>5} [{}] {detail}", kind.tag());
173    eprintln!("{line}");
174    if let Ok(path) = std::env::var("FACETT_TRAIL") {
175        use std::io::Write;
176        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
177            let _ = writeln!(f, "{line}");
178        }
179    }
180}
181
182/// Dump a facet's FULL observable `state_json` as a single greppable line
183/// (`facett STATE <title> = {…}`), the per-component analogue of viz_matrix's
184/// `eprintln!("state_json = {pretty}")`. Untruncated so a Facet's rendered
185/// contents are greppable in test output the way viz's are.
186pub fn dump_state(r: &RenderReport) {
187    eprintln!("facett STATE {} = {}", r.title, r.state);
188    trail(Kind::State, format!("{} state={}", r.title, r.state));
189}
190
191/// Emit a uniform per-case matrix summary line + trail entry for one
192/// component × axis case (e.g. a theme or a size), mirroring viz_matrix's
193/// `[ws] releases=… tables=…` per-workspace summary. `axis` is a free-form
194/// label like `theme=sci_fi` or `size=10000`.
195pub fn case_summary(component: &str, axis: &str, r: &RenderReport) {
196    eprintln!(
197        "facett CASE  {:<14} {:<16} → {:>8} verts  drew={}  state={}",
198        component,
199        axis,
200        r.vertices,
201        r.drew(),
202        r.state,
203    );
204    trail(Kind::Case, format!("{component} {axis} verts={} drew={}", r.vertices, r.drew()));
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::{Scene, hash_color};
211
212    struct Tiny(Scene);
213    impl Facet for Tiny {
214        fn title(&self) -> &str {
215            "tiny"
216        }
217        fn ui(&mut self, ui: &mut egui::Ui) {
218            crate::draw(ui, &self.0, crate::Layout::Circular, "empty");
219        }
220        fn state_json(&self) -> serde_json::Value {
221            serde_json::json!({ "nodes": self.0.nodes.len() })
222        }
223    }
224
225    #[test]
226    fn now_stamp_is_hms_millis_shaped() {
227        let s = now_stamp();
228        // HH:MM:SS.mmm — 12 chars, two ':' and one '.'.
229        assert_eq!(s.len(), 12, "stamp `{s}` should be HH:MM:SS.mmm");
230        assert_eq!(s.matches(':').count(), 2, "stamp `{s}` needs two colons");
231        assert_eq!(s.matches('.').count(), 1, "stamp `{s}` needs one dot");
232    }
233
234    #[test]
235    fn seq_is_monotonic() {
236        let a = next_seq();
237        let b = next_seq();
238        assert!(b > a, "seq must strictly increase: {a} then {b}");
239    }
240
241    #[test]
242    fn kind_tags_are_distinct() {
243        let tags = [Kind::Render.tag(), Kind::State.tag(), Kind::Case.tag()];
244        for (i, t) in tags.iter().enumerate() {
245            assert!(!t.is_empty());
246            assert!(!tags[..i].contains(t), "duplicate tag {t}");
247        }
248    }
249
250    #[test]
251    fn headless_render_captures_state_and_draws() {
252        let mut scene = Scene::new();
253        let a = scene.node("a", hash_color("a"));
254        let b = scene.node("b", hash_color("b"));
255        scene.edge(a, b);
256        let mut t = Tiny(scene);
257        let r = headless_render(&mut t);
258        assert_eq!(r.title, "tiny");
259        assert_eq!(r.state["nodes"], 2);
260        assert!(r.drew(), "a 2-node graph should tessellate to vertices");
261    }
262}