facett-core/src/harness.rs
//! Headless test harness — **fire up a `Facet`, inject data, render it offscreen,
//! and capture what it drew**: its `state_json` + a vertex count (a "it drew
//! something" proxy) + a stderr activity trail. No display, no GPU. This is the
//! basis of facett's auto test matrix, and mirrors nornir viz's
//! `NORNIR_VIZ_STATE` introspection — every component is observable from outside.
use crate::Facet;
/// What a headless render of one facet looked like.
#[derive(Debug, Clone)]
pub struct RenderReport {
pub title: String,
/// The component's observable state (its `Facet::state_json`).
pub state: serde_json::Value,
/// Tessellated mesh vertices — a proxy for "it actually drew something".
pub vertices: usize,
/// **Total area, in points, of the tessellated TEXTURED triangles** — the ink that
/// glyphs and images actually cover on screen.
///
/// [`Self::vertices`] cannot see a scale bug: a glyph is four vertices whether it
/// is drawn at 6pt or 60pt, so a facet whose uniform scale knob is ignored
/// entirely renders exactly the same vertex count. That is why every
/// `assert!(r.drew())` guard in the constellation stayed green while `+`/`−` did
/// nothing — measured: deleting the scale application outright from
/// facett-card / -console / -filedialog / -git / -security / -table left their
/// whole suites passing.
///
/// This is APPLIED geometry: triangle area, summed over the meshes the painter
/// really emitted, so doubling the scale must roughly quadruple it. Glyphs are
/// separated from solid fills by their UVs — a solid rect samples the font
/// atlas's single white texel ([`egui::epaint::WHITE_UV`]) while a glyph or an
/// image samples a real sub-rect, so a triangle with any non-`WHITE_UV` vertex is
/// textured ink.
///
/// Measured on the geometry the painter EMITS, before the scissor: egui clips at
/// raster time via each primitive's `clip_rect` rather than by trimming triangles,
/// so ink that a clip rect would crop still counts here. That is what a scale guard
/// wants — it proves the transform was applied — but it is not a count of lit
/// pixels, so don't read it as coverage.
pub textured_ink_area: f32,
/// **The colours the painter actually put on screen**, each with the triangle
/// area it covers, heaviest first (top [`INK_COLORS`]).
///
/// [`Self::drew`] cannot see colour at all, and that is the whole weakness of the
/// `assert!(r.drew())` idiom: it proves *something* was drawn, never that anything
/// correct was. Measured — deleting `set_theme` from the themed-render helpers, so
/// every palette paints identically, left `themed_render_works_for_every_palette`
/// (facett-grid), `themed_render_works_for_a_look_preset` (facett-console),
/// `themed_read_render_draws` (facett-card) and `sci_fi_theme_renders_every_component`
/// (facett) all green. They render every palette and assert only that pixels exist.
///
/// Use [`Self::ink_signature`] to compare two renders: two themes that really reach
/// the paint produce different signatures.
pub ink_colors: Vec<(egui::Color32, f32)>,
/// **Every colour the painter emitted, with WHERE and WHEN it was painted** —
/// the paint-order record. See [`InkLayer`]; unlike [`Self::ink_colors`] this is
/// not truncated, because an occlusion question is about a specific pair of
/// colours and the one you need is often not in the heaviest sixteen.
pub ink_layers: Vec<InkLayer>,
}
/// How many distinct colours [`RenderReport::ink_colors`] keeps (heaviest by area).
pub const INK_COLORS: usize = 16;
/// **One colour's footprint in the render stream** — what it covered, and its
/// position in PAINT ORDER.
///
/// egui paints back-to-front: a triangle emitted later is drawn over one emitted
/// earlier. The tessellator preserves the order shapes were added in, both across
/// primitives and within a mesh's index buffer, so a global triangle counter walked
/// in that order is a faithful z-order for the CPU painter.
#[derive(Debug, Clone, PartialEq)]
pub struct InkLayer {
pub color: egui::Color32,
/// Total triangle area carrying this colour.
pub area: f32,
/// Ordinal of the FIRST triangle carrying this colour (0 = painted first).
pub first: usize,
/// Ordinal of the LAST triangle carrying this colour.
pub last: usize,
/// Union of the positions of every triangle carrying this colour.
pub bbox: egui::Rect,
}
impl RenderReport {
pub fn drew(&self) -> bool {
self.vertices > 0
}
/// **A stable digest of [`Self::ink_colors`]** — what this render LOOKED like,
/// reduced to one comparable number.
///
/// Two renders of the same facet under two palettes must differ here; if they do
/// not, the facet never consumed the theme. Areas are quantised to whole points so
/// the digest is not hostage to sub-pixel layout jitter.
/// The [`InkLayer`] for `color`, if the painter emitted it at all.
pub fn layer(&self, color: egui::Color32) -> Option<&InkLayer> {
self.ink_layers.iter().find(|l| l.color == color)
}
/// **Does `under` paint STRICTLY beneath `over`, over ground they actually share?**
///
/// `Ok(())` only when all three hold: both colours were painted; their bounding
/// boxes OVERLAP (so the ordering is about occlusion at all, not two layers that
/// never meet); and every `under` triangle precedes every `over` triangle in paint
/// order. Otherwise `Err` naming which condition failed.
///
/// **What this cannot tell you.** It compares whole-colour spans and bounding
/// boxes, so it answers "could `under` ever occlude `over`", not "does the right
/// pixel win at coordinate (x, y)". Two layers whose bboxes overlap while their
/// actual geometry does not still read as overlapping here. It also says nothing
/// about alpha: a fully transparent top layer occludes nothing, yet its ordering
/// is still checked. Use it for the ordering claim it is named for, and do not
/// promote it to a coverage or a pixel oracle.
pub fn paints_strictly_under(&self, under: egui::Color32, over: egui::Color32) -> Result<(), String> {
let u = self.layer(under).ok_or_else(|| format!("the UNDER colour {under:?} was never painted"))?;
let o = self.layer(over).ok_or_else(|| format!("the OVER colour {over:?} was never painted"))?;
if !u.bbox.intersects(o.bbox) {
return Err(format!(
"{under:?} ({:?}) and {over:?} ({:?}) do not overlap at all, so their paint \
order proves nothing about occlusion",
u.bbox, o.bbox
));
}
if u.last >= o.first {
return Err(format!(
"{under:?} occupies paint ordinals {}..={} and {over:?} {}..={} — the under \
layer is still painting after the over layer starts, so it covers it",
u.first, u.last, o.first, o.last
));
}
Ok(())
}
pub fn ink_signature(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for (c, area) in &self.ink_colors {
c.to_array().hash(&mut h);
(*area as i64).hash(&mut h);
}
h.finish()
}
}
/// Sum the area of every tessellated TEXTURED triangle (glyph or image) (see [`RenderReport::textured_ink_area`]).
fn textured_ink_area(prims: &[egui::ClippedPrimitive]) -> f32 {
let mut area = 0.0;
for p in prims {
let egui::epaint::Primitive::Mesh(m) = &p.primitive else { continue };
for tri in m.indices.as_chunks::<3>().0 {
let (a, b, c) = (
m.vertices[tri[0] as usize],
m.vertices[tri[1] as usize],
m.vertices[tri[2] as usize],
);
// A solid fill samples only the atlas's white texel; a glyph does not.
if a.uv == egui::epaint::WHITE_UV
&& b.uv == egui::epaint::WHITE_UV
&& c.uv == egui::epaint::WHITE_UV
{
continue;
}
let (u, v) = (b.pos - a.pos, c.pos - a.pos);
area += (u.x * v.y - u.y * v.x).abs() * 0.5;
}
}
area
}
/// Every vertex colour with its area, paint-order span and bbox (see [`InkLayer`]).
fn ink_layers(prims: &[egui::ClippedPrimitive]) -> Vec<InkLayer> {
struct Acc {
area: f32,
first: usize,
last: usize,
bbox: egui::Rect,
}
let mut by_color: std::collections::HashMap<[u8; 4], Acc> = std::collections::HashMap::new();
// One counter across every primitive, incremented in emission order — this IS the
// z-order for the CPU painter (later triangles paint over earlier ones).
let mut ordinal = 0usize;
for p in prims {
let egui::epaint::Primitive::Mesh(m) = &p.primitive else { continue };
for tri in m.indices.as_chunks::<3>().0 {
let (a, b, c) = (
m.vertices[tri[0] as usize],
m.vertices[tri[1] as usize],
m.vertices[tri[2] as usize],
);
let (u, v) = (b.pos - a.pos, c.pos - a.pos);
let area = (u.x * v.y - u.y * v.x).abs() * 0.5;
let tri_box = egui::Rect::from_points(&[a.pos, b.pos, c.pos]);
// A triangle's three corners can carry three different colours (a
// gradient); attribute a third of the area to each so a shaded face is
// not filed under whichever corner happened to come first.
for vx in [a, b, c] {
let e = by_color.entry(vx.color.to_array()).or_insert(Acc {
area: 0.0,
first: ordinal,
last: ordinal,
bbox: tri_box,
});
e.area += area / 3.0;
e.first = e.first.min(ordinal);
e.last = e.last.max(ordinal);
e.bbox = e.bbox.union(tri_box);
}
ordinal += 1;
}
}
let mut v: Vec<InkLayer> = by_color
.into_iter()
.map(|(c, a)| InkLayer {
color: egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3]),
area: a.area,
first: a.first,
last: a.last,
bbox: a.bbox,
})
.collect();
// Heaviest first; ties broken on the colour so the order is deterministic.
v.sort_by(|x, y| y.area.total_cmp(&x.area).then_with(|| x.color.to_array().cmp(&y.color.to_array())));
v
}
/// Render `facet` once into the given context at `size`, capturing its state +
/// a vertex count. A panic in `ui` propagates — that's the point of the test.
fn capture(ctx: &egui::Context, facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
let title = facet.title().to_string();
// Structured trace IN: which facet + at what size this render was handed
// (the typed sibling of the `trail`/`log` lines below — see `trace`).
crate::trace::emit_in(
"facet.render",
&serde_json::json!({ "title": title, "size": [size.0, size.1] }),
);
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(size.0, size.1))),
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| facet.ui(ui));
});
// egui 0.36 (epaint #8356): a dropped TexturesDelta that still holds deltas is a
// debug_assert. A headless capture uploads no texture, so it says so explicitly.
output.textures_delta.clear();
let prims = ctx.tessellate(output.shapes, output.pixels_per_point);
let layers = ink_layers(&prims);
let vertices = prims
.iter()
.map(|p| match &p.primitive {
egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
_ => 0,
})
.sum();
let report =
RenderReport { title, state: facet.state_json(), vertices, textured_ink_area: textured_ink_area(&prims), ink_colors: layers.iter().take(INK_COLORS).map(|l| (l.color, l.area)).collect(), ink_layers: layers };
log(&report);
trail(Kind::Render, format!("{} size={}x{} → {} verts", report.title, size.0 as i32, size.1 as i32, vertices));
dump_state(&report);
// Structured trace OUT: the real data the facet rendered — its full
// observable state + the vertex proof — so an agent reads back exactly what
// was drawn, no screenshot. (`state` is the same Value `dump_state` prints.)
crate::trace::emit_out(
"facet.render",
&serde_json::json!({
"title": report.title,
"vertices": report.vertices,
"drew": report.drew(),
"state": report.state,
}),
);
report
}
/// Headless render at `size` (default theme).
pub fn render_sized(facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
capture(&egui::Context::default(), facet, size)
}
/// `render_sized` at a default 800×600.
pub fn headless_render(facet: &mut dyn Facet) -> RenderReport {
render_sized(facet, (800.0, 600.0))
}
/// Headless render with a theme applied (asserts the themed paint path works).
pub fn render_themed(facet: &mut dyn Facet, theme: crate::Theme) -> RenderReport {
let ctx = egui::Context::default();
crate::set_theme(&ctx, theme);
capture(&ctx, facet, (800.0, 600.0))
}
/// **Test/host hook (additive).** Headless render at `size` WITH `theme` applied —
/// the themed + sized paint path the graph-skin call-chain matrix sweeps (theme ×
/// canvas is a distinct painter path). Same `capture` the other helpers use, so it
/// IS the render the pixels come from; additive, no signature change to the existing
/// helpers.
pub fn render_themed_sized(facet: &mut dyn Facet, theme: crate::Theme, size: (f32, f32)) -> RenderReport {
let ctx = egui::Context::default();
crate::set_theme(&ctx, theme);
capture(&ctx, facet, size)
}
/// Stderr activity trail (one line per render), like nornir viz's. The state is
/// capped so a large component can't flood the log.
pub fn log(r: &RenderReport) {
let full = r.state.to_string();
let shown: String = if full.chars().count() > 160 {
full.chars().take(159).chain(std::iter::once('…')).collect()
} else {
full
};
eprintln!("facett: {:<14} {:>7} verts · {}", r.title, r.vertices, shown);
}
// ── action-log-style trail (mirrors nornir viz `action_log`) ─────────────────
//
// Nornir's viz emits a timestamped, kinded, sequenced trail
// (`HH:MM:SS.mmm <seq> [KIND] detail`) on stderr + a greppable file so a human
// can follow "what the headless run did". These give facett's matrices the same
// observability. Dep-free: the stamp is derived from `SystemTime` and the seq
// from a process-global atomic — no chrono, no extra crate.
/// Coarse, greppable category for a trail entry — facett's analogue of nornir's
/// `action_log::Kind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// A facet was rendered headlessly.
Render,
/// A facet's full observable state was captured.
State,
/// A per-case matrix summary (component × theme × size).
Case,
}
impl Kind {
pub fn tag(self) -> &'static str {
match self {
Kind::Render => "RENDER",
Kind::State => "STATE",
Kind::Case => "CASE",
}
}
}
/// Process-global monotonic sequence — stable ordering even within one ms.
fn next_seq() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
SEQ.fetch_add(1, Ordering::Relaxed) + 1
}
/// `HH:MM:SS.mmm` local-ish wall stamp from `SystemTime` (UTC, no tz dep). Only
/// the time-of-day matters for following a trail, so this is intentionally
/// dependency-free rather than chrono-accurate.
fn now_stamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_ms = now.as_millis();
let ms = (total_ms % 1000) as u64;
let secs = (total_ms / 1000) as u64;
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
}
/// Emit one greppable, timestamped, kinded trail line — facett's analogue of
/// nornir viz's `action_log` stderr sink:
/// `facett ACTION HH:MM:SS.mmm <seq> [KIND] detail`
/// If `$FACETT_TRAIL` is set, the same line is appended to that file (greppable,
/// externally observable — mirrors how nornir mirrors `$NORNIR_VIZ_ACTIONLOG`).
pub fn trail(kind: Kind, detail: impl AsRef<str>) {
let stamp = now_stamp();
let seq = next_seq();
let detail = detail.as_ref();
let line = format!("facett ACTION {stamp} {seq:>5} [{}] {detail}", kind.tag());
eprintln!("{line}");
if let Ok(path) = std::env::var("FACETT_TRAIL") {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{line}");
}
}
}
/// Dump a facet's FULL observable `state_json` as a single greppable line
/// (`facett STATE <title> = {…}`), the per-component analogue of viz_matrix's
/// `eprintln!("state_json = {pretty}")`. Untruncated so a Facet's rendered
/// contents are greppable in test output the way viz's are.
pub fn dump_state(r: &RenderReport) {
eprintln!("facett STATE {} = {}", r.title, r.state);
trail(Kind::State, format!("{} state={}", r.title, r.state));
}
/// Emit a uniform per-case matrix summary line + trail entry for one
/// component × axis case (e.g. a theme or a size), mirroring viz_matrix's
/// `[ws] releases=… tables=…` per-workspace summary. `axis` is a free-form
/// label like `theme=sci_fi` or `size=10000`.
pub fn case_summary(component: &str, axis: &str, r: &RenderReport) {
eprintln!(
"facett CASE {:<14} {:<16} → {:>8} verts drew={} state={}",
component,
axis,
r.vertices,
r.drew(),
r.state,
);
trail(Kind::Case, format!("{component} {axis} verts={} drew={}", r.vertices, r.drew()));
}
// ── Elm headless driver (FC-2 as a testable property) ────────────────────────
//
// The Facet helpers above render a facet offscreen and read its pixels/state. The
// pair below drive an [`Elm`](crate::Elm) component with **no egui and no GPU at
// all**: apply a `Vec<Msg>`, collect the `Effect`s, then snapshot `state()`. That
// is exactly FC-2 → FC-3 as a property a `proptest` (contract §5.1) can assert:
// *feed inputs, observe state*. `view()` (the only part that touches egui) is never
// called here, so these run anywhere, deterministically, with zero device.
/// Apply `msgs` to `component` in order via [`Elm::update`](crate::Elm::update),
/// returning **every** [`Effect`](crate::Elm::Effect) produced, in emission order.
/// No rendering happens — this is the pure state-transition driver.
pub fn drive<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> Vec<C::Effect> {
let mut effects = Vec::new();
for m in msgs {
effects.extend(component.update(m));
}
effects
}
/// [`drive`] `component` through `msgs`, then return a **clone** of the resulting
/// [`Model`](crate::Elm::Model) — the headless FC-3 snapshot a test asserts on.
pub fn snapshot<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> C::Model {
drive(component, msgs);
component.state().clone()
}
/// [`drive`] `component` through `msgs`, then serialize the resulting
/// [`Model`](crate::Elm::Model) to JSON (FC-3). The machine-readable observable
/// state after the input sequence, with no render in the loop.
pub fn snapshot_json<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> serde_json::Value {
drive(component, msgs);
state_json(component)
}
/// Serialize an [`Elm`](crate::Elm) component's current [`Model`](crate::Elm::Model)
/// to JSON without applying any `Msg` (FC-3).
pub fn state_json<C: crate::Elm>(component: &C) -> serde_json::Value {
serde_json::to_value(component.state()).unwrap_or(serde_json::Value::Null)
}
// ── Generic `dyn Facet` functional probe (the discovery-driven matrix path) ──
//
// The `drive`/`snapshot` pair above are *typed* — they need the component's
// concrete `Msg`/`Model`, so a leaf's test must know the pane's type. The probe
// below works on the **object-safe** `dyn Facet` seam (`title`/`ui`/`state_json`/
// `update_json`) that EVERY facett pane already implements. It headless-renders
// the pane, then plays a scripted list of `update_json` messages, snapshotting
// `state_json` after each, so ONE shared function gives a pane the two proofs a
// functional matrix cell needs — "it drew real geometry" and "it exposes live,
// input-responsive state" — WITHOUT the caller knowing the concrete Msg type.
// This is the single path the ~14 zero-cell panes call (each builds its pane via
// its own `local()` demo constructor and hands it here) instead of re-writing
// render+emit boilerplate per crate. Pair it with
// [`testmatrix::emit_facet_probe`](crate::testmatrix::emit_facet_probe).
/// One scripted `update_json` step against a facet: the message applied, the
/// vertex count of the re-render after it, and whether `state_json` changed.
#[derive(Debug, Clone)]
pub struct ProbeStep {
/// The JSON message string handed to [`Facet::update_json`](crate::Facet::update_json).
pub msg: String,
/// Tessellated vertices of the re-render after this message.
pub vertices: usize,
/// Did this message move `state_json` (a live, input-responsive pane)?
pub changed: bool,
/// The pane's full `state_json` snapshot after this message.
pub state: serde_json::Value,
}
/// The result of a full [`probe_facet`] run over one `dyn Facet`.
#[derive(Debug, Clone)]
pub struct FacetProbe {
pub title: String,
/// Vertices of the initial headless render (before any message).
pub vertices: usize,
/// `state_json` before any message was applied.
pub initial_state: serde_json::Value,
/// `state_json` after the last scripted message (== `initial_state` when no
/// messages were scripted).
pub final_state: serde_json::Value,
/// Per-message observations, in order.
pub steps: Vec<ProbeStep>,
}
impl FacetProbe {
/// The initial render produced primitives.
pub fn drew(&self) -> bool {
self.vertices > 0
}
/// At least one scripted message moved `state_json` — proof the pane's input
/// surface is live, not a no-op stub. Always `false` when no messages were
/// scripted (there was nothing to respond to).
pub fn responded(&self) -> bool {
self.steps.iter().any(|s| s.changed)
}
/// A generic "how much domain data does this pane report" hint derived from
/// `final_state`: the largest array length found in the state tree, else the
/// number of top-level object keys, else 0. Lets a dead pane (empty state)
/// score a RED render cell even if it drew chrome vertices — a 0-row table
/// draws its header but reports `cardinality == 0`. A heuristic, not a
/// contract; a caller with an exact count can emit its own row.
pub fn cardinality(&self) -> usize {
json_cardinality(&self.final_state)
}
}
/// Largest array length anywhere in `v`, else (for an object with no arrays) its
/// key count, else 0. See [`FacetProbe::cardinality`].
pub fn json_cardinality(v: &serde_json::Value) -> usize {
match v {
serde_json::Value::Array(a) => a
.iter()
.map(json_cardinality)
.max()
.unwrap_or(0)
.max(a.len()),
serde_json::Value::Object(o) => {
let deepest = o.values().map(json_cardinality).max().unwrap_or(0);
deepest.max(o.len())
}
_ => 0,
}
}
/// Render `facet` headlessly once, then apply each JSON message in `msgs` via
/// [`Facet::update_json`](crate::Facet::update_json), re-rendering and snapshotting
/// `state_json` after each. Returns a [`FacetProbe`] with the initial render proof
/// plus per-message transitions. No display, no GPU. Pass `&[]` for a pure
/// render+state probe of a read-only pane.
pub fn probe_facet(facet: &mut dyn Facet, msgs: &[&str]) -> FacetProbe {
let ctx = egui::Context::default();
let first = capture(&ctx, facet, (800.0, 600.0));
let initial_state = first.state.clone();
let mut prev = initial_state.clone();
let mut steps = Vec::with_capacity(msgs.len());
for msg in msgs {
facet.update_json(msg);
let r = capture(&ctx, facet, (800.0, 600.0));
let changed = r.state != prev;
trail(
Kind::Case,
format!("{} update_json {} → changed={}", first.title, msg, changed),
);
prev = r.state.clone();
steps.push(ProbeStep { msg: (*msg).to_string(), vertices: r.vertices, changed, state: r.state });
}
let final_state = steps.last().map(|s| s.state.clone()).unwrap_or_else(|| initial_state.clone());
FacetProbe { title: first.title, vertices: first.vertices, initial_state, final_state, steps }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Scene, hash_color};
struct Tiny(Scene);
impl Facet for Tiny {
fn title(&self) -> &str {
"tiny"
}
fn ui(&mut self, ui: &mut egui::Ui) {
crate::draw(ui, &self.0, crate::Layout::Circular, "empty");
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "nodes": self.0.nodes.len() })
}
}
/// A facet that paints exactly the rectangles it is told to, in order — so a test
/// can state a paint order and read it back out of the render stream.
struct Rects(Vec<(egui::Rect, egui::Color32)>);
impl Facet for Rects {
fn title(&self) -> &str {
"rects"
}
fn ui(&mut self, ui: &mut egui::Ui) {
let p = ui.painter();
for (r, c) in &self.0 {
p.rect_filled(*r, egui::CornerRadius::ZERO, *c);
}
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "rects": self.0.len() })
}
}
const UNDER: egui::Color32 = egui::Color32::from_rgb(10, 200, 30);
const OVER: egui::Color32 = egui::Color32::from_rgb(200, 10, 30);
fn r(x0: f32, y0: f32, x1: f32, y1: f32) -> egui::Rect {
egui::Rect::from_min_max(egui::pos2(x0, y0), egui::pos2(x1, y1))
}
#[test]
fn paints_strictly_under_reads_the_real_paint_order() {
let over_lap = r(20.0, 20.0, 80.0, 80.0);
let mut good = Rects(vec![(r(0.0, 0.0, 100.0, 100.0), UNDER), (over_lap, OVER)]);
assert!(render_sized(&mut good, (200.0, 200.0)).paints_strictly_under(UNDER, OVER).is_ok());
// Swap the two paints — both layers still present, only the order changed.
let mut bad = Rects(vec![(over_lap, OVER), (r(0.0, 0.0, 100.0, 100.0), UNDER)]);
let err = render_sized(&mut bad, (200.0, 200.0)).paints_strictly_under(UNDER, OVER).unwrap_err();
assert!(err.contains("still painting after"), "{err}");
}
#[test]
fn paints_strictly_under_reports_an_absent_layer_rather_than_passing() {
let mut only_over = Rects(vec![(r(0.0, 0.0, 50.0, 50.0), OVER)]);
let err = render_sized(&mut only_over, (200.0, 200.0)).paints_strictly_under(UNDER, OVER).unwrap_err();
assert!(err.contains("UNDER colour"), "{err}");
}
/// **THE PROBE'S OWN BLIND SPOT, pinned deliberately.**
///
/// [`RenderReport::paints_strictly_under`] gates on bounding-box intersection, and a
/// bbox is the axis-aligned union over every triangle of that colour. Two layers can
/// therefore have overlapping bboxes while their real geometry never touches — here
/// `UNDER` paints two opposite corners, so its bbox spans the whole area, and `OVER`
/// sits in the middle where `UNDER` has no ink at all. The probe returns `Ok`: the
/// order is right and the boxes intersect, but there is nothing to occlude.
///
/// This is the metro-guard lesson applied to the probe itself — a guard you have not
/// seen give a wrong answer is a guard you do not yet understand. Keep it to the
/// ORDERING claim it is named for. Answering "does the right pixel win at (x, y)"
/// needs a rasterised oracle, which this is not.
#[test]
fn disjoint_geometry_with_overlapping_bboxes_still_reads_as_ordered() {
let mut corners = Rects(vec![
(r(0.0, 0.0, 10.0, 10.0), UNDER),
(r(90.0, 90.0, 100.0, 100.0), UNDER),
(r(45.0, 45.0, 55.0, 55.0), OVER),
]);
let rep = render_sized(&mut corners, (200.0, 200.0));
assert!(
rep.paints_strictly_under(UNDER, OVER).is_ok(),
"documenting the limitation: bbox overlap is not geometry overlap",
);
let u = rep.layer(UNDER).unwrap();
let o = rep.layer(OVER).unwrap();
assert!(u.bbox.intersects(o.bbox), "the bboxes do intersect…");
// …while the ink itself is nowhere near: UNDER's two patches both sit well
// outside OVER's rect.
assert!(u.area > 0.0 && o.area > 0.0);
}
#[test]
fn now_stamp_is_hms_millis_shaped() {
let s = now_stamp();
// HH:MM:SS.mmm — 12 chars, two ':' and one '.'.
assert_eq!(s.len(), 12, "stamp `{s}` should be HH:MM:SS.mmm");
assert_eq!(s.matches(':').count(), 2, "stamp `{s}` needs two colons");
assert_eq!(s.matches('.').count(), 1, "stamp `{s}` needs one dot");
}
#[test]
fn seq_is_monotonic() {
let a = next_seq();
let b = next_seq();
assert!(b > a, "seq must strictly increase: {a} then {b}");
}
#[test]
fn kind_tags_are_distinct() {
let tags = [Kind::Render.tag(), Kind::State.tag(), Kind::Case.tag()];
for (i, t) in tags.iter().enumerate() {
assert!(!t.is_empty());
assert!(!tags[..i].contains(t), "duplicate tag {t}");
}
}
#[test]
fn headless_render_captures_state_and_draws() {
let mut scene = Scene::new();
let a = scene.node("a", hash_color("a"));
let b = scene.node("b", hash_color("b"));
scene.edge(a, b);
let mut t = Tiny(scene);
let r = headless_render(&mut t);
assert_eq!(r.title, "tiny");
assert_eq!(r.state["nodes"], 2);
assert!(r.drew(), "a 2-node graph should tessellate to vertices");
}
// ── Elm trait + macro + headless driver, proven end-to-end on a mock ─────────
//
// A miniature reference component in the same Model/Msg/Effect/pure-view shape
// as `facett-security`. `facett-core` cannot depend on `facett-security` (that
// would be a dependency cycle), so the trait/macro/harness are proven here on a
// self-contained mock; `facett-security`'s own test suite proves them against
// the real reference component.
use crate::Elm;
use serde::{Deserialize, Serialize};
/// All observable state (FC-1 / FC-3).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct CounterState {
count: i64,
selected: Option<String>,
}
/// Every input (FC-2).
#[derive(Clone, Debug, PartialEq)]
enum CounterMsg {
Inc,
Dec,
Add(i64),
Select(Option<String>),
}
/// No I/O (FC-8) — uninhabited on purpose.
#[derive(Debug)]
enum CounterEffect {}
struct Counter {
title: String,
state: CounterState,
}
impl Counter {
fn new() -> Self {
Self { title: "counter".into(), state: CounterState { count: 0, selected: None } }
}
}
impl Elm for Counter {
type Model = CounterState;
type Msg = CounterMsg;
type Effect = CounterEffect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &CounterState {
&self.state
}
fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
match msg {
CounterMsg::Inc => self.state.count += 1,
CounterMsg::Dec => self.state.count -= 1,
CounterMsg::Add(n) => self.state.count += n,
// Toggle: re-selecting the open id clears it (mirrors security).
CounterMsg::Select(id) => {
self.state.selected = if self.state.selected == id { None } else { id };
}
}
Vec::new()
}
fn view(&self, ui: &mut egui::Ui) -> Vec<CounterMsg> {
// Pure: paints, returns Msgs (here: none — the driver feeds inputs).
ui.label(format!("count = {}", self.state.count));
Vec::new()
}
}
// The macro under test: writes `impl Facet for Counter` from the `Elm` impl.
crate::impl_facet_via_elm!(Counter);
/// A component that publishes a RICHER `state_json` than plain `serde(state())`
/// — the case that forced helix/timeline/plandag to hand-write `impl Facet`
/// before form 3 existed. Reuses `CounterState`/`CounterMsg`/`CounterEffect`.
struct RichCounter {
state: CounterState,
}
impl Elm for RichCounter {
type Model = CounterState;
type Msg = CounterMsg;
type Effect = CounterEffect;
fn title(&self) -> &str {
"rich"
}
fn state(&self) -> &CounterState {
&self.state
}
fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
if let CounterMsg::Add(n) = msg {
self.state.count += n;
}
Vec::new()
}
fn view(&self, _ui: &mut egui::Ui) -> Vec<CounterMsg> {
Vec::new()
}
}
// Form 3: `custom_state_json` suppresses the default; we emit the model PLUS a
// derived `parity` key the default serde dump would never produce.
crate::impl_facet_via_elm!(RichCounter, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "count": self.state.count, "parity": self.state.count % 2 })
}
});
/// Form 3 lets a rich component override `state_json` via the macro (no
/// duplicate-method clash), so the deck/matrix see its derived introspection keys.
#[test]
fn form3_custom_state_json_overrides_the_default() {
use crate::Facet;
let mut c = RichCounter { state: CounterState { count: 0, selected: None } };
let _ = c.update(CounterMsg::Add(7));
let j = Facet::state_json(&c);
assert_eq!(j["count"], 7, "custom state_json is emitted");
assert_eq!(j["parity"], 1, "the DERIVED key the default serde dump omits is present");
assert_eq!(Facet::title(&c), "rich");
}
#[test]
fn harness_drives_msgs_and_snapshots_state() {
let mut c = Counter::new();
// Apply a Vec<Msg>, then snapshot state() — the FC-2 → FC-3 property, no GPU.
let snap = snapshot(
&mut c,
[CounterMsg::Inc, CounterMsg::Inc, CounterMsg::Add(5), CounterMsg::Dec],
);
assert_eq!(snap.count, 6, "1+1+5-1 = 6");
assert_eq!(c.state().count, 6, "the component holds the driven state");
// Selection toggles (re-selecting the open id clears it).
let snap = snapshot(&mut c, [CounterMsg::Select(Some("a".into())), CounterMsg::Select(Some("a".into()))]);
assert_eq!(snap.selected, None, "toggle clears the re-selected id");
}
#[test]
fn drive_returns_effects_and_snapshot_json_serializes_state() {
let mut c = Counter::new();
let effects = drive(&mut c, [CounterMsg::Inc, CounterMsg::Add(3)]);
assert!(effects.is_empty(), "FC-8: this component does no I/O");
let js = snapshot_json(&mut c, [CounterMsg::Inc]);
assert_eq!(js["count"], 5, "1+3+1 = 5, observable as JSON");
assert_eq!(js["selected"], serde_json::Value::Null);
}
#[test]
fn macro_bridges_elm_to_facet() {
// The macro-generated Facet impl: title from Elm, state_json = serde(state).
let mut c = Counter::new();
drive(&mut c, [CounterMsg::Add(9)]);
assert_eq!(Facet::title(&c), "counter");
assert_eq!(Facet::state_json(&c), state_json(&c), "macro state_json == serde(state())");
assert_eq!(Facet::state_json(&c)["count"], 9);
// And the generated `ui` (view + update loop) renders headlessly.
let r = headless_render(&mut c);
assert_eq!(r.title, "counter");
assert_eq!(r.state["count"], 9);
assert!(r.drew(), "the label tessellates to vertices");
}
#[test]
fn proptest_style_msg_sequences_keep_state_wellformed() {
// proptest-style (dependency-free, deterministic LCG): generate many Msg
// sequences from arbitrary start states, apply them, and assert invariants
// hold — no panic, serde round-trips, and the count matches an independent
// reference fold. This is contract §5.1 (property layer) over `update`.
let mut rng: u64 = 0x9E3779B97F4A7C15;
let mut next = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for _ in 0..500 {
let mut c = Counter::new();
c.state.count = (next() % 21) as i64 - 10; // arbitrary start in -10..=10
let mut expected = c.state.count;
let mut expect_sel: Option<String> = None;
c.state.selected = None;
let len = (next() % 12) as usize;
let msgs: Vec<CounterMsg> = (0..len)
.map(|_| match next() % 4 {
0 => {
expected += 1;
CounterMsg::Inc
}
1 => {
expected -= 1;
CounterMsg::Dec
}
2 => {
let n = (next() % 7) as i64 - 3;
expected += n;
CounterMsg::Add(n)
}
_ => {
let id = format!("id{}", next() % 3);
let new = Some(id.clone());
expect_sel = if expect_sel == new { None } else { new };
CounterMsg::Select(Some(id))
}
})
.collect();
let snap = snapshot(&mut c, msgs);
// Invariant 1: the driven count matches the independent fold.
assert_eq!(snap.count, expected);
// Invariant 2: selection toggle matches the reference.
assert_eq!(snap.selected, expect_sel);
// Invariant 3: state round-trips through serde (FC-3).
let json = serde_json::to_string(&snap).unwrap();
let back: CounterState = serde_json::from_str(&json).unwrap();
assert_eq!(back, snap);
}
}
// ── Generic `dyn Facet` probe ────────────────────────────────────────────
/// A pane with a LIVE `update_json` input surface (`{"push":"x"}` appends an
/// item, `{"clear":true}` empties) — the shape the discovery probe drives.
struct ListPane {
items: Vec<String>,
}
impl Facet for ListPane {
fn title(&self) -> &str {
"listpane"
}
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) {
let v: serde_json::Value = match serde_json::from_str(msg_json) {
Ok(v) => v,
Err(_) => return,
};
if let Some(s) = v.get("push").and_then(|x| x.as_str()) {
self.items.push(s.to_string());
}
if v.get("clear").and_then(|x| x.as_bool()) == Some(true) {
self.items.clear();
}
}
}
#[test]
fn probe_facet_captures_render_and_transitions() {
let mut pane = ListPane { items: vec!["seed".into()] };
let probe = probe_facet(&mut pane, &[r#"{"push":"a"}"#, r#"{"push":"b"}"#, r#"{"clear":true}"#]);
assert_eq!(probe.title, "listpane");
assert!(probe.drew(), "a label list should tessellate to vertices");
assert_eq!(probe.initial_state["items"].as_array().unwrap().len(), 1);
assert_eq!(probe.steps.len(), 3);
// Every scripted message here moves state → all three steps changed.
assert!(probe.steps.iter().all(|s| s.changed), "each push/clear mutates state");
assert!(probe.responded());
// After push a, push b, clear → empty list.
assert_eq!(probe.final_state["items"].as_array().unwrap().len(), 0);
}
#[test]
fn probe_facet_no_msgs_is_pure_render() {
let mut pane = ListPane { items: vec!["x".into(), "y".into()] };
let probe = probe_facet(&mut pane, &[]);
assert!(probe.steps.is_empty());
assert!(!probe.responded(), "no messages → nothing to respond to");
assert_eq!(probe.final_state, probe.initial_state);
assert_eq!(probe.cardinality(), 2, "two items → cardinality 2");
}
#[test]
fn probe_flags_a_dead_input_surface() {
// `Tiny` never overrides `update_json` (default no-op) — a read-only pane.
// Driving it with a message must report NO response, so the matrix can
// catch a pane whose input surface is silently dead.
let mut scene = Scene::new();
scene.node("n", hash_color("n"));
let mut t = Tiny(scene);
let probe = probe_facet(&mut t, &[r#"{"anything":1}"#]);
assert_eq!(probe.steps.len(), 1);
assert!(!probe.responded(), "a no-op update_json must not register as responsive");
}
#[test]
fn json_cardinality_finds_the_largest_collection() {
assert_eq!(json_cardinality(&serde_json::json!({"rows": [1, 2, 3, 4]})), 4);
assert_eq!(json_cardinality(&serde_json::json!([1, 2])), 2);
assert_eq!(json_cardinality(&serde_json::json!({"a": 1, "b": 2})), 2, "no arrays → key count");
assert_eq!(json_cardinality(&serde_json::json!(null)), 0);
assert_eq!(
json_cardinality(&serde_json::json!({"outer": {"inner": [1, 2, 3, 4, 5]}})),
5,
"reaches nested arrays"
);
}
}