Guide

Virtual Time

Advance deterministic time or fire one exact mounted timer occurrence.

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

Move the clockadvance · advanceTo

Process every deadline due inside one deterministic time window.

Best for schedule order, cascades, and elapsed-time behavior.
Hold the clockfireTimer

Deliver 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

  1. Advance four seconds, inspect Noop, then advance one more second and inspect both due steps.
  2. Fire only the Right citizen and prove the Left deadline remains armed at the same now.
  3. Use a deliberately ambiguous citizen name and inspect every slot in AmbiguousInjection.
  4. Build a self-arming zero-duration cascade and assert CascadeLimitExceeded rather than a timeout.