Sensors
A sensor is a read-only observation. It reads state, returns what the software actually produced, and Varar compares that against what the Markdown claims. A sensor never changes state, and you never write an assertion — the document is the assertion. It is one of Varar’s two step kinds — the other is the stimulus, which drives the software.
The word comes from Birgitta Böckeler’s Harness Engineering: a test harness surrounds the system with instruments the way a hardware rig does — you drive the system with stimuli and read its behaviour back through sensors, rather than reaching inside it.
const { stimulus, sensor } = steps(() => ({ total: 0 }))
stimulus('I add {int}', (state, n) => ({ ...state, total: state.total + n }))sensor('the total is {int}', (state) => state.total)// inside register(Steps<Ctx> s)record Ctx(int total) implements State {}
s.state(() -> new Ctx(0));
s.stimulus("I add {int}", (Ctx ctx, Integer n) -> new Ctx(ctx.total() + n));s.sensor("the total is {int}", (Ctx ctx, Integer expected) -> ctx.total());data class Ctx(val total: Int = 0)
val steps = steps(::Ctx) { stimulus("I add {int}") { n: Int -> copy(total = total + n) } sensor("the total is {int}") { _: Int -> total }}param, stimulus, sensor = steps(lambda: {"total": 0})
@stimulus("I add {int}")def _(state, n): return {**state, "total": state["total"] + n}
@sensor("the total is {int}")def _(state, expected): return state["total"]steps(-> { { total: 0 } }) do stimulus('I add {int}') { |state, n| state.merge(total: state[:total] + n) } sensor('the total is {int}') { |state, _expected| state[:total] }ends.stimulus( "I add {int}", file!(), line!() as usize, Handler::sync1(|state, n| { // FULL-REPLACEMENT state: return the whole next state. let mut m = smap(&state); let total = m.get("total").map(as_int).unwrap_or(0) + as_int(&n); m.insert("total".into(), Value::Int(total)); Ok(Some(Value::Map(m))) }),);s.sensor( "the total is {int}", file!(), line!() as usize, Handler::sync1(|state, _expected| Ok(smap(&state).get("total").cloned())),);s.State(() => VMap(("total", Value.Of(0))));
// FULL-REPLACEMENT state: return the whole next state.s.Stimulus("I add {int}", (state, n) => new VMap(SMap(state).SetItem("total", Value.Of(state["total"].AsInt() + n.AsInt()))));s.Sensor("the total is {int}", (state, expected) => state["total"]);s.Stimulus("I add {int}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { // FULL-REPLACEMENT state: return the whole next state. m := state.CloneMap() m["total"] = varar.IntValue(m["total"].MustInt() + args[0].MustInt()) return varar.Ptr(varar.MapValue(m)), nil})s.Sensor("the total is {int}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { return varar.Ptr(state.CloneMap()["total"]), nil})In your prose the sensor is the outcome you expect; see Test anatomy for how context, action and outcome map onto the two step kinds.
This page is the reference for what a sensor may return and how each return value is compared. The rules are identical in every port (TypeScript, Java, Kotlin, Python, Ruby).
Cells and slots
Section titled “Cells and slots”A cell is one value Varar compares — the atomic unit of a check. A cell can sit in a table, in a header-bound row, or in a paragraph, where an expression parameter captures it. (See Varar overview for the spreadsheet analogy this comes from.)
A sensor’s slots are the positions in its return, in order. A slot holds either a single cell or a whole table of cells:
- each parameter captured by the expression (
{int},{word},{string}, custom types), left to right — one cell each; - the trailing data table or doc string, if the step has one (always last, at most one) — a table is a grid of cells; a doc string is one cell compared whole.
So a two-slot sensor may well compare a dozen cells: one for its {int}, and
one per cell of the table that follows.
The return value maps onto the slots by count:
| Slots | Return | Example |
|---|---|---|
| 0 | nothing (undefined / None / null) |
() => { assertSomething() } |
| 1 | the slot’s value, bare | (state) => state.total |
| 2+ | an array/list, one element per slot, in order | (state) => [state.n, state.s] |
With one or more slots the return is required. Returning nothing is a
ReturnShapeError, not a pass — see Returning nothing.
Zero slots — nothing to compare
Section titled “Zero slots — nothing to compare”A sensor with no parameters, no table and no doc string has nothing to compare a return value against. Throw to fail, return nothing to pass:
sensor('the alarm fired', (state) => { if (!state.alarm) throw new Error('no alarm')})s.sensor("the alarm fired", (Ctx ctx) -> { if (!ctx.alarm()) throw new AssertionError("no alarm"); return null;});sensor("the alarm fired") { if (!alarm) throw AssertionError("no alarm") null}@sensor("the alarm fired")def _(state): if not state["alarm"]: raise AssertionError("no alarm")sensor('the alarm fired') { |state| raise 'no alarm' unless state[:alarm] }s.sensor( "the alarm fired", file!(), line!() as usize, Handler::sync0(|state| { if !matches!(smap(&state).get("alarm"), Some(Value::Bool(true))) { panic!("no alarm"); } Ok(None) }),);s.Sensor("the alarm fired", state =>{ if (SMap(state)["alarm"] is not VBool { Bool: true }) throw new Exception("no alarm"); return null;});s.Sensor("the alarm fired", func(state varar.Value, args []varar.Value) (*varar.Value, error) { if b, _ := state.CloneMap()["alarm"].AsBool(); !b { return nil, errors.New("no alarm") } return nil, nil})Returning any other value is a ReturnShapeError. This is deliberate: a
returned value here would silently assert nothing, and the author almost
certainly believed it was being checked.
One slot — return the value bare
Section titled “One slot — return the value bare”sensor('the total is {int}', (state) => state.total)s.sensor("the total is {int}", (Ctx ctx, Integer expected) -> ctx.total());sensor("the total is {int}") { _: Int -> total }@sensor("the total is {int}")def _(state, expected): return state["total"]sensor('the total is {int}') { |state, _expected| state[:total] }s.sensor( "the total is {int}", file!(), line!() as usize, Handler::sync1(|state, _expected| Ok(smap(&state).get("total").cloned())),);s.Sensor("the total is {int}", (state, expected) => state["total"]);s.Sensor("the total is {int}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { return varar.Ptr(state.CloneMap()["total"]), nil})The return is the slot’s value. It is never interpreted as a positional
array — so wrapping it (return [state.total]) fails the comparison, because
[42] is not 42.
This also resolves what would otherwise be an ambiguity with custom parameter
types whose parse produces an array:
const { sensor } = steps(() => ({ dice: [5, 6] })).param( 'numbers', /\d+(?:, \d+)*/, (raw) => raw.split(', ').map(Number),)
// "The dice show 5, 6" — {numbers} transforms to [5, 6]sensor('The dice show {numbers}', (state) => state.dice) // deep-equal [5, 6] vs [5, 6] ✓s.state(() -> new Ctx(List.of(5, 6)));s.param( "numbers", Pattern.compile("\\d+(?:, \\d+)*"), groups -> Arrays.stream(groups[0].split(", ")).map(Integer::parseInt).toList());
// "The dice show 5, 6" — {numbers} transforms to [5, 6]s.sensor("The dice show {numbers}", (Ctx ctx, List<Integer> dice) -> ctx.dice());data class DiceCtx(val dice: List<Int> = listOf(5, 6))
val diceSteps = steps(::DiceCtx) { param("numbers", Regex("""\d+(?:, \d+)*""")) { groups -> groups[0].split(", ").map(String::toInt) }
// "The dice show 5, 6" — {numbers} transforms to [5, 6] sensor("The dice show {numbers}") { _: List<Int> -> dice }}param, stimulus, sensor = steps(lambda: {"dice": [5, 6]})param("numbers", r"\d+(?:, \d+)*", parse=lambda raw: [int(n) for n in raw.split(", ")])
# "The dice show 5, 6" — {numbers} transforms to [5, 6]@sensor("The dice show {numbers}")def _(state, dice): return state["dice"]steps(-> { { dice: [5, 6] } }) do param('numbers', '\d+(?:, \d+)*', parse: ->(raw) { raw.split(', ').map(&:to_i) })
# "The dice show 5, 6" — {numbers} transforms to [5, 6] sensor('The dice show {numbers}') { |state, _dice| state[:dice] }endlet parse: ParseFn = Rc::new(|g: &[&str]| { Value::List(g[0].split(", ").map(|n| Value::Int(n.parse().unwrap())).collect())});s.param("numbers", r"\d+(?:, \d+)*", parse);
// "The dice show 5, 6" — {numbers} transforms to [5, 6]s.sensor( "The dice show {numbers}", file!(), line!() as usize, Handler::sync1(|state, _dice| Ok(smap(&state).get("dice").cloned())),);s.State(() => VMap(("dice", Value.List([Value.Of(5), Value.Of(6)]))));s.Param("numbers", @"\d+(?:, \d+)*", groups => Value.List(groups[0]!.Split(", ").Select(n => Value.Of(long.Parse(n)))));
// "The dice show 5, 6" — {numbers} transforms to [5, 6]s.Sensor("The dice show {numbers}", (state, dice) => state["dice"]);s.Param("numbers", `\d+(?:, \d+)*`, func(g []string) varar.Value { nums := []varar.Value{} for _, n := range strings.Split(g[0], ", ") { i, _ := strconv.ParseInt(n, 10, 64) nums = append(nums, varar.IntValue(i)) } return varar.ListOf(nums)}, nil)
// "The dice show 5, 6" — {numbers} transforms to [5, 6]s.Sensor("The dice show {numbers}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { return varar.Ptr(state.CloneMap()["dice"]), nil})Because a single-slot return is always the bare value, [5, 6] here is
unambiguously the value, deep-compared against the transformed parameter —
never mistaken for a two-slot positional array.
Two or more slots — return a positional array
Section titled “Two or more slots — return a positional array”sensor('I should have {int} cukes in my {word} belly', (state) => [ state.count, state.bellyName,])s.sensor("I should have {int} cukes in my {word} belly", (Ctx ctx, Integer count, String belly) -> List.of(ctx.count(), ctx.bellyName()));sensor("I should have {int} cukes in my {word} belly") { _: Int, _: String -> listOf(count, bellyName)}@sensor("I should have {int} cukes in my {word} belly")def _(state, count, belly): return [state["count"], state["belly_name"]]sensor('I should have {int} cukes in my {word} belly') do |state, _count, _belly| [state[:count], state[:belly_name]]ends.sensor( "I should have {int} cukes in my {word} belly", file!(), line!() as usize, Handler::sync2(|state, _count, _belly| { let m = smap(&state); Ok(Some(Value::List(vec![ m["count"].clone(), m["belly_name"].clone(), ]))) }),);s.Sensor("I should have {int} cukes in my {word} belly", (state, count, belly) => Value.List([state["count"], state["bellyName"]]));s.Sensor("I should have {int} cukes in my {word} belly", func(state varar.Value, args []varar.Value) (*varar.Value, error) { m := state.CloneMap() return varar.Ptr(varar.ListValue(m["count"], m["bellyName"])), nil})The array must have exactly one element per slot; a different length or a
non-array return is a ReturnShapeError. Each element is compared against its
slot. When the step also has a trailing table or doc string, it occupies the
last element:
sensor('Greet {word}:', (state, name, _body: string) => [name, `Hello, ${name}!\n`])s.sensor("Greet {word}:", (Ctx ctx, String name, String doc) -> List.of(name, "Hello, " + name + "!\n"));sensor("Greet {word}:") { name: String, _: String -> listOf(name, "Hello, $name!\n")}@sensor("Greet {word}:")def _(state, name, doc): return [name, f"Hello, {name}!\n"]sensor('Greet {word}:') { |_state, name, _doc| [name, "Hello, #{name}!\n"] }s.sensor( "Greet {word}:", file!(), line!() as usize, Handler::sync2(|_state, name, _doc| { let name = as_str(&name); Ok(Some(Value::List(vec![ Value::from(name.clone()), Value::from(format!("Hello, {name}!\n")), ]))) }),);s.Sensor("Greet {word}:", (state, name, doc) =>{ var n = name.AsString(); return Value.List([Value.Of(n), Value.Of($"Hello, {n}!\n")]);});s.Sensor("Greet {word}:", func(state varar.Value, args []varar.Value) (*varar.Value, error) { name := args[0].MustString() return varar.Ptr(varar.ListValue( varar.StrValue(name), varar.StrValue("Hello, "+name+"!\n"), )), nil})What each slot kind compares
Section titled “What each slot kind compares”| Slot holds | Cells compared | How |
|---|---|---|
| an inline parameter | one | deep equality against the transformed captured value, so {int} compares numbers and a custom type compares whatever its parse function produced |
| a whole table | one per table cell | exact string comparison, cell by cell — see Check tables and doc strings |
| a doc string | one | exact string equality of the whole block, trailing newline included; expected/actual are quoted so a whitespace-only difference stays visible |
Every mismatch is a CellMismatchError carrying one diff per failing cell,
anchored to that cell’s span in the Markdown.
Header-bound table rows
Section titled “Header-bound table rows”A sensor bound to a sentence that names a table’s columns runs once per row and returns a row object keyed by the header — cells, not slots:
sensor('a decimal and a roman number', (_state, row: { decimal: string; roman: string }) => { return { decimal: row.decimal, roman: toRoman(Number(row.decimal)) }})s.sensor("a decimal and a roman number", (Ctx ctx, Map<String, String> row) -> Map.of( "decimal", row.get("decimal"), "roman", RomanNumerals.toRoman(Integer.parseInt(row.get("decimal")))));sensor("a decimal and a roman number") { row: Map<String, String> -> mapOf("decimal" to row.getValue("decimal"), "roman" to toRoman(row.getValue("decimal").toInt()))}@sensor("a decimal and a roman number")def _(state, row): return {"decimal": row["decimal"], "roman": to_roman(int(row["decimal"]))}sensor('a decimal and a roman number') do |_state, row| { 'decimal' => row['decimal'], 'roman' => RomanNumerals.to_roman(row['decimal'].to_i) }ends.sensor( "a decimal and a roman number", file!(), line!() as usize, Handler::sync1(|_state, row| { let m = smap(&row); let decimal = as_str(&m["decimal"]); let roman = to_roman(decimal.parse().expect("decimal")); Ok(Some(vmap(vec![ ("decimal", Value::from(decimal)), ("roman", Value::from(roman)), ]))) }),);s.Sensor("a decimal and a roman number", (state, row) =>{ var decimal = SMap(row)["decimal"].AsString(); var roman = ToRoman(int.Parse(decimal)); return VMap(("decimal", Value.Of(decimal)), ("roman", Value.Of(roman)));});s.Sensor("a decimal and a roman number", func(state varar.Value, args []varar.Value) (*varar.Value, error) { row := args[0].CloneMap() decimal := row["decimal"].MustString() n, _ := strconv.Atoi(decimal) return varar.Ptr(varar.MapValue(map[string]varar.Value{ "decimal": varar.StrValue(decimal), "roman": varar.StrValue(toRoman(n)), })), nil})Each returned column is compared cell by cell against that row. This is the one sensor shape that bypasses the slot contract — a row is not a slot, it is a set of cells, one per bound column.
Returning nothing
Section titled “Returning nothing”Returning undefined (TypeScript), None (Python), null (Java, Kotlin) or
nil (Ruby) is allowed only for a zero-slot sensor, where there is nothing
to compare against: throw to fail, return nothing to pass.
A sensor with slots must answer them. Returning nothing raises a
ReturnShapeError:
a sensor with 1 slot(s) must return one value per slot, got nothingThat closes a silent-coverage hole. The document makes a claim, so something has
to check it — and a mistake as small as a typo’d property access returns
undefined, which would otherwise skip the comparison and leave the example
green while verifying nothing. The same applies to a
header-bound row step: it must return its row object.
Throwing is always allowed, at any slot count — a plain throw or your test
framework’s own assertions fail the step just as a mismatching return does. What
is no longer allowed is answering nothing at all when the document asked a
question. If a capture is awkward to echo back (a greedy {word} swallowing a
trailing period, say), match the punctuation in the expression itself —
The result is {word}. — so the cell holds just the value you want to compare.
Errors
Section titled “Errors”| Error | Raised when |
|---|---|
CellMismatchError |
one or more cells differ — an inline capture, a table cell, a header-bound row’s cell, or a doc string; carries one span-anchored diff per failing cell |
ReturnShapeError |
the return doesn’t fit the slots: a value from a zero-slot sensor, nothing from a sensor with slots (or from a header-bound row step), a non-array or wrong-length array for 2+ slots, or a malformed table |
Because every diff is anchored to a source span, editors highlight exactly the failing characters in the Markdown and show the actual value in place.
Why an array at all?
Section titled “Why an array at all?”A sensor with several slots is answering several independent questions the document asked, and Varar needs to know which returned value belongs to which. Positional mapping — same order as the sentence — does that without naming ceremony. With one slot the position is unambiguous, so the wrapper would be pure noise; that’s why one slot takes the bare value.