Timers let authored behavior depend on time. Tests should not depend on sleep, scheduler load, or a wall clock to prove that behavior. Simulator turns every armed Timer into deterministic data and gives the test two different controls: move the clock, or fire one exact occurrence.
Choose clock or citizen
advance · advanceToProcess every deadline due inside one deterministic time window.
Best for schedule order, cascades, and elapsed-time behavior.fireTimerDeliver one exact currently armed Timer occurrence immediately.
Best for occurrence identity, repeated placements, and focused transitions.Both controls fold the same Timer completion semantics. They differ in what the test is claiming: schedule behavior across a window, or the reaction to one named citizen.
Control time and address the exact timer
Move time deliberately—or fire one citizen exactly.
Mounted timers can be exercised without waiting on a wall clock. Virtual advance preserves schedule order, while an exact Timer citizen lets a test target one repeated occurrence without ambiguity.
1 · Bind authored timers to virtual duration
The clock starts at a controlled instant.
Both regions arm named occurrences of the same Deadline definition. Simulator.make binds that definition to five virtual seconds; no wall clock, sleep, or background timer has started.
import { Simulator, State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Effect } from "effect";
const Deadline = Timer.relative("Deadline");
const LeftDeadline = Deadline.as("LeftDeadline");
const RightDeadline = Deadline.as("RightDeadline");
export class ServiceWindow extends State.Parallel<ServiceWindow>()("ServiceWindow") {
static states = States.make(() => [Left, Right]);
}
class Left extends State.Compound<Left>()("Left") {
static states = States.make(() => [LeftWaiting, LeftExpired]);
}
class LeftWaiting extends State.Atomic<LeftWaiting>()("LeftWaiting") {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(LeftDeadline),
on(LeftDeadline.Done, LeftExpired),
]);
}
class LeftExpired extends State.Atomic<LeftExpired>()("LeftExpired") {}
class Right extends State.Compound<Right>()("Right") {
static states = States.make(() => [RightWaiting, RightExpired]);
}
class RightWaiting extends State.Atomic<RightWaiting>()("RightWaiting") {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(RightDeadline),
on(RightDeadline.Done, RightExpired),
]);
}
class RightExpired extends State.Atomic<RightExpired>()("RightExpired") {}
export const ServiceWindowChart = Statechart.make(ServiceWindow);
export const initial = Effect.runSync(
Simulator.make(ServiceWindowChart, {
timers: { Deadline: "5 seconds" },
}),
);
2 · Move virtual time once
Advance fires every deadline that becomes due.
Advancing five seconds fires both armed deadlines in deterministic schedule order and returns one next Simulator. A large advance also processes timers armed inside its remaining window.
import { Simulator, State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Effect, Result } from "effect";
const Deadline = Timer.relative("Deadline");
const LeftDeadline = Deadline.as("LeftDeadline");
const RightDeadline = Deadline.as("RightDeadline");
export class ServiceWindow extends State.Parallel<ServiceWindow>()("ServiceWindow") {
static states = States.make(() => [Left, Right]);
}
class Left extends State.Compound<Left>()("Left") {
static states = States.make(() => [LeftWaiting, LeftExpired]);
}
class LeftWaiting extends State.Atomic<LeftWaiting>()("LeftWaiting") {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(LeftDeadline),
on(LeftDeadline.Done, LeftExpired),
]);
}
class LeftExpired extends State.Atomic<LeftExpired>()("LeftExpired") {}
class Right extends State.Compound<Right>()("Right") {
static states = States.make(() => [RightWaiting, RightExpired]);
}
class RightWaiting extends State.Atomic<RightWaiting>()("RightWaiting") {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(RightDeadline),
on(RightDeadline.Done, RightExpired),
]);
}
class RightExpired extends State.Atomic<RightExpired>()("RightExpired") {}
export const ServiceWindowChart = Statechart.make(ServiceWindow);
export const initial = Effect.runSync(
Simulator.make(ServiceWindowChart, {
timers: { Deadline: "5 seconds" },
}),
);
export const advanced = Result.getOrThrow(initial.advance("5 seconds"));
3 · Address one exact mounted occurrence
A TimerCitizen selects the right deadline only.
Both timers share the Deadline definition, so the injection names the mounted Right.RightWaiting.RightDeadline occurrence. Only the Right region expires; the Left deadline remains armed and unspent.
import { Simulator, State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Effect, Result } from "effect";
const Deadline = Timer.relative("Deadline");
const LeftDeadline = Deadline.as("LeftDeadline");
const RightDeadline = Deadline.as("RightDeadline");
export class ServiceWindow extends State.Parallel<ServiceWindow>()("ServiceWindow") {
static states = States.make(() => [Left, Right]);
}
class Left extends State.Compound<Left>()("Left") {
static states = States.make(() => [LeftWaiting, LeftExpired]);
}
class LeftWaiting extends State.Atomic<LeftWaiting>()("LeftWaiting") {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(LeftDeadline),
on(LeftDeadline.Done, LeftExpired),
]);
}
class LeftExpired extends State.Atomic<LeftExpired>()("LeftExpired") {}
class Right extends State.Compound<Right>()("Right") {
static states = States.make(() => [RightWaiting, RightExpired]);
}
class RightWaiting extends State.Atomic<RightWaiting>()("RightWaiting") {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(RightDeadline),
on(RightDeadline.Done, RightExpired),
]);
}
class RightExpired extends State.Atomic<RightExpired>()("RightExpired") {}
export const ServiceWindowChart = Statechart.make(ServiceWindow);
export const initial = Effect.runSync(
Simulator.make(ServiceWindowChart, {
timers: { Deadline: "5 seconds" },
}),
);
export const rightExpired = Result.getOrThrow(
initial.fireTimer(Simulator.TimerCitizen.make("Right.RightWaiting.RightDeadline")),
);
Bind duration at the simulation boundary
Both parallel regions arm distinct .as occurrences of the same relative Deadline definition.
Simulator.make therefore requires one virtual duration binding for Deadline:
Simulator.make(ServiceWindowChart, {
timers: { Deadline: "5 seconds" },
});
The chart owns when the Timer is armed, cancelled, restarted, and handled. The Simulator boundary owns how its relative duration becomes a virtual deadline. No wall-clock timer, sleep, or background fiber is created.
The default virtual start is the Unix epoch. Supply startAt when the test needs an explicit UTC
origin. now remains a value on each immutable Simulator world.
Advance the whole due window
initial.advance("5 seconds") moves now forward and fires every deadline due by the target.
advanceTo(target) states the same operation with an absolute virtual instant. Equal deadlines use
authored arming order as their deterministic tie-breaker.
The operation keeps processing the window. If a fired Timer transition arms another Timer whose deadline also falls at or before the target, that new deadline fires in the same call. This proves the whole scheduled consequence of elapsed time rather than only the first item in a queue.
An advance with nothing due still returns a new Simulator with the requested now and appends an
advance / Noop trace entry. Time moved; chart geometry did not. Tests can assert both facts.
Keep cascades finite and visible
Virtual determinism does not make an infinite Timer cascade valid. One advance may fire at most 256
due deadlines. Exceeding that ceiling returns Simulator.CascadeLimitExceeded with chart, limit,
and operation identity instead of hanging the test process.
The ceiling is evidence that the requested window cannot stabilize under the authored Timer behavior. Narrowing the advance or increasing wall-clock test time would not repair that model.
Address one mounted occurrence
Simulator.TimerCitizen.make("Right.RightWaiting.RightDeadline") names the mounted Timer
occurrence, not only the shared Deadline definition. fireTimer delivers that citizen at the
current virtual now; the Left deadline stays armed and the clock does not advance.
If a supplied name matches more than one armed occurrence, the operation fails with
Simulator.AmbiguousInjection and reports the matching slots. Simulator.StateCitizen can scope
the injection to one mounted state path when repeated Component placements reuse the same local
occurrence name.
If no matching occurrence is armed, the operation succeeds with unchanged Snapshot geometry and a
fireTimer / Stale / NotArmed trace entry. Absence is a semantic non-step, not permission to choose
another Timer.
Virtual time is not a runtime clock proof
Simulator proves authored schedule semantics under the durations and starting instant supplied by the test. It does not prove operating-system wakeup precision, process survival, durable Timer recovery, owner transfer, or the latency of a production Timer provider.
Test those properties at the engine and storage boundary. Keep this proof focused on which Timer would be due, in what order, and how the chart reacts when it fires.
Change the world
- Advance four seconds, inspect
Noop, then advance one more second and inspect both due steps. - Fire only the Right citizen and prove the Left deadline remains armed at the same
now. - Use a deliberately ambiguous citizen name and inspect every slot in
AmbiguousInjection. - Build a self-arming zero-duration cascade and assert
CascadeLimitExceededrather than a timeout.