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}