Skip to main content

facett_core/
testmatrix.rs

1//! **The functional-status → nornir test-matrix bridge** (feature `testmatrix`).
2//!
3//! This is the single, discoverable seam every facett crate's tests + self-test
4//! / headless-render paths use to feed the `nornir test` matrix one row per
5//! **real, meaningful check**. It wraps [`nornir_testmatrix::functional_status`]
6//! so a leaf crate's test only has to call `facett_core::testmatrix::emit(...)`
7//! (or [`emit_render`]) — it never has to depend on `nornir-testmatrix`
8//! directly; this crate owns that edge.
9//!
10//! ## Gated so release strips it
11//! Everything here is `#[cfg(feature = "testmatrix")]`. A facett crate adds a
12//! passthrough `testmatrix = ["facett-core/testmatrix"]` and wraps each call in
13//! `#[cfg(feature = "testmatrix")]`. So a **release build** (feature OFF):
14//! * has no `nornir-testmatrix` dependency edge (it is `dep:` optional),
15//! * compiles out every emit call site, and
16//! * even a stray call would hit a no-op (the upstream function is itself a
17//!   no-op when its own `testmatrix` feature is off).
18//!
19//! With `--features testmatrix` each call appends one functional row to the JSON
20//! sink at `$NORNIR_TESTMATRIX_OUT` (default `target/nornir-functional.json`),
21//! which `nornir test` reads back into the matrix. A blank / zero-geometry
22//! render becomes a **RED** functional row (`ok = false`); a real render is green.
23
24/// Emit one functional-status row for a single real check.
25///
26/// * `component` — the unit under test (a facett component / view name).
27/// * `check` — the specific assertion (e.g. `"renders_non_blank"`).
28/// * `ok` — the **real** assertion result (`true` → pass, `false` → red row).
29/// * `detail` — the **measured** value (a count, a ratio, a reason …).
30///
31/// No-op (and zero-cost) when the `testmatrix` feature is off.
32#[cfg(feature = "testmatrix")]
33pub fn emit(component: &str, check: &str, ok: bool, detail: &str) {
34    nornir_testmatrix::functional_status(component, check, ok, detail);
35}
36
37/// No-op shape compiled when `testmatrix` is OFF — the release path.
38#[cfg(not(feature = "testmatrix"))]
39#[inline(always)]
40pub fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}
41
42/// Convenience for a **headless render** check: a render is OK only when it drew
43/// real geometry AND the component reports non-zero domain geometry
44/// (`geometry > 0`). A blank framebuffer (`vertices == 0`) or a zero-geometry
45/// component (`geometry == 0`, e.g. a dead map with no ways/points) emits a RED
46/// row so a dead view is visible in the matrix.
47///
48/// `geometry` is the component's own count of what it should be drawing — ways +
49/// points for a map, nodes for a graph, rows for a table — read from its
50/// `state_json`. `vertices` is the harness's tessellated-mesh proof that the GPU
51/// path actually produced primitives.
52#[cfg(feature = "testmatrix")]
53pub fn emit_render(component: &str, vertices: usize, geometry: usize) {
54    let ok = vertices > 0 && geometry > 0;
55    emit(
56        component,
57        "headless_render",
58        ok,
59        &format!("vertices={vertices} geometry={geometry}"),
60    );
61}
62
63/// No-op shape compiled when `testmatrix` is OFF — the release path.
64#[cfg(not(feature = "testmatrix"))]
65#[inline(always)]
66pub fn emit_render(_component: &str, _vertices: usize, _geometry: usize) {}
67
68/// Convenience for a **pixel non-blank** render proof (the map / geomap class):
69/// OK only when the measured content ratio clears `min_ratio` AND `geometry > 0`.
70/// A blank pane (ratio ≈ 0) or zero-geometry view emits a RED row.
71#[cfg(feature = "testmatrix")]
72pub fn emit_non_blank(component: &str, ratio: f64, min_ratio: f64, geometry: usize) {
73    let ok = ratio >= min_ratio && geometry > 0;
74    emit(
75        component,
76        "renders_non_blank",
77        ok,
78        &format!("content_ratio={ratio:.4} min={min_ratio} geometry={geometry}"),
79    );
80}
81
82/// No-op shape compiled when `testmatrix` is OFF — the release path.
83#[cfg(not(feature = "testmatrix"))]
84#[inline(always)]
85pub fn emit_non_blank(_component: &str, _ratio: f64, _min_ratio: f64, _geometry: usize) {}
86
87// ── The discovery-driven functional cell (backfills the zero-cell panes) ─────
88//
89// [`emit_facet_probe`] turns one [`FacetProbe`](crate::harness::FacetProbe) — the
90// output of headless-rendering + JSON-driving ANY `dyn Facet` — into the set of
91// functional rows a pane needs, in ONE call. This is the shared path the ~14
92// panes that emit no matrix cell adopt: a leaf's test builds its pane via its own
93// `local()` demo constructor, calls
94// [`probe_facet`](crate::harness::probe_facet), and hands the result here — no
95// per-crate render+emit boilerplate. Everything is `#[cfg(feature = "testmatrix")]`
96// so release strips the edge, exactly like [`emit`].
97
98/// Emit the functional rows a probed pane needs from one
99/// [`FacetProbe`](crate::harness::FacetProbe):
100/// * `renders_non_blank` — the initial headless render produced geometry AND the
101///   pane reports non-empty domain state (a blank OR zero-cardinality pane → RED);
102/// * `responds_to_input` — at least one scripted `update_json` message moved
103///   `state_json` (only emitted when messages were scripted; a dead input surface
104///   → RED).
105///
106/// No-op (zero-cost) when the `testmatrix` feature is off.
107#[cfg(feature = "testmatrix")]
108pub fn emit_facet_probe(component: &str, probe: &crate::harness::FacetProbe) {
109    emit_render(component, probe.vertices, probe.cardinality());
110    if !probe.steps.is_empty() {
111        emit(
112            component,
113            "responds_to_input",
114            probe.responded(),
115            &format!(
116                "changed={}/{} steps",
117                probe.steps.iter().filter(|s| s.changed).count(),
118                probe.steps.len()
119            ),
120        );
121    }
122}
123
124/// No-op shape compiled when `testmatrix` is OFF — the release path.
125#[cfg(not(feature = "testmatrix"))]
126#[inline(always)]
127pub fn emit_facet_probe(_component: &str, _probe: &crate::harness::FacetProbe) {}
128
129/// One-shot convenience: [`probe_facet`](crate::harness::probe_facet) the pane
130/// through `msgs`, then [`emit_facet_probe`] the result. The single line a leaf's
131/// discovery test writes to give its pane a functional matrix cell. Returns the
132/// probe so the test can add pane-specific assertions on top.
133pub fn probe_and_emit(
134    component: &str,
135    facet: &mut dyn crate::Facet,
136    msgs: &[&str],
137) -> crate::harness::FacetProbe {
138    let probe = crate::harness::probe_facet(facet, msgs);
139    emit_facet_probe(component, &probe);
140    probe
141}
142
143#[cfg(all(test, feature = "testmatrix"))]
144mod tests {
145    use super::*;
146
147    /// The recorder reads `NORNIR_TESTMATRIX_OUT` from the process-global env at
148    /// each `record` call, so the two tests that set/clear it must NOT run
149    /// concurrently (cargo runs tests in parallel) or one clears the other's sink
150    /// mid-emit. Serialize them behind this lock.
151    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
152
153    /// INJECT-AND-ASSERT: a real render report → a real functional row on the
154    /// sink. We point the sink at a temp file, emit a green + a red render, and
155    /// assert both landed with the right status (the matrix's RED/green contract).
156    #[test]
157    fn emit_render_writes_green_and_red_rows() {
158        let _env = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
159        let dir = std::env::temp_dir().join(format!(
160            "facett-tm-{}",
161            std::time::SystemTime::now()
162                .duration_since(std::time::UNIX_EPOCH)
163                .unwrap()
164                .as_nanos()
165        ));
166        std::fs::create_dir_all(&dir).unwrap();
167        let file = dir.join("functional.json");
168        // SAFETY (edition 2024): the ENV_LOCK above serializes the env-touching
169        // run single-threaded for the duration of the assertions; no other
170        // thread reads the environment concurrently here.
171        unsafe {
172            std::env::set_var("NORNIR_TESTMATRIX_OUT", &file);
173            std::env::set_var("NORNIR_TESTMATRIX_REPO", "facett");
174            std::env::set_var("NORNIR_TESTMATRIX_RUN", "run-fc-test");
175        }
176
177        // A live view: drew 1234 verts over 9 geometry → green.
178        emit_render("live_view", 1234, 9);
179        // A dead map: 0 geometry even though something drew → RED.
180        emit_render("dead_map", 50, 0);
181        // A blank pane: ratio under floor → RED.
182        emit_non_blank("blank_pane", 0.0007, 0.01, 40_000);
183        // A real pane: ratio over floor + geometry → green.
184        emit_non_blank("real_map", 0.028, 0.01, 40_000);
185
186        let rows = nornir_testmatrix::JsonFileSink::new(&file)
187            .read_all()
188            .unwrap();
189        assert_eq!(rows.len(), 4, "four checks → four functional rows");
190
191        let live = rows.iter().find(|r| r.suite == "live_view").unwrap();
192        assert_eq!(live.status, nornir_testmatrix::status::PASS);
193        assert_eq!(live.aspect, "functional"); // functional_status sets aspect="functional" (no ASPECT_FUNCTIONAL const)
194
195        let dead = rows.iter().find(|r| r.suite == "dead_map").unwrap();
196        assert_eq!(dead.status, nornir_testmatrix::status::FAIL, "zero geometry = RED");
197
198        let blank = rows.iter().find(|r| r.suite == "blank_pane").unwrap();
199        assert_eq!(blank.status, nornir_testmatrix::status::FAIL, "blank pane = RED");
200
201        let real = rows.iter().find(|r| r.suite == "real_map").unwrap();
202        assert_eq!(real.status, nornir_testmatrix::status::PASS);
203
204        unsafe {
205            std::env::remove_var("NORNIR_TESTMATRIX_OUT");
206            std::env::remove_var("NORNIR_TESTMATRIX_REPO");
207            std::env::remove_var("NORNIR_TESTMATRIX_RUN");
208        }
209        let _ = std::fs::remove_dir_all(&dir);
210    }
211
212    /// A pane with a live `update_json` surface, for the probe→emit test.
213    struct ProbeListPane {
214        items: Vec<String>,
215    }
216    impl crate::Facet for ProbeListPane {
217        fn title(&self) -> &str {
218            "probe_list"
219        }
220        fn ui(&mut self, ui: &mut egui::Ui) {
221            for it in &self.items {
222                ui.label(it);
223            }
224        }
225        fn state_json(&self) -> serde_json::Value {
226            serde_json::json!({ "items": self.items })
227        }
228        fn update_json(&mut self, msg_json: &str) {
229            if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json)
230                && let Some(s) = v.get("push").and_then(|x| x.as_str())
231            {
232                self.items.push(s.to_string());
233            }
234        }
235    }
236
237    /// INJECT-AND-ASSERT: driving a real pane through `probe_and_emit` writes the
238    /// discovery-driven functional rows — a green render row (drew + non-empty
239    /// state) and a green `responds_to_input` row — onto the sink.
240    #[test]
241    fn probe_and_emit_writes_discovery_rows() {
242        let _env = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
243        let dir = std::env::temp_dir().join(format!(
244            "facett-tm-probe-{}",
245            std::time::SystemTime::now()
246                .duration_since(std::time::UNIX_EPOCH)
247                .unwrap()
248                .as_nanos()
249        ));
250        std::fs::create_dir_all(&dir).unwrap();
251        let file = dir.join("functional.json");
252        unsafe {
253            std::env::set_var("NORNIR_TESTMATRIX_OUT", &file);
254            std::env::set_var("NORNIR_TESTMATRIX_REPO", "facett");
255            std::env::set_var("NORNIR_TESTMATRIX_RUN", "run-probe");
256        }
257
258        let mut pane = ProbeListPane { items: vec!["seed".into()] };
259        let probe = probe_and_emit("probe_list", &mut pane, &[r#"{"push":"a"}"#, r#"{"push":"b"}"#]);
260        assert!(probe.responded(), "both pushes move state");
261        assert_eq!(pane.items.len(), 3);
262
263        let rows = nornir_testmatrix::JsonFileSink::new(&file).read_all().unwrap();
264        // One render row + one responds_to_input row.
265        assert_eq!(rows.len(), 2, "probe emits render + input rows");
266
267        let render = rows.iter().find(|r| r.test_name == "headless_render").unwrap();
268        assert_eq!(render.suite, "probe_list");
269        assert_eq!(render.status, nornir_testmatrix::status::PASS, "drew + non-empty state = green");
270
271        let input = rows.iter().find(|r| r.test_name == "responds_to_input").unwrap();
272        assert_eq!(input.status, nornir_testmatrix::status::PASS, "live update_json = green");
273
274        unsafe {
275            std::env::remove_var("NORNIR_TESTMATRIX_OUT");
276            std::env::remove_var("NORNIR_TESTMATRIX_REPO");
277            std::env::remove_var("NORNIR_TESTMATRIX_RUN");
278        }
279        let _ = std::fs::remove_dir_all(&dir);
280    }
281}