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// ── Elm headless driver (FC-2 as a testable property) ────────────────────────
208//
209// The Facet helpers above render a facet offscreen and read its pixels/state. The
210// pair below drive an [`Elm`](crate::Elm) component with **no egui and no GPU at
211// all**: apply a `Vec<Msg>`, collect the `Effect`s, then snapshot `state()`. That
212// is exactly FC-2 → FC-3 as a property a `proptest` (contract §5.1) can assert:
213// *feed inputs, observe state*. `view()` (the only part that touches egui) is never
214// called here, so these run anywhere, deterministically, with zero device.
215
216/// Apply `msgs` to `component` in order via [`Elm::update`](crate::Elm::update),
217/// returning **every** [`Effect`](crate::Elm::Effect) produced, in emission order.
218/// No rendering happens — this is the pure state-transition driver.
219pub fn drive<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> Vec<C::Effect> {
220    let mut effects = Vec::new();
221    for m in msgs {
222        effects.extend(component.update(m));
223    }
224    effects
225}
226
227/// [`drive`] `component` through `msgs`, then return a **clone** of the resulting
228/// [`Model`](crate::Elm::Model) — the headless FC-3 snapshot a test asserts on.
229pub fn snapshot<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> C::Model {
230    drive(component, msgs);
231    component.state().clone()
232}
233
234/// [`drive`] `component` through `msgs`, then serialize the resulting
235/// [`Model`](crate::Elm::Model) to JSON (FC-3). The machine-readable observable
236/// state after the input sequence, with no render in the loop.
237pub fn snapshot_json<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> serde_json::Value {
238    drive(component, msgs);
239    state_json(component)
240}
241
242/// Serialize an [`Elm`](crate::Elm) component's current [`Model`](crate::Elm::Model)
243/// to JSON without applying any `Msg` (FC-3).
244pub fn state_json<C: crate::Elm>(component: &C) -> serde_json::Value {
245    serde_json::to_value(component.state()).unwrap_or(serde_json::Value::Null)
246}
247
248// ── Generic `dyn Facet` functional probe (the discovery-driven matrix path) ──
249//
250// The `drive`/`snapshot` pair above are *typed* — they need the component's
251// concrete `Msg`/`Model`, so a leaf's test must know the pane's type. The probe
252// below works on the **object-safe** `dyn Facet` seam (`title`/`ui`/`state_json`/
253// `update_json`) that EVERY facett pane already implements. It headless-renders
254// the pane, then plays a scripted list of `update_json` messages, snapshotting
255// `state_json` after each, so ONE shared function gives a pane the two proofs a
256// functional matrix cell needs — "it drew real geometry" and "it exposes live,
257// input-responsive state" — WITHOUT the caller knowing the concrete Msg type.
258// This is the single path the ~14 zero-cell panes call (each builds its pane via
259// its own `local()` demo constructor and hands it here) instead of re-writing
260// render+emit boilerplate per crate. Pair it with
261// [`testmatrix::emit_facet_probe`](crate::testmatrix::emit_facet_probe).
262
263/// One scripted `update_json` step against a facet: the message applied, the
264/// vertex count of the re-render after it, and whether `state_json` changed.
265#[derive(Debug, Clone)]
266pub struct ProbeStep {
267    /// The JSON message string handed to [`Facet::update_json`](crate::Facet::update_json).
268    pub msg: String,
269    /// Tessellated vertices of the re-render after this message.
270    pub vertices: usize,
271    /// Did this message move `state_json` (a live, input-responsive pane)?
272    pub changed: bool,
273    /// The pane's full `state_json` snapshot after this message.
274    pub state: serde_json::Value,
275}
276
277/// The result of a full [`probe_facet`] run over one `dyn Facet`.
278#[derive(Debug, Clone)]
279pub struct FacetProbe {
280    pub title: String,
281    /// Vertices of the initial headless render (before any message).
282    pub vertices: usize,
283    /// `state_json` before any message was applied.
284    pub initial_state: serde_json::Value,
285    /// `state_json` after the last scripted message (== `initial_state` when no
286    /// messages were scripted).
287    pub final_state: serde_json::Value,
288    /// Per-message observations, in order.
289    pub steps: Vec<ProbeStep>,
290}
291
292impl FacetProbe {
293    /// The initial render produced primitives.
294    pub fn drew(&self) -> bool {
295        self.vertices > 0
296    }
297    /// At least one scripted message moved `state_json` — proof the pane's input
298    /// surface is live, not a no-op stub. Always `false` when no messages were
299    /// scripted (there was nothing to respond to).
300    pub fn responded(&self) -> bool {
301        self.steps.iter().any(|s| s.changed)
302    }
303    /// A generic "how much domain data does this pane report" hint derived from
304    /// `final_state`: the largest array length found in the state tree, else the
305    /// number of top-level object keys, else 0. Lets a dead pane (empty state)
306    /// score a RED render cell even if it drew chrome vertices — a 0-row table
307    /// draws its header but reports `cardinality == 0`. A heuristic, not a
308    /// contract; a caller with an exact count can emit its own row.
309    pub fn cardinality(&self) -> usize {
310        json_cardinality(&self.final_state)
311    }
312}
313
314/// Largest array length anywhere in `v`, else (for an object with no arrays) its
315/// key count, else 0. See [`FacetProbe::cardinality`].
316pub fn json_cardinality(v: &serde_json::Value) -> usize {
317    match v {
318        serde_json::Value::Array(a) => a
319            .iter()
320            .map(json_cardinality)
321            .max()
322            .unwrap_or(0)
323            .max(a.len()),
324        serde_json::Value::Object(o) => {
325            let deepest = o.values().map(json_cardinality).max().unwrap_or(0);
326            deepest.max(o.len())
327        }
328        _ => 0,
329    }
330}
331
332/// Render `facet` headlessly once, then apply each JSON message in `msgs` via
333/// [`Facet::update_json`](crate::Facet::update_json), re-rendering and snapshotting
334/// `state_json` after each. Returns a [`FacetProbe`] with the initial render proof
335/// plus per-message transitions. No display, no GPU. Pass `&[]` for a pure
336/// render+state probe of a read-only pane.
337pub fn probe_facet(facet: &mut dyn Facet, msgs: &[&str]) -> FacetProbe {
338    let ctx = egui::Context::default();
339    let first = capture(&ctx, facet, (800.0, 600.0));
340    let initial_state = first.state.clone();
341    let mut prev = initial_state.clone();
342    let mut steps = Vec::with_capacity(msgs.len());
343    for msg in msgs {
344        facet.update_json(msg);
345        let r = capture(&ctx, facet, (800.0, 600.0));
346        let changed = r.state != prev;
347        trail(
348            Kind::Case,
349            format!("{} update_json {} → changed={}", first.title, msg, changed),
350        );
351        prev = r.state.clone();
352        steps.push(ProbeStep { msg: (*msg).to_string(), vertices: r.vertices, changed, state: r.state });
353    }
354    let final_state = steps.last().map(|s| s.state.clone()).unwrap_or_else(|| initial_state.clone());
355    FacetProbe { title: first.title, vertices: first.vertices, initial_state, final_state, steps }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::{Scene, hash_color};
362
363    struct Tiny(Scene);
364    impl Facet for Tiny {
365        fn title(&self) -> &str {
366            "tiny"
367        }
368        fn ui(&mut self, ui: &mut egui::Ui) {
369            crate::draw(ui, &self.0, crate::Layout::Circular, "empty");
370        }
371        fn state_json(&self) -> serde_json::Value {
372            serde_json::json!({ "nodes": self.0.nodes.len() })
373        }
374    }
375
376    #[test]
377    fn now_stamp_is_hms_millis_shaped() {
378        let s = now_stamp();
379        // HH:MM:SS.mmm — 12 chars, two ':' and one '.'.
380        assert_eq!(s.len(), 12, "stamp `{s}` should be HH:MM:SS.mmm");
381        assert_eq!(s.matches(':').count(), 2, "stamp `{s}` needs two colons");
382        assert_eq!(s.matches('.').count(), 1, "stamp `{s}` needs one dot");
383    }
384
385    #[test]
386    fn seq_is_monotonic() {
387        let a = next_seq();
388        let b = next_seq();
389        assert!(b > a, "seq must strictly increase: {a} then {b}");
390    }
391
392    #[test]
393    fn kind_tags_are_distinct() {
394        let tags = [Kind::Render.tag(), Kind::State.tag(), Kind::Case.tag()];
395        for (i, t) in tags.iter().enumerate() {
396            assert!(!t.is_empty());
397            assert!(!tags[..i].contains(t), "duplicate tag {t}");
398        }
399    }
400
401    #[test]
402    fn headless_render_captures_state_and_draws() {
403        let mut scene = Scene::new();
404        let a = scene.node("a", hash_color("a"));
405        let b = scene.node("b", hash_color("b"));
406        scene.edge(a, b);
407        let mut t = Tiny(scene);
408        let r = headless_render(&mut t);
409        assert_eq!(r.title, "tiny");
410        assert_eq!(r.state["nodes"], 2);
411        assert!(r.drew(), "a 2-node graph should tessellate to vertices");
412    }
413
414    // ── Elm trait + macro + headless driver, proven end-to-end on a mock ─────────
415    //
416    // A miniature reference component in the same Model/Msg/Effect/pure-view shape
417    // as `facett-security`. `facett-core` cannot depend on `facett-security` (that
418    // would be a dependency cycle), so the trait/macro/harness are proven here on a
419    // self-contained mock; `facett-security`'s own test suite proves them against
420    // the real reference component.
421
422    use crate::Elm;
423    use serde::{Deserialize, Serialize};
424
425    /// All observable state (FC-1 / FC-3).
426    #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
427    struct CounterState {
428        count: i64,
429        selected: Option<String>,
430    }
431
432    /// Every input (FC-2).
433    #[derive(Clone, Debug, PartialEq)]
434    enum CounterMsg {
435        Inc,
436        Dec,
437        Add(i64),
438        Select(Option<String>),
439    }
440
441    /// No I/O (FC-8) — uninhabited on purpose.
442    #[derive(Debug)]
443    enum CounterEffect {}
444
445    struct Counter {
446        title: String,
447        state: CounterState,
448    }
449
450    impl Counter {
451        fn new() -> Self {
452            Self { title: "counter".into(), state: CounterState { count: 0, selected: None } }
453        }
454    }
455
456    impl Elm for Counter {
457        type Model = CounterState;
458        type Msg = CounterMsg;
459        type Effect = CounterEffect;
460
461        fn title(&self) -> &str {
462            &self.title
463        }
464        fn state(&self) -> &CounterState {
465            &self.state
466        }
467        fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
468            match msg {
469                CounterMsg::Inc => self.state.count += 1,
470                CounterMsg::Dec => self.state.count -= 1,
471                CounterMsg::Add(n) => self.state.count += n,
472                // Toggle: re-selecting the open id clears it (mirrors security).
473                CounterMsg::Select(id) => {
474                    self.state.selected = if self.state.selected == id { None } else { id };
475                }
476            }
477            Vec::new()
478        }
479        fn view(&self, ui: &mut egui::Ui) -> Vec<CounterMsg> {
480            // Pure: paints, returns Msgs (here: none — the driver feeds inputs).
481            ui.label(format!("count = {}", self.state.count));
482            Vec::new()
483        }
484    }
485
486    // The macro under test: writes `impl Facet for Counter` from the `Elm` impl.
487    crate::impl_facet_via_elm!(Counter);
488
489    /// A component that publishes a RICHER `state_json` than plain `serde(state())`
490    /// — the case that forced helix/timeline/plandag to hand-write `impl Facet`
491    /// before form 3 existed. Reuses `CounterState`/`CounterMsg`/`CounterEffect`.
492    struct RichCounter {
493        state: CounterState,
494    }
495    impl Elm for RichCounter {
496        type Model = CounterState;
497        type Msg = CounterMsg;
498        type Effect = CounterEffect;
499        fn title(&self) -> &str {
500            "rich"
501        }
502        fn state(&self) -> &CounterState {
503            &self.state
504        }
505        fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
506            if let CounterMsg::Add(n) = msg {
507                self.state.count += n;
508            }
509            Vec::new()
510        }
511        fn view(&self, _ui: &mut egui::Ui) -> Vec<CounterMsg> {
512            Vec::new()
513        }
514    }
515    // Form 3: `custom_state_json` suppresses the default; we emit the model PLUS a
516    // derived `parity` key the default serde dump would never produce.
517    crate::impl_facet_via_elm!(RichCounter, custom_state_json, {
518        fn state_json(&self) -> serde_json::Value {
519            serde_json::json!({ "count": self.state.count, "parity": self.state.count % 2 })
520        }
521    });
522
523    /// Form 3 lets a rich component override `state_json` via the macro (no
524    /// duplicate-method clash), so the deck/matrix see its derived introspection keys.
525    #[test]
526    fn form3_custom_state_json_overrides_the_default() {
527        use crate::Facet;
528        let mut c = RichCounter { state: CounterState { count: 0, selected: None } };
529        let _ = c.update(CounterMsg::Add(7));
530        let j = Facet::state_json(&c);
531        assert_eq!(j["count"], 7, "custom state_json is emitted");
532        assert_eq!(j["parity"], 1, "the DERIVED key the default serde dump omits is present");
533        assert_eq!(Facet::title(&c), "rich");
534    }
535
536    #[test]
537    fn harness_drives_msgs_and_snapshots_state() {
538        let mut c = Counter::new();
539        // Apply a Vec<Msg>, then snapshot state() — the FC-2 → FC-3 property, no GPU.
540        let snap = snapshot(
541            &mut c,
542            [CounterMsg::Inc, CounterMsg::Inc, CounterMsg::Add(5), CounterMsg::Dec],
543        );
544        assert_eq!(snap.count, 6, "1+1+5-1 = 6");
545        assert_eq!(c.state().count, 6, "the component holds the driven state");
546
547        // Selection toggles (re-selecting the open id clears it).
548        let snap = snapshot(&mut c, [CounterMsg::Select(Some("a".into())), CounterMsg::Select(Some("a".into()))]);
549        assert_eq!(snap.selected, None, "toggle clears the re-selected id");
550    }
551
552    #[test]
553    fn drive_returns_effects_and_snapshot_json_serializes_state() {
554        let mut c = Counter::new();
555        let effects = drive(&mut c, [CounterMsg::Inc, CounterMsg::Add(3)]);
556        assert!(effects.is_empty(), "FC-8: this component does no I/O");
557        let js = snapshot_json(&mut c, [CounterMsg::Inc]);
558        assert_eq!(js["count"], 5, "1+3+1 = 5, observable as JSON");
559        assert_eq!(js["selected"], serde_json::Value::Null);
560    }
561
562    #[test]
563    fn macro_bridges_elm_to_facet() {
564        // The macro-generated Facet impl: title from Elm, state_json = serde(state).
565        let mut c = Counter::new();
566        drive(&mut c, [CounterMsg::Add(9)]);
567        assert_eq!(Facet::title(&c), "counter");
568        assert_eq!(Facet::state_json(&c), state_json(&c), "macro state_json == serde(state())");
569        assert_eq!(Facet::state_json(&c)["count"], 9);
570        // And the generated `ui` (view + update loop) renders headlessly.
571        let r = headless_render(&mut c);
572        assert_eq!(r.title, "counter");
573        assert_eq!(r.state["count"], 9);
574        assert!(r.drew(), "the label tessellates to vertices");
575    }
576
577    #[test]
578    fn proptest_style_msg_sequences_keep_state_wellformed() {
579        // proptest-style (dependency-free, deterministic LCG): generate many Msg
580        // sequences from arbitrary start states, apply them, and assert invariants
581        // hold — no panic, serde round-trips, and the count matches an independent
582        // reference fold. This is contract §5.1 (property layer) over `update`.
583        let mut rng: u64 = 0x9E3779B97F4A7C15;
584        let mut next = || {
585            rng ^= rng << 13;
586            rng ^= rng >> 7;
587            rng ^= rng << 17;
588            rng
589        };
590        for _ in 0..500 {
591            let mut c = Counter::new();
592            c.state.count = (next() % 21) as i64 - 10; // arbitrary start in -10..=10
593            let mut expected = c.state.count;
594            let mut expect_sel: Option<String> = None;
595            c.state.selected = None;
596            let len = (next() % 12) as usize;
597            let msgs: Vec<CounterMsg> = (0..len)
598                .map(|_| match next() % 4 {
599                    0 => {
600                        expected += 1;
601                        CounterMsg::Inc
602                    }
603                    1 => {
604                        expected -= 1;
605                        CounterMsg::Dec
606                    }
607                    2 => {
608                        let n = (next() % 7) as i64 - 3;
609                        expected += n;
610                        CounterMsg::Add(n)
611                    }
612                    _ => {
613                        let id = format!("id{}", next() % 3);
614                        let new = Some(id.clone());
615                        expect_sel = if expect_sel == new { None } else { new };
616                        CounterMsg::Select(Some(id))
617                    }
618                })
619                .collect();
620
621            let snap = snapshot(&mut c, msgs);
622            // Invariant 1: the driven count matches the independent fold.
623            assert_eq!(snap.count, expected);
624            // Invariant 2: selection toggle matches the reference.
625            assert_eq!(snap.selected, expect_sel);
626            // Invariant 3: state round-trips through serde (FC-3).
627            let json = serde_json::to_string(&snap).unwrap();
628            let back: CounterState = serde_json::from_str(&json).unwrap();
629            assert_eq!(back, snap);
630        }
631    }
632
633    // ── Generic `dyn Facet` probe ────────────────────────────────────────────
634
635    /// A pane with a LIVE `update_json` input surface (`{"push":"x"}` appends an
636    /// item, `{"clear":true}` empties) — the shape the discovery probe drives.
637    struct ListPane {
638        items: Vec<String>,
639    }
640    impl Facet for ListPane {
641        fn title(&self) -> &str {
642            "listpane"
643        }
644        fn ui(&mut self, ui: &mut egui::Ui) {
645            for it in &self.items {
646                ui.label(it);
647            }
648        }
649        fn state_json(&self) -> serde_json::Value {
650            serde_json::json!({ "items": self.items })
651        }
652        fn update_json(&mut self, msg_json: &str) {
653            let v: serde_json::Value = match serde_json::from_str(msg_json) {
654                Ok(v) => v,
655                Err(_) => return,
656            };
657            if let Some(s) = v.get("push").and_then(|x| x.as_str()) {
658                self.items.push(s.to_string());
659            }
660            if v.get("clear").and_then(|x| x.as_bool()) == Some(true) {
661                self.items.clear();
662            }
663        }
664    }
665
666    #[test]
667    fn probe_facet_captures_render_and_transitions() {
668        let mut pane = ListPane { items: vec!["seed".into()] };
669        let probe = probe_facet(&mut pane, &[r#"{"push":"a"}"#, r#"{"push":"b"}"#, r#"{"clear":true}"#]);
670        assert_eq!(probe.title, "listpane");
671        assert!(probe.drew(), "a label list should tessellate to vertices");
672        assert_eq!(probe.initial_state["items"].as_array().unwrap().len(), 1);
673        assert_eq!(probe.steps.len(), 3);
674        // Every scripted message here moves state → all three steps changed.
675        assert!(probe.steps.iter().all(|s| s.changed), "each push/clear mutates state");
676        assert!(probe.responded());
677        // After push a, push b, clear → empty list.
678        assert_eq!(probe.final_state["items"].as_array().unwrap().len(), 0);
679    }
680
681    #[test]
682    fn probe_facet_no_msgs_is_pure_render() {
683        let mut pane = ListPane { items: vec!["x".into(), "y".into()] };
684        let probe = probe_facet(&mut pane, &[]);
685        assert!(probe.steps.is_empty());
686        assert!(!probe.responded(), "no messages → nothing to respond to");
687        assert_eq!(probe.final_state, probe.initial_state);
688        assert_eq!(probe.cardinality(), 2, "two items → cardinality 2");
689    }
690
691    #[test]
692    fn probe_flags_a_dead_input_surface() {
693        // `Tiny` never overrides `update_json` (default no-op) — a read-only pane.
694        // Driving it with a message must report NO response, so the matrix can
695        // catch a pane whose input surface is silently dead.
696        let mut scene = Scene::new();
697        scene.node("n", hash_color("n"));
698        let mut t = Tiny(scene);
699        let probe = probe_facet(&mut t, &[r#"{"anything":1}"#]);
700        assert_eq!(probe.steps.len(), 1);
701        assert!(!probe.responded(), "a no-op update_json must not register as responsive");
702    }
703
704    #[test]
705    fn json_cardinality_finds_the_largest_collection() {
706        assert_eq!(json_cardinality(&serde_json::json!({"rows": [1, 2, 3, 4]})), 4);
707        assert_eq!(json_cardinality(&serde_json::json!([1, 2])), 2);
708        assert_eq!(json_cardinality(&serde_json::json!({"a": 1, "b": 2})), 2, "no arrays → key count");
709        assert_eq!(json_cardinality(&serde_json::json!(null)), 0);
710        assert_eq!(
711            json_cardinality(&serde_json::json!({"outer": {"inner": [1, 2, 3, 4, 5]}})),
712            5,
713            "reaches nested arrays"
714        );
715    }
716}