facett-core/src/testmatrix.rs
//! **The functional-status → nornir test-matrix bridge** (feature `testmatrix`).
//!
//! This is the single, discoverable seam every facett crate's tests + self-test
//! / headless-render paths use to feed the `nornir test` matrix one row per
//! **real, meaningful check**. It wraps [`nornir_testmatrix::functional_status`]
//! so a leaf crate's test only has to call `facett_core::testmatrix::emit(...)`
//! (or [`emit_render`]) — it never has to depend on `nornir-testmatrix`
//! directly; this crate owns that edge.
//!
//! ## Gated so release strips it
//! Everything here is `#[cfg(feature = "testmatrix")]`. A facett crate adds a
//! passthrough `testmatrix = ["facett-core/testmatrix"]` and wraps each call in
//! `#[cfg(feature = "testmatrix")]`. So a **release build** (feature OFF):
//! * has no `nornir-testmatrix` dependency edge (it is `dep:` optional),
//! * compiles out every emit call site, and
//! * even a stray call would hit a no-op (the upstream function is itself a
//! no-op when its own `testmatrix` feature is off).
//!
//! With `--features testmatrix` each call appends one functional row to the JSON
//! sink at `$NORNIR_TESTMATRIX_OUT` (default `target/nornir-functional.json`),
//! which `nornir test` reads back into the matrix. A blank / zero-geometry
//! render becomes a **RED** functional row (`ok = false`); a real render is green.
/// Emit one functional-status row for a single real check.
///
/// * `component` — the unit under test (a facett component / view name).
/// * `check` — the specific assertion (e.g. `"renders_non_blank"`).
/// * `ok` — the **real** assertion result (`true` → pass, `false` → red row).
/// * `detail` — the **measured** value (a count, a ratio, a reason …).
///
/// No-op (and zero-cost) when the `testmatrix` feature is off.
#[cfg(feature = "testmatrix")]
pub fn emit(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// No-op shape compiled when `testmatrix` is OFF — the release path.
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}
/// **The ONE way a device-gated test may decline to run.** Call this, then return.
///
/// # The defect this deletes
///
/// The idiom it replaces, written by hand at twenty call sites across six facett
/// crates, is:
///
/// ```ignore
/// let Some((device, queue)) = device() else {
/// eprintln!("no WebGPU-class adapter — skipped");
/// return;
/// };
/// ```
///
/// `eprintln!` is a message to a human; **libtest records `ok`**. MEASURED on oden
/// 2026-08-22 by hiding the Vulkan and GL ICDs from the loader
/// (`VK_ICD_FILENAMES=/nonexistent.json __EGL_VENDOR_LIBRARY_FILENAMES=/nonexistent.json
/// GALLIUM_DRIVER=nonexistent LIBGL_DRIVERS_PATH=/nonexistent`), against facett
/// `9ed1f86f`: **twenty `#[test]` fns in nine files reported `ok`** — `gpu_taa` 4,
/// `gpu_picking` 6, `gpu_oit` 3, `gpu_sdf_parity` 1, `render_callchain_matrix` 1,
/// `gpu_graph_bloom` 2, `mega_still` 1, `gpu_bloom` 1, `sky_lut_proof` 1 — several of
/// them in **0.02 s**, with no device, no pipeline and no pixel. That is the same dark
/// green as the crash they were written for, and it is what `granska-25` named inside
/// `facett-map` before that crate grew its own gate.
///
/// # What this does instead
///
/// Three marks a green run cannot swallow:
///
/// 1. a line on stderr that says a skip is not a pass;
/// 2. a **non-passing matrix row** through [`emit`] (`ok = false`), so a skipped cell is
/// visibly not a passing cell — a no-op without the `testmatrix` feature, which is
/// why it is not the teeth;
/// 3. under `CI=1` or `FACETT_REQUIRE_GPU=1` it **panics**. A host that is supposed to
/// have an adapter and does not is a broken host, not a quiet pass. That is the
/// teeth, and it bites with the feature off.
///
/// `why` names what was missing (no adapter, no TIMESTAMP_QUERY, …); `detail` names what
/// the arm would have proven, so the row is readable without opening the test.
///
/// This lives in `facett-core` rather than in each crate's `tests/common/mod.rs` because
/// there must be one writer for "an arm declined to run" (LAW 5) — `facett-map`'s gate
/// delegates here rather than keeping a second copy.
///
/// # "No adapter" is now TWO different facts
///
/// oden runs thirteen lanes over one RTX 4090, and on 2026-08-22 the shared device
/// started refusing work under contention: the owner's live `korp-ui` died twice in an hour
/// with SIGABRT and no Rust panic, with NVKMS GEM allocation failures in `dmesg` beside a
/// segfaulting test binary. On such a box a device request can fail for a reason that has
/// **nothing** to do with this host lacking a GPU — and reporting the two as one string
/// would trade a silent green for a flaky red, which is not a trade worth making.
///
/// So every skip is classified by [`host_gpu`] before it is filed: it asks the loader to
/// ENUMERATE adapters, which is a different question from "may I have a device". Zero
/// adapters means a GPU-less host; adapters present but no device means the device was
/// busy or lost. Both still fail under `CI` / `FACETT_REQUIRE_GPU` — a proof that did not
/// run is never a pass — but the message and the matrix row say WHICH, so nobody spends a
/// morning debugging a driver that is fine.
pub fn gpu_skip(component: &str, check: &str, why: &str, detail: &str) {
let host = host_gpu();
let verdict = host.describe();
eprintln!(
"[gpu-gate] {component}::{check}: SKIPPED — {why}. A skip is not a pass ({detail}) [{verdict}]"
);
emit(component, check, false, &format!("SKIPPED: {why} [{verdict}] — not a pass ({detail})"));
assert!(
std::env::var("CI").is_err() && std::env::var("FACETT_REQUIRE_GPU").is_err(),
"{component}::{check}: {why} — {verdict}. CI / FACETT_REQUIRE_GPU says this host \
must be able to run it. A device-gated proof that silently returns is the defect \
it was written to catch."
);
}
/// What the wgpu loader says about this box, asked the moment a device request failed.
///
/// The distinction that matters: [`Self::None`] is a property of the HOST (no driver, no
/// device node, ICDs hidden) and is stable across reruns; [`Self::Busy`] is a property of
/// the MOMENT (another lane holds the card, an allocation failed, the device was lost) and
/// usually is not. They arrive through the same `Option::None` at a call site, which is
/// exactly why they have to be told apart here instead of there.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostGpu {
/// The loader enumerates no adapter at all on any backend. This host has no GPU.
None,
/// Adapters ARE enumerable (named here) — so the device request failed for a reason
/// other than absence: contention, an allocation failure, or a lost device.
Busy(Vec<String>),
/// Not askable: this build of `facett-core` has no `wgpu` feature, so there is no
/// loader to ask. Reported as unknown rather than guessed — see LAW 7.
Unknown,
}
impl HostGpu {
/// One line for a human and for the matrix row. Never guesses.
pub fn describe(&self) -> String {
match self {
Self::None => "the loader enumerates NO adapter on any backend — this host has no GPU".to_string(),
Self::Busy(names) => format!(
"the loader DOES enumerate {} adapter(s) ({}) — the device was BUSY or LOST, \
not absent; on a shared box this is contention, not a broken driver",
names.len(),
names.join(", ")
),
Self::Unknown => "not askable: this build has no `wgpu` feature, so there is no loader to ask".to_string(),
}
}
}
/// Ask the loader to enumerate adapters. Enumeration is a strictly weaker request than
/// `request_adapter` + `request_device`, so it answers the one question the failing call
/// site could not: was there anything there at all?
#[cfg(feature = "wgpu")]
pub fn host_gpu() -> HostGpu {
let instance = wgpu::Instance::default();
let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()));
if adapters.is_empty() {
HostGpu::None
} else {
HostGpu::Busy(adapters.iter().map(|a| a.get_info().name).collect())
}
}
/// Without the `wgpu` feature there is no loader in this binary to ask.
#[cfg(not(feature = "wgpu"))]
pub fn host_gpu() -> HostGpu {
HostGpu::Unknown
}
/// Convenience for a **headless render** check: a render is OK only when it drew
/// real geometry AND the component reports non-zero domain geometry
/// (`geometry > 0`). A blank framebuffer (`vertices == 0`) or a zero-geometry
/// component (`geometry == 0`, e.g. a dead map with no ways/points) emits a RED
/// row so a dead view is visible in the matrix.
///
/// `geometry` is the component's own count of what it should be drawing — ways +
/// points for a map, nodes for a graph, rows for a table — read from its
/// `state_json`. `vertices` is the harness's tessellated-mesh proof that the GPU
/// path actually produced primitives.
#[cfg(feature = "testmatrix")]
pub fn emit_render(component: &str, vertices: usize, geometry: usize) {
let ok = vertices > 0 && geometry > 0;
emit(
component,
"headless_render",
ok,
&format!("vertices={vertices} geometry={geometry}"),
);
}
/// No-op shape compiled when `testmatrix` is OFF — the release path.
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit_render(_component: &str, _vertices: usize, _geometry: usize) {}
/// Convenience for a **pixel non-blank** render proof (the map / geomap class):
/// OK only when the measured content ratio clears `min_ratio` AND `geometry > 0`.
/// A blank pane (ratio ≈ 0) or zero-geometry view emits a RED row.
#[cfg(feature = "testmatrix")]
pub fn emit_non_blank(component: &str, ratio: f64, min_ratio: f64, geometry: usize) {
let ok = ratio >= min_ratio && geometry > 0;
emit(
component,
"renders_non_blank",
ok,
&format!("content_ratio={ratio:.4} min={min_ratio} geometry={geometry}"),
);
}
/// No-op shape compiled when `testmatrix` is OFF — the release path.
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit_non_blank(_component: &str, _ratio: f64, _min_ratio: f64, _geometry: usize) {}
// ── The discovery-driven functional cell (backfills the zero-cell panes) ─────
//
// [`emit_facet_probe`] turns one [`FacetProbe`](crate::harness::FacetProbe) — the
// output of headless-rendering + JSON-driving ANY `dyn Facet` — into the set of
// functional rows a pane needs, in ONE call. This is the shared path the ~14
// panes that emit no matrix cell adopt: a leaf's test builds its pane via its own
// `local()` demo constructor, calls
// [`probe_facet`](crate::harness::probe_facet), and hands the result here — no
// per-crate render+emit boilerplate. Everything is `#[cfg(feature = "testmatrix")]`
// so release strips the edge, exactly like [`emit`].
/// Emit the functional rows a probed pane needs from one
/// [`FacetProbe`](crate::harness::FacetProbe):
/// * `renders_non_blank` — the initial headless render produced geometry AND the
/// pane reports non-empty domain state (a blank OR zero-cardinality pane → RED);
/// * `responds_to_input` — at least one scripted `update_json` message moved
/// `state_json` (only emitted when messages were scripted; a dead input surface
/// → RED).
///
/// No-op (zero-cost) when the `testmatrix` feature is off.
#[cfg(feature = "testmatrix")]
pub fn emit_facet_probe(component: &str, probe: &crate::harness::FacetProbe) {
emit_render(component, probe.vertices, probe.cardinality());
if !probe.steps.is_empty() {
emit(
component,
"responds_to_input",
probe.responded(),
&format!(
"changed={}/{} steps",
probe.steps.iter().filter(|s| s.changed).count(),
probe.steps.len()
),
);
}
}
/// No-op shape compiled when `testmatrix` is OFF — the release path.
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit_facet_probe(_component: &str, _probe: &crate::harness::FacetProbe) {}
/// One-shot convenience: [`probe_facet`](crate::harness::probe_facet) the pane
/// through `msgs`, then [`emit_facet_probe`] the result. The single line a leaf's
/// discovery test writes to give its pane a functional matrix cell. Returns the
/// probe so the test can add pane-specific assertions on top.
pub fn probe_and_emit(
component: &str,
facet: &mut dyn crate::Facet,
msgs: &[&str],
) -> crate::harness::FacetProbe {
let probe = crate::harness::probe_facet(facet, msgs);
emit_facet_probe(component, &probe);
probe
}
#[cfg(all(test, feature = "testmatrix"))]
mod tests {
use super::*;
/// The recorder reads `NORNIR_TESTMATRIX_OUT` from the process-global env at
/// each `record` call, so the two tests that set/clear it must NOT run
/// concurrently (cargo runs tests in parallel) or one clears the other's sink
/// mid-emit. Serialize them behind this lock.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// INJECT-AND-ASSERT: a real render report → a real functional row on the
/// sink. We point the sink at a temp file, emit a green + a red render, and
/// assert both landed with the right status (the matrix's RED/green contract).
#[test]
fn emit_render_writes_green_and_red_rows() {
let _env = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let dir = std::env::temp_dir().join(format!(
"facett-tm-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("functional.json");
// SAFETY (edition 2024): the ENV_LOCK above serializes the env-touching
// run single-threaded for the duration of the assertions; no other
// thread reads the environment concurrently here.
unsafe {
std::env::set_var("NORNIR_TESTMATRIX_OUT", &file);
std::env::set_var("NORNIR_TESTMATRIX_REPO", "facett");
std::env::set_var("NORNIR_TESTMATRIX_RUN", "run-fc-test");
}
// A live view: drew 1234 verts over 9 geometry → green.
emit_render("live_view", 1234, 9);
// A dead map: 0 geometry even though something drew → RED.
emit_render("dead_map", 50, 0);
// A blank pane: ratio under floor → RED.
emit_non_blank("blank_pane", 0.0007, 0.01, 40_000);
// A real pane: ratio over floor + geometry → green.
emit_non_blank("real_map", 0.028, 0.01, 40_000);
let rows = nornir_testmatrix::JsonFileSink::new(&file)
.read_all()
.unwrap();
assert_eq!(rows.len(), 4, "four checks → four functional rows");
let live = rows.iter().find(|r| r.suite == "live_view").unwrap();
assert_eq!(live.status, nornir_testmatrix::status::PASS);
assert_eq!(live.aspect, "functional"); // functional_status sets aspect="functional" (no ASPECT_FUNCTIONAL const)
let dead = rows.iter().find(|r| r.suite == "dead_map").unwrap();
assert_eq!(dead.status, nornir_testmatrix::status::FAIL, "zero geometry = RED");
let blank = rows.iter().find(|r| r.suite == "blank_pane").unwrap();
assert_eq!(blank.status, nornir_testmatrix::status::FAIL, "blank pane = RED");
let real = rows.iter().find(|r| r.suite == "real_map").unwrap();
assert_eq!(real.status, nornir_testmatrix::status::PASS);
unsafe {
std::env::remove_var("NORNIR_TESTMATRIX_OUT");
std::env::remove_var("NORNIR_TESTMATRIX_REPO");
std::env::remove_var("NORNIR_TESTMATRIX_RUN");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A pane with a live `update_json` surface, for the probe→emit test.
struct ProbeListPane {
items: Vec<String>,
}
impl crate::Facet for ProbeListPane {
fn title(&self) -> &str {
"probe_list"
}
fn ui(&mut self, ui: &mut egui::Ui) {
for it in &self.items {
ui.label(it);
}
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "items": self.items })
}
fn update_json(&mut self, msg_json: &str) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json)
&& let Some(s) = v.get("push").and_then(|x| x.as_str())
{
self.items.push(s.to_string());
}
}
}
/// INJECT-AND-ASSERT: driving a real pane through `probe_and_emit` writes the
/// discovery-driven functional rows — a green render row (drew + non-empty
/// state) and a green `responds_to_input` row — onto the sink.
#[test]
fn probe_and_emit_writes_discovery_rows() {
let _env = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let dir = std::env::temp_dir().join(format!(
"facett-tm-probe-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("functional.json");
unsafe {
std::env::set_var("NORNIR_TESTMATRIX_OUT", &file);
std::env::set_var("NORNIR_TESTMATRIX_REPO", "facett");
std::env::set_var("NORNIR_TESTMATRIX_RUN", "run-probe");
}
let mut pane = ProbeListPane { items: vec!["seed".into()] };
let probe = probe_and_emit("probe_list", &mut pane, &[r#"{"push":"a"}"#, r#"{"push":"b"}"#]);
assert!(probe.responded(), "both pushes move state");
assert_eq!(pane.items.len(), 3);
let rows = nornir_testmatrix::JsonFileSink::new(&file).read_all().unwrap();
// One render row + one responds_to_input row.
assert_eq!(rows.len(), 2, "probe emits render + input rows");
let render = rows.iter().find(|r| r.test_name == "headless_render").unwrap();
assert_eq!(render.suite, "probe_list");
assert_eq!(render.status, nornir_testmatrix::status::PASS, "drew + non-empty state = green");
let input = rows.iter().find(|r| r.test_name == "responds_to_input").unwrap();
assert_eq!(input.status, nornir_testmatrix::status::PASS, "live update_json = green");
unsafe {
std::env::remove_var("NORNIR_TESTMATRIX_OUT");
std::env::remove_var("NORNIR_TESTMATRIX_REPO");
std::env::remove_var("NORNIR_TESTMATRIX_RUN");
}
let _ = std::fs::remove_dir_all(&dir);
}
}