Predicate Lab
A focused testing strip for a single predicate. The caller supplies a predicate(input) => boolean, an ordered inputs array, and an optional expected array of booleans. The component runs the predicate against every input, catches any thrown errors per row, and renders a side-by-side strip with the actual verdict next to the expected verdict — so the monotonic flip in feasibility tests, the boundary in a binary-search-on-answer-space drill, or a regression in a unit suite becomes a single glance.
Pure layout / playback primitive — it does not score, gate, or grade. Controlled (activeIndex + onActiveIndexChange) and uncontrolled (defaultActiveIndex) on the Radix pattern; built-in prev / next chrome can be hidden when the parent drives focus from outside.
Installation
npx shadcn@latest add https://craftbits.dev/r/predicate-lab.jsonUsage
import { PredicateLab } from "@craft-bits/core";
function isEven(n: number) {
return n % 2 === 0;
}
<PredicateLab
predicate={isEven}
inputs={[1, 2, 3, 4, 5]}
expected={[false, true, false, true, false]}
/>Controlled — the parent owns the active row and can drive it from a prediction gate or autoplay timer:
const [activeIndex, setActiveIndex] = useState(0);
<PredicateLab
predicate={isEven}
inputs={[1, 2, 3, 4, 5]}
expected={[false, true, false, true, false]}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
/>Subscribe to the full results array — useful for the caller to gate "continue" on full coverage, score the run, or persist a diff:
<PredicateLab
predicate={isEven}
inputs={[1, 2, 3, 4, 5]}
expected={[false, true, false, true, false]}
onResults={(results) => {
const allMatch = results.every((r) => r.matches);
setCanContinue(allMatch);
}}
/>Generic over the input type — pass a formatInput to control how each input renders:
type Case = { name: string; arr: number[]; target: number };
<PredicateLab
predicate={(c) => c.arr.includes(c.target)}
inputs={CASES}
expected={CASES.map((c) => c.target % 2 === 0)}
formatInput={(c) => c.name + ": includes " + c.target + "?"}
/>Understanding the component
- Pure predicate replay. Every render runs the predicate once per row inside a
useMemokeyed on(predicate, inputs, expected). The same triple always produces the same verdict strip — no internal scoring, no internal timing, no internal sound. - Caught predicate errors. A predicate that throws on a row's input is caught and surfaced as an
errorverdict on that row — the strip never crashes, and the error message is visible in the active-row banner. - Expected vs actual. Each row renders the actual verdict (T / F / err) next to the expected verdict in a small two-cell badge — matches share the tone colour, mismatches and errors flip to the error tone so the regression reads at a glance.
- Controlled and uncontrolled
activeIndex. PairactiveIndexwithonActiveIndexChangefor Radix-style control, or passdefaultActiveIndexand let the component own its focus. Built-in prev / next chrome can be hidden when the parent drives focus from outside. - Active-row banner. The currently focused row gets a separate accent-tinted card above the strip showing the row index, the formatted input, an optional
description, and the predicate's thrown error message if any. Keying the banner onactiveIndexcross-fades softly between rows. - Row anatomy. Every row is a real
<button>carryingdata-row-index,data-status(match/mismatch/error/unverified), anddata-active— assistive tooling and consumer styles hook into these without depending on classnames. onResultssubscription. The component firesonResultsonce per change in the computed results array — callers gate "continue" on full coverage, score the run, or persist a diff without re-running the predicate themselves.- Hit targets. Every row and the prev / next buttons all clear the 44 by 44 px WCAG 2.5.8 floor.
- Reduced motion. Row enter, active-row cross-fade, and prev / next press scale all collapse to instant under
prefers-reduced-motion: reduce. The strip still advances; only the motion drops.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
predicate | (input: T) => boolean | required | Predicate to test. Thrown errors are caught and rendered as an error verdict. |
inputs | readonly T[] | required | Ordered inputs to test the predicate against. |
expected | readonly boolean[] | — | Expected verdict per input. Missing indices render as unverified (neutral). |
formatInput | (input: T, index: number) => ReactNode | String(input) | Custom renderer for each input label. |
title | ReactNode | — | Optional title rendered above the strip. |
description | ReactNode | — | Optional caption rendered inside the active-row banner. |
activeIndex | number | — | Controlled active row. Pair with onActiveIndexChange. |
defaultActiveIndex | number | 0 | Uncontrolled initial active row. |
onActiveIndexChange | (next: number) => void | — | Fires when the active row changes. |
onResults | (results: readonly PredicateLabResult<T>[]) => void | — | Fires with the full results array whenever the predicate, inputs, or expected change. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Semantic tone for the active row, match dots, and focus ring. |
prevLabel | string | "Prev" | Label for the previous-row button. |
nextLabel | string | "Next" | Label for the next-row button. |
hideControls | boolean | false | Hide the built-in prev / next chrome. |
transition | Transition | SPRINGS.smooth | Override transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- A visually-hidden
aria-live="polite"region narrates the row count, match tally, and any predicate errors as the inputs change. - The root carries
aria-roledescription="predicate test lab"plusdata-state,data-tone, anddata-statusattributes so assistive tooling and consumer styles can hook into the lab's overall verdict. - Each row is a real
<button>with an explicitaria-labelsummarising actual, expected, and match status, plusaria-current="true"on the active row. - Prev / next buttons render at 44 by 44 px minimum, disable themselves at the ends, and respond to keyboard
EnterandSpace. Arrow-left / arrow-right keys on the root advance the active row. - Verdict glyphs (T / F / err) are paired with tone tinting, an inset border, and the explicit aria-label text — verdict is never communicated by colour alone.
- Motion respects
prefers-reduced-motion: reduce— row enter, active-row cross-fade, and prev / next press scale collapse to instant. The strip still advances; only the motion drops.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/decision/PredicateLab.tsx). The source was a 2900-line four-act game (Probe, Discover, Build, Search) that drove a shipping-capacity binary-search puzzle through manual probing, a number-line discovery of the F-F-F-T-T-T monotonic pattern, a drag-and-drop predicate assembly with distractor tiles, and a spatial binary search with prediction gates — all wired to a reducer with twenty-plus phases, per-step audio cues, a hardcoded greedy-packing simulator, and a built-in weighted scoring formula. The library extract drops the game, the reducer, the scoring, the audio, the simulator, the morph transitions, and the tile drag-and-drop. What remains is the testing spine: a predicate, a range of inputs, an optional expected array, and a side-by-side actual / expected strip. Consumers compose any acts, narration, prediction gates, or sound on top.