Stimuli
A stimulus drives the software: it arranges the state an example starts from and acts on it. It is one of Varar’s two kinds of step functions. The other is sensors, the read-only observations.
The names are a hardware analogy: you put a stimulus into the system, and you read its response with sensors.
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| { 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))));
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) { 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 stimulus covers both the context (arrange) and the action (act) — Varar never matches keywords; see Test anatomy for why the concepts and the mechanism are decoupled.
Evolving state
Section titled “Evolving state”A stimulus receives the current state as its first argument, followed by the
values the expression captured. It evolves state by returning
the complete next state — full replacement, in every port. The value you
return is the new state, so a return that leaves a field out drops it rather
than preserving it. Spread the current state to keep the rest:
(state) => ({ ...state, count: 1 }).
Returning nothing leaves state unchanged. Convention is new value out, always.
const { stimulus } = steps(() => ({ greeting: '', count: 0 }))
stimulus('I greet {string}', (state, name) => ({ ...state, greeting: `Hello, ${name}!` }))stimulus('I add {int}', (state, n) => ({ ...state, count: state.count + n }))record Ctx(String greeting, int count) implements State {}
s.state(() -> new Ctx("", 0));
s.stimulus("I greet {string}", (Ctx ctx, String name) -> new Ctx("Hello, " + name + "!", ctx.count()));s.stimulus("I add {int}", (Ctx ctx, Integer n) -> new Ctx(ctx.greeting(), ctx.count() + n));data class Ctx(val greeting: String = "", val count: Int = 0)
val steps = steps(::Ctx) { stimulus("I greet {string}") { name: String -> copy(greeting = "Hello, $name!") } stimulus("I add {int}") { n: Int -> copy(count = count + n) }}param, stimulus, sensor = steps(lambda: {"greeting": "", "count": 0})
@stimulus("I greet {string}")def _(state, name): return {**state, "greeting": f"Hello, {name}!"}
@stimulus("I add {int}")def _(state, n): return {**state, "count": state["count"] + n}steps(-> { { greeting: '', count: 0 } }) do stimulus('I greet {string}') { |state, name| state.merge(greeting: "Hello, #{name}!") } stimulus('I add {int}') { |state, n| state.merge(count: state[:count] + n) }ends.stimulus( "I greet {string}", file!(), line!() as usize, Handler::sync1(|state, name| { // Full replacement: clone state, then set just the changed key. let mut m = smap(&state); m.insert("greeting".into(), Value::from(format!("Hello, {}!", as_str(&name)))); Ok(Some(Value::Map(m))) }),);s.stimulus( "I add {int}", file!(), line!() as usize, Handler::sync1(|state, n| { let mut m = smap(&state); let count = m.get("count").map(as_int).unwrap_or(0) + as_int(&n); m.insert("count".into(), Value::Int(count)); Ok(Some(Value::Map(m))) }),);s.State(() => VMap(("greeting", Value.Of("")), ("count", Value.Of(0))));
// Full replacement: read the current state map, set just the changed key.s.Stimulus("I greet {string}", (state, name) => new VMap(SMap(state).SetItem("greeting", Value.Of($"Hello, {name.AsString()}!"))));s.Stimulus("I add {int}", (state, n) => new VMap(SMap(state).SetItem("count", Value.Of(state["count"].AsInt() + n.AsInt()))));// Full replacement: read the current state map, set just the changed key.s.Stimulus("I greet {string}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { m := state.CloneMap() m["greeting"] = varar.StrValue("Hello, " + args[0].MustString() + "!") return varar.Ptr(varar.MapValue(m)), nil})s.Stimulus("I add {int}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { m := state.CloneMap() m["count"] = varar.IntValue(m["count"].MustInt() + args[0].MustInt()) return varar.Ptr(varar.MapValue(m)), nil})- Returning nothing leaves state unchanged — right for a stimulus whose side effects live entirely in the system under test. (In Java and Kotlin, return the received state unchanged instead.)
- In TypeScript, Python and Ruby, returning anything that isn’t an object (or
nothing) is a
ReturnShapeError. A stimulus never returns values for comparison — that’s the sensor’s job. - Mutating
stateis your call, not Varar’s. Varar hands you the value your factory (or your last stimulus) produced, untouched — it is not frozen, copied or retyped. Declare your statereadonlyif you want the compiler to stop you; in Java and Kotlin the record/data class already does. Evolution is meant to happen by returning, and mutating instead will confuse the next reader, but nothing at runtime forbids it — a state that holds a DB client or a page object needs those objects live. - There is no merge. The returned object replaces state wholesale, so a field the return omits is gone. This is the same model the typed ports get from their record/data-class state, where a partial return cannot even be expressed.
State is per step file, per example
Section titled “State is per step file, per example”steps declares the state its step file’s examples start from. Every
example gets a fresh state from the factory, so examples never leak into each
other; steps defined in different step files never see each other’s state.
No state? Omit the factory
Section titled “No state? Omit the factory”The factory is optional. A step file whose steps are pure — nothing to
arrange, nothing to evolve — calls steps bare; handlers receive an
empty state they can ignore:
const { stimulus, sensor } = steps()
sensor('the square of {int} is {int}', (_state, n) => [n, n * n])s.sensor("the square of {int} is {int}", (State.Empty state, Integer n, Integer expected) -> List.of(n, n * n));val steps = steps { sensor("the square of {int} is {int}") { n: Int -> listOf(n, n * n) }}param, stimulus, sensor = steps()
@sensor("the square of {int} is {int}")def _(state, n, expected): return [n, n * n]steps do sensor('the square of {int} is {int}') { |_state, n| [n, n * n] }ends.sensor( "the square of {int} is {int}", file!(), line!() as usize, Handler::sync2(|_state, n, _square| { let n = as_int(&n); Ok(Some(Value::List(vec![Value::Int(n), Value::Int(n * n)]))) }),);s.Sensor("the square of {int} is {int}", (state, n, square) => Value.List([Value.Of(n.AsInt()), Value.Of(n.AsInt() * n.AsInt())]));s.Sensor("the square of {int} is {int}", func(state varar.Value, args []varar.Value) (*varar.Value, error) { n := args[0].MustInt() return varar.Ptr(varar.ListValue(varar.IntValue(n), varar.IntValue(n*n))), nil})Tables and doc strings
Section titled “Tables and doc strings”A trailing data table or fenced code block arrives as the last handler argument, after the captured parameters — a table as a list of rows (header row first), a doc string as its exact text:
stimulus('these books exist:', (state, rows: ReadonlyArray<ReadonlyArray<string>>) => ({ ...state, books: rows.slice(1).map(([title, author]) => ({ title, author })),}))s.stimulus("these books exist:", (Ctx ctx, List<List<String>> rows) -> new Ctx( rows.subList(1, rows.size()).stream() .map(row -> new Book(row.get(0), row.get(1))) .toList()));stimulus("these books exist:") { rows: List<List<String>> -> copy(books = rows.drop(1).map { (title, author) -> Book(title, author) })}@stimulus("these books exist:")def _(state, rows): return {**state, "books": [{"title": title, "author": author} for title, author in rows[1:]]}stimulus('these books exist:') do |_state, rows| { books: rows.drop(1).map { |title, author| { title: title, author: author } } }ends.stimulus( "these books exist:", file!(), line!() as usize, Handler::sync1(|state, table| { let Value::List(rows) = table else { panic!("expected a table") }; let books: Vec<Value> = rows .iter() .skip(1) .map(|row| { let Value::List(cells) = row else { panic!("expected a row") }; vmap(vec![ ("title", cells[0].clone()), ("author", cells[1].clone()), ]) }) .collect(); let mut m = smap(&state); m.insert("books".into(), Value::List(books)); Ok(Some(Value::Map(m))) }),);s.Stimulus("these books exist:", (state, table) =>{ var books = ((VList)table).Items.Skip(1).Select(row => { var cells = ((VList)row).Items; return VMap(("title", cells[0]), ("author", cells[1])); }); return new VMap(SMap(state).SetItem("books", Value.List(books)));});s.Stimulus("these books exist:", func(state varar.Value, args []varar.Value) (*varar.Value, error) { rows, _ := args[0].AsList() books := []varar.Value{} for _, row := range rows[1:] { // skip the header row cells, _ := row.AsList() books = append(books, varar.MapValue(map[string]varar.Value{ "title": cells[0], "author": cells[1], })) } m := state.CloneMap() m["books"] = varar.ListOf(books) return varar.Ptr(varar.MapValue(m)), nil})A stimulus consumes these as input. To check a table or doc string against what the software produced, use a sensor — see Check tables and doc strings.
A stimulus handler may be async (async function in TypeScript, async def
in Python, suspend in Kotlin); the runtime awaits it before the next step
runs.
Errors
Section titled “Errors”| Error | Raised when |
|---|---|
ReturnShapeError |
the handler returns something that isn’t a complete state object or nothing |
Any exception the handler itself throws fails the example, anchored to the step’s line in the Markdown.