// mavend/simulator_test.go — the replayable full-system simulator // (Vikunja #284, 20-07-2026-BACKLOG.md item 7). // // # What it is // // A scripted day, replayed through the real mavend code paths, with every // boundary faked and the clock under the scenario's control. A scenario is a // JSON file in testdata/scenarios; the harness reads it, builds a world, walks // the steps in order, and asserts on what actually happened: // // what Maven SAID — the reply text of every utterance // what was SENT — every delivery.Sendable the dispatcher emitted // what ARRIVED — the unified intake journal from #283 // what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis // what did NOT happen — expect_no_send / expect_no_call, first-class // // The last one is the point. Maven's hard constraints are mostly negative — // not a nag, not autonomous, nothing executed without confirmation — and a // harness that can only assert on things that happened cannot test any of // them. "Nothing was sent" is an assertion here, not an absence of one. // // # Determinism // // No time.Now() runs inside a replay. The scenario names a start instant, each // step names a wall-clock offset from it, and the harness advances a fakeClock // to that offset before running the step. Every clock reader in the world — // the handler's `now`, the tick loop's `tick(ctx, now)`, the intake journal's // publish stamp — is wired to that clock. Two runs of the same file produce // the same transcript, and a scenario about 08:35 does not behave differently // at 03:00 in CI. // // The tick is driven by the scenario, not by a ticker: tick() already takes // `now` as an argument, so the only thing the daemon's ticker contributed was // wall-clock timing, which is exactly what a replay must not have. // // # Why this shape and not a binary // // Vikunja #288 (golden-audio STT) deferred its tier-2 "audio → STT → router → // phraser" scenarios to this task, and asked that they reuse a fixture format // rather than inventing a third. A scenario here can name a WAV from // cmd/mavsttd/testdata and the harness will feed it through the STT seam. As a // test it runs under `make test` on every change, which a separate binary // would not. // // # Production is untouched // // Every file this task adds is a _test.go file or testdata. There is no // simulator in the daemon, no flag, no config key, and no code path that // checks whether a simulation is running. The seams it uses — stt.Transcriber, // tts.Synthesizer, router.Completer, delivery.Sink, ipc.CoreAPI, the // event.Bus from #283 — all already existed for the production wiring. package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/event" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/voice" ) // --------------------------------------------------------------------------- // Scenario format // --------------------------------------------------------------------------- // scenario — one scripted day. schema_version matches the convention already // set by testdata/system_safety_scenarios.json. type scenario struct { SchemaVersion int `json:"schema_version"` Name string `json:"name"` Description string `json:"description,omitempty"` // Start — the instant the day begins, RFC3339. Every step offset is // relative to it, and nothing in the run reads a real clock. Start string `json:"start"` // Script — what the resident model answers. The world has no llama-server; // see scriptedLLM for how an entry is chosen. Script []scriptEntry `json:"script,omitempty"` // Praxis / Nexus / Hexis — canned bodies for the ecosystem fakes. Absent ⇒ // that service is not wired at all, which is the default box. Praxis string `json:"praxis_attention,omitempty"` Nexus string `json:"nexus_resolve,omitempty"` Hexis string `json:"hexis_capabilities,omitempty"` Steps []step `json:"steps"` } // scriptEntry — one canned model answer. Match is a substring of the user // message; the first entry whose Match is contained in it wins, and an entry // with an empty Match is the catch-all. // // Route and Reply are separate because the same model serves both contracts // (CLAUDE.md, "LLM output contract"): a grammar-constrained call is a routing // call and gets Route, an unconstrained one is a phrasing call and gets Reply. type scriptEntry struct { Match string `json:"match"` Route string `json:"route,omitempty"` Reply string `json:"reply,omitempty"` } // step — one scripted moment. At is "HH:MM" or "HH:MM:SS", interpreted in the // start instant's location; the clock is advanced to it before the step runs. // // A step does exactly one thing (say / audio / signal / fact / tick / arrive) // and then asserts. Assertions are evaluated against everything recorded since // the run began, except expect_no_send and expect_no_call, which are scoped to // this step — "nothing was sent because of THIS" is the useful question. type step struct { At string `json:"at"` Note string `json:"note,omitempty"` // --- stimuli (at most one per step) --- // Say — an utterance, as text, through the same runTurn the IPC chat path // uses. Say string `json:"say,omitempty"` // Audio — a WAV under cmd/mavsttd/testdata, fed through the STT seam. This // is #288's deferred tier 2. The harness uses the deterministic stt stub // unless a real transcriber is available, so the assertion a scenario can // make about an audio step is about the PIPELINE, not about whisper's // accuracy — that is what cmd/mavsttd/golden_test.go is for. Audio string `json:"audio,omitempty"` // Signal — a presence/world fact arriving from a poller or /api/signal. Signal *signalStep `json:"signal,omitempty"` // Arrive — an intake write from a module: an ambient notification, a feed // item, a mail candidate. Goes through the same decorated ipc.CoreAPI the // daemon gives those callers, so it lands in the journal exactly as it // would in production. Arrive *arriveStep `json:"arrive,omitempty"` // Tick — run one iteration of the proactive loop at this instant. Tick bool `json:"tick,omitempty"` // Fault — make every ecosystem fake answer with this HTTP status from now // on. The degraded-mode lever; ClearFault puts them back. Fault int `json:"fault,omitempty"` ClearFault bool `json:"clear_fault,omitempty"` // --- assertions --- ExpectReply []string `json:"expect_reply_contains,omitempty"` ExpectNotReply []string `json:"expect_reply_lacks,omitempty"` ExpectSent []string `json:"expect_sent_contains,omitempty"` ExpectNoSend bool `json:"expect_no_send,omitempty"` ExpectCalled []string `json:"expect_called,omitempty"` ExpectNotCalled []string `json:"expect_not_called,omitempty"` ExpectEvents []string `json:"expect_events,omitempty"` ExpectNoEvents bool `json:"expect_no_events,omitempty"` } type signalStep struct { Key string `json:"key"` Value string `json:"value"` Source string `json:"source"` Kind string `json:"kind,omitempty"` } type arriveStep struct { // Note / Fact / Task — exactly one. Each mirrors the intake seam its real // caller uses. Note *arriveNote `json:"note,omitempty"` Fact *signalStep `json:"fact,omitempty"` Task *arriveTask `json:"task,omitempty"` AsOf string `json:"as_of,omitempty"` // "HH:MM" — OccurredAt, when it differs from the step time Source string `json:"source"` } type arriveNote struct { Text string `json:"text"` } type arriveTask struct { Text string `json:"text"` Evidence string `json:"evidence,omitempty"` Status string `json:"status,omitempty"` } // --------------------------------------------------------------------------- // The world // --------------------------------------------------------------------------- // simWorld — every faked boundary plus the real components between them. type simWorld struct { t *testing.T clock *fakeClock loc *time.Location start time.Time store *store.Store api ipc.CoreAPI // the intake-decorated adapter, same as the daemon builds bus *event.Bus handler *reactiveHandler tick *tickLoop sink *recordingSink llm *scriptedLLM praxis *fakeServer nexus *fakeServer hexis *fakeServer // transcript — everything that happened, in order. Printed on failure so a // broken scenario is diagnosable without a debugger. transcript []string replies []string } // recordingSink captures every send, mutex-guarded (the tick loop dispatches // from its own goroutine in production and the race detector is on here). type recordingSink struct { mu sync.Mutex sends []delivery.Sendable } func (s *recordingSink) Send(_ context.Context, d delivery.Sendable) error { s.mu.Lock() defer s.mu.Unlock() s.sends = append(s.sends, d) return nil } func (s *recordingSink) all() []delivery.Sendable { s.mu.Lock() defer s.mu.Unlock() out := make([]delivery.Sendable, len(s.sends)) copy(out, s.sends) return out } func (s *recordingSink) count() int { s.mu.Lock() defer s.mu.Unlock() return len(s.sends) } // scriptedLLM stands in for llama-server on BOTH contracts the resident model // serves: grammar-constrained routing and unconstrained phrasing. // // It is not a stub that ignores its input — a scenario that scripts an answer // for "что я пропустил" and gets asked something else must fail, not silently // return the wrong intent. An unmatched call returns an error, and the router // then falls through to the classifier cascade exactly as it does in // production when llama-server is unreachable. That fall-through is itself // worth exercising: it is the failure floor CLAUDE.md refuses to let rot. type scriptedLLM struct { mu sync.Mutex entries []scriptEntry calls []llm.Req } func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, r) routing := r.Grammar != "" for _, e := range s.entries { if e.Match != "" && !strings.Contains(strings.ToLower(r.User), strings.ToLower(e.Match)) { continue } if routing && e.Route != "" { return e.Route, nil } if !routing && e.Reply != "" { return e.Reply, nil } } return "", fmt.Errorf("simulator: no scripted %s answer for %q", map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60)) } // --------------------------------------------------------------------------- // Building the world // --------------------------------------------------------------------------- func newSimWorld(t *testing.T, sc scenario) *simWorld { t.Helper() start, err := time.Parse(time.RFC3339, sc.Start) if err != nil { t.Fatalf("scenario %q: bad start %q: %v", sc.Name, sc.Start, err) } clock := newFakeClock(start) st := newTestStore(t) bus := event.NewBus(512) // The same decorator the daemon wires, on the same clock: intake in a // replay is journalled exactly as it is in production. api := newIntakeAPI(ipc.NewStoreAPI(st), bus, clock.Now) sink := &recordingSink{} rules := loop.DefaultRules() gatherer := loop.NewGatherer(st, rules) dispatcher := delivery.NewDispatcher(delivery.Config{ Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st, }) tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules, time.Minute, 5*time.Minute, 0, nil, nil, nil, nil) scripted := &scriptedLLM{entries: sc.Script} w := &simWorld{ t: t, clock: clock, loc: start.Location(), start: start, store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted, } // Ecosystem fakes, wired only when the scenario supplies a body — a box // with no praxis block has no praxis client, and a scenario must be able to // reproduce that. eco := &ecosystemWiring{} if sc.Praxis != "" { w.praxis = newFakePraxis(t, sc.Praxis) eco.praxis = newPraxisClient(w.praxis.URL) } if sc.Nexus != "" { w.nexus = newFakeNexus(t, sc.Nexus) } if sc.Hexis != "" { w.hexis = newFakeHexis(t, sc.Hexis, fixtureHexisExecuted("exec_1", "completed")) } // The router: the same cascade the daemon builds — stage-0 grammars, the // LLM router on the scripted model, the classifier underneath. Keeping the // classifier in is deliberate; it is the failure floor, and a scenario that // scripts no route for an utterance exercises it. emb := router.NewHashEmbedder(1024) matcher := tool.NewMatcher(nil) rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted)) w.handler = &reactiveHandler{ stt: simTranscriber{}, tts: simSynthesizer{}, router: rtr, embedder: emb, api: api, matcher: matcher, phraser: phraser.NewStub(), replier: newLLMReplier(scripted, nil), now: clock.Now, memStore: st.VectorMemory(), dataStore: st, queryMinScore: config.DefaultQueryMinScore, queryMinMargin: config.DefaultQueryMinMargin, timeParser: router.StubDateTimeParser{}, dialogueSessions: dialogue.NewSessionStore(time.Hour), clarifyStore: dialogue.NewClarifyStore(time.Hour), clarifyMaxAttempts: dialogue.DefaultMaxAttempts, ecosystem: eco, } return w } // simTranscriber — the STT seam. Deterministic by construction: it returns the // text the harness parked for this step, so the pipeline under test is // "audio arrives → a turn runs", not "whisper heard correctly". Transcription // accuracy is cmd/mavsttd/golden_test.go's job (#288 tier 1), and duplicating // it here would make every scenario depend on a 500 MB model. type simTranscriber struct{ text string } func (s simTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) { return s.text, 1.0, nil } // simSynthesizer — the TTS seam. A scenario asserts on what Maven SAID, which // is the reply text; the waveform is not the artefact under test. type simSynthesizer struct{} func (simSynthesizer) Synthesize(_ context.Context, _ string) (audio.Audio, error) { return audio.Audio{Format: audio.PCM16kMono}, nil } // --------------------------------------------------------------------------- // Running // --------------------------------------------------------------------------- func (w *simWorld) logf(format string, args ...any) { w.transcript = append(w.transcript, fmt.Sprintf("%s %s", w.clock.Now().In(w.loc).Format("15:04:05"), fmt.Sprintf(format, args...))) } // dump prints the whole transcript. Called on any failure — a scenario that // broke on step 7 is unreadable without the six steps before it. func (w *simWorld) dump() { w.t.Logf("--- replay transcript ---\n%s", strings.Join(w.transcript, "\n")) } // advanceTo moves the clock to the step's offset. Time only ever moves // FORWARD: a scenario with steps out of order is a bug in the scenario, and // silently reordering it would hide the bug. func (w *simWorld) advanceTo(at string) { w.t.Helper() if at == "" { return } target := w.timeOf(at) now := w.clock.Now() if target.Before(now) { w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order", at, now.In(w.loc).Format("15:04:05")) } w.clock.Advance(target.Sub(now)) } // timeOf resolves an "HH:MM" or "HH:MM:SS" step offset against the scenario's // start day and location. func (w *simWorld) timeOf(at string) time.Time { w.t.Helper() layout := "15:04" if strings.Count(at, ":") == 2 { layout = "15:04:05" } hm, err := time.Parse(layout, at) if err != nil { w.t.Fatalf("bad step time %q: %v", at, err) } return time.Date(w.start.Year(), w.start.Month(), w.start.Day(), hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc) } func (w *simWorld) run(sc scenario) { ctx := context.Background() for i, s := range sc.Steps { w.advanceTo(s.At) if s.Note != "" { w.logf("# %s", s.Note) } sendsBefore := w.sink.count() callsBefore := w.callCount() eventsBefore := w.bus.Len() w.stimulate(ctx, s) w.assert(i, s, sendsBefore, callsBefore, eventsBefore) } } func (w *simWorld) stimulate(ctx context.Context, s step) { if s.Fault != 0 || s.ClearFault { for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} { if fs != nil { fs.SetFault(s.Fault) } } w.logf("fault=%d on every ecosystem fake", s.Fault) } switch { case s.Say != "": reply := w.handler.runTurn(ctx, s.Say, sourceText) w.replies = append(w.replies, reply) w.logf("он: %s", s.Say) w.logf("она: %s", reply) case s.Audio != "": text := w.audioText(s.Audio) // Swap in a transcriber parked with this step's text, then run the same // push-to-talk entry point the voice client calls. w.handler.stt = simTranscriber{text: text} resp, err := w.handler.HandlePushToTalk(ctx, voicePTT(), 0) if err != nil { w.t.Fatalf("push-to-talk on %s: %v", s.Audio, err) } w.replies = append(w.replies, resp.ReplyText) w.logf("[wav %s → %q]", filepath.Base(s.Audio), text) w.logf("она: %s", resp.ReplyText) case s.Signal != nil: w.write(ctx, *s.Signal, w.clock.Now()) w.logf("сигнал: %s=%s (%s)", s.Signal.Key, s.Signal.Value, s.Signal.Source) case s.Arrive != nil: w.arrive(ctx, *s.Arrive) case s.Tick: w.tick.tick(ctx, w.clock.Now()) w.logf("tick") } } func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) { w.t.Helper() kind := sig.Kind if kind == "" { kind = "env" } if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{ Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0, }); err != nil { w.t.Fatalf("write fact %s: %v", sig.Key, err) } } func (w *simWorld) arrive(ctx context.Context, a arriveStep) { w.t.Helper() // AsOf is when the thing HAPPENED, which for a feed item or a relayed // notification is usually earlier than when Maven heard about it. It does // not move the clock — only the timestamp on the row and the envelope. ts := w.clock.Now() if a.AsOf != "" { ts = w.timeOf(a.AsOf) } switch { case a.Fact != nil: f := *a.Fact if f.Source == "" { f.Source = a.Source } w.write(ctx, f, ts) w.logf("пришло: факт %s=%s (%s)", f.Key, f.Value, f.Source) case a.Note != nil: if _, err := w.api.WriteNote(ctx, ts, a.Note.Text, nil, a.Source); err != nil { w.t.Fatalf("write note from %s: %v", a.Source, err) } w.logf("пришло: заметка от %s — %s", a.Source, truncateRunes(a.Note.Text, 60)) case a.Task != nil: status := a.Task.Status if status == "" { status = store.TaskCandidate } if _, err := w.api.CaptureTask(ctx, ipc.CaptureTaskReq{ Text: a.Task.Text, Source: a.Source, Evidence: a.Task.Evidence, Status: status, Ts: ts, }); err != nil { w.t.Fatalf("capture task from %s: %v", a.Source, err) } w.logf("пришло: задача от %s — %s", a.Source, a.Task.Text) default: w.t.Fatalf("arrive step from %s carries nothing", a.Source) } } // audioText resolves a scenario's WAV reference to the text the fixture is // known to contain, by reading cmd/mavsttd's golden manifest (#288's format, // reused rather than duplicated). An unknown reference fails the scenario // rather than quietly transcribing to "". func (w *simWorld) audioText(ref string) string { w.t.Helper() manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json") raw, err := os.ReadFile(manifest) if err != nil { w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err) } var m struct { Cases []struct { Name string `json:"name"` WAV string `json:"wav"` Text string `json:"text"` } `json:"cases"` } if err := json.Unmarshal(raw, &m); err != nil { w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err) } for _, c := range m.Cases { if c.Name == ref || c.WAV == ref { return c.Text } } w.t.Fatalf("audio step %q: no such case in %s", ref, manifest) return "" } func voicePTT() voice.PushToTalkReq { return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}} } // callCount — how many requests every wired ecosystem fake has seen. func (w *simWorld) callCount() int { n := 0 for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} { if fs != nil { n += len(fs.Requests()) } } return n } func (w *simWorld) callPaths() []string { var out []string for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} { if fs == nil { continue } for _, r := range fs.Requests() { out = append(out, r.Method+" "+r.Path) } } return out } // --------------------------------------------------------------------------- // Assertions // --------------------------------------------------------------------------- func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) { w.t.Helper() where := fmt.Sprintf("step %d (%s)", i+1, s.At) if s.Note != "" { where += " " + s.Note } fail := func(format string, args ...any) { w.dump() w.t.Errorf("%s: %s", where, fmt.Sprintf(format, args...)) } lastReply := "" if len(w.replies) > 0 { lastReply = w.replies[len(w.replies)-1] } for _, want := range s.ExpectReply { if !containsFold(lastReply, want) { fail("reply %q does not contain %q", lastReply, want) } } for _, unwanted := range s.ExpectNotReply { if containsFold(lastReply, unwanted) { fail("reply %q contains %q and must not", lastReply, unwanted) } } sent := w.sink.all() for _, want := range s.ExpectSent { if !anyContains(sendableTexts(sent), want) { fail("nothing sent mentions %q; sent so far: %v", want, sendableTexts(sent)) } } // Scoped to this step on purpose: "nothing was sent BECAUSE OF THIS" is the // question a not-a-nag constraint asks. if s.ExpectNoSend && len(sent) > sendsBefore { fail("expected nothing to be sent, got %v", sendableTexts(sent[sendsBefore:])) } paths := w.callPaths() for _, want := range s.ExpectCalled { if !anyContains(paths, want) { fail("no ecosystem call matches %q; calls so far: %v", want, paths) } } for _, unwanted := range s.ExpectNotCalled { if anyContains(paths[callsBefore:], unwanted) { fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:]) } } evs := w.bus.Recent(0) for _, want := range s.ExpectEvents { if !anyContains(eventLines(evs), want) { fail("no intake event matches %q; journal: %v", want, eventLines(evs)) } } if s.ExpectNoEvents && w.bus.Len() > eventsBefore { fail("expected nothing to arrive, journal grew to %d", w.bus.Len()) } } func sendableTexts(sends []delivery.Sendable) []string { out := make([]string, 0, len(sends)) for _, s := range sends { out = append(out, fmt.Sprintf("[%s] %s", s.RuleName, s.Body)) } return out } func eventLines(evs []event.Event) []string { out := make([]string, 0, len(evs)) for _, e := range evs { out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body)) } return out } func containsFold(hay, needle string) bool { return strings.Contains(strings.ToLower(hay), strings.ToLower(needle)) } func anyContains(hay []string, needle string) bool { for _, h := range hay { if containsFold(h, needle) { return true } } return false } // --------------------------------------------------------------------------- // The test // --------------------------------------------------------------------------- const scenarioDir = "testdata/scenarios" // TestSimulatorScenarios replays every scenario file. Adding a scenario is // adding a JSON file — no Go change, which is the property that makes this // cheap enough to actually use. func TestSimulatorScenarios(t *testing.T) { entries, err := os.ReadDir(scenarioDir) if err != nil { t.Fatalf("reading %s: %v", scenarioDir, err) } var ran int for _, ent := range entries { if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") { continue } ran++ name := strings.TrimSuffix(ent.Name(), ".json") t.Run(name, func(t *testing.T) { sc := loadScenario(t, filepath.Join(scenarioDir, ent.Name())) w := newSimWorld(t, sc) w.run(sc) if testing.Verbose() { w.dump() } }) } if ran == 0 { t.Fatalf("no scenarios in %s — the harness would pass vacuously", scenarioDir) } } func loadScenario(t *testing.T, path string) scenario { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("reading %s: %v", path, err) } var sc scenario dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() // a typo'd assertion key must fail, not be ignored if err := dec.Decode(&sc); err != nil { t.Fatalf("parsing %s: %v", path, err) } if sc.SchemaVersion != 1 { t.Fatalf("%s: schema_version = %d, want 1", path, sc.SchemaVersion) } if sc.Name == "" || sc.Start == "" || len(sc.Steps) == 0 { t.Fatalf("%s: a scenario needs a name, a start and at least one step", path) } return sc } // TestSimulatorIsDeterministic replays one scenario twice and requires an // identical transcript. This is the property the whole task rests on: if a // time.Now() creeps into a replayed path, two runs diverge and this fails. func TestSimulatorIsDeterministic(t *testing.T) { path := filepath.Join(scenarioDir, "morning_missed.json") sc := loadScenario(t, path) transcriptOf := func() string { w := newSimWorld(t, sc) w.run(sc) return strings.Join(w.transcript, "\n") } first := transcriptOf() second := transcriptOf() if first != second { t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second) } // And the transcript's own timestamps must be the scenario's, not today's. if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) { t.Error("transcript carries the wall clock — something in the replay path read time.Now()") } } // TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake // that would silently produce a meaningless run. func TestSimulatorRefusesBackwardsSteps(t *testing.T) { // Not table-driven through run() because advanceTo calls t.Fatalf; this // checks the ordering arithmetic directly. sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00", Steps: []step{{At: "09:00"}}} w := newSimWorld(t, sc) w.advanceTo("09:00") if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" { t.Fatalf("clock at %s after advancing to 09:00", got) } w.advanceTo("09:30") if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" { t.Fatalf("clock at %s after advancing to 09:30", got) } }