From 7ff986ed795891902b639b15b43788778e43ac6d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 22:25:31 -0400 Subject: [PATCH] test: pin v1.0 freeze-critical coverage (parallel cascade, zero-coverage funcs) Signed-off-by: Joshua Temple --- state/actor_output_spawninput_test.go | 154 +++++++++++++++++++++ state/forbid_any_test.go | 73 ++++++++++ state/guard_inspect_test.go | 145 +++++++++++++++++++ state/parallel_in_parallel_cascade_test.go | 133 ++++++++++++++++++ state/systemclock_test.go | 137 ++++++++++++++++++ 5 files changed, 642 insertions(+) create mode 100644 state/actor_output_spawninput_test.go create mode 100644 state/forbid_any_test.go create mode 100644 state/guard_inspect_test.go create mode 100644 state/parallel_in_parallel_cascade_test.go create mode 100644 state/systemclock_test.go diff --git a/state/actor_output_spawninput_test.go b/state/actor_output_spawninput_test.go new file mode 100644 index 0000000..a328cbe --- /dev/null +++ b/state/actor_output_spawninput_test.go @@ -0,0 +1,154 @@ +package state_test + +import ( + "context" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// This file pins two previously-uncovered exported actor seams: +// +// - ActorInstance.Output (actor.go:381, via the *actorAdapter the exported +// NewActor returns): it must return nil before the child reaches its final +// state and the extracted completion output once it does. +// - WithSpawnInput (options.go:125): the input map declared on a dynamic Spawn +// must reach the spawned actor's ActorBehavior verbatim, so a child can be +// seeded from it. + +// TestOutput_NilBeforeFinal_ValueAfterFinal pins ActorInstance.Output across the +// completion boundary. We hold the ActorInstance the behavior returns so we can +// call Output() directly: before the child reaches "done" it must report nil, and +// once "finish" steps it to its final state the output extractor's value surfaces. +func TestOutput_NilBeforeFinal_ValueAfterFinal(t *testing.T) { + cm := state.Forge[string, string, *childEntity]("outchild"). + Action("record", func(c state.ActionCtx[*childEntity]) (state.Effect, error) { + c.Entity.result = "done-output" + return nil, nil + }). + State("working"). + State("done").Final().OnEntry("record"). + Initial("working"). + Transition("working").On("finish").GoTo("done"). + Quench() + + // Capture the ActorInstance returned by NewActor so Output() can be called on + // it directly across the completion boundary. + var captured state.ActorInstance + behavior := func(input map[string]any) (state.ActorInstance, error) { + inst := cm.Cast(&childEntity{}, state.WithInitialState("working")) + ai := state.NewActor(inst, func(i *state.Instance[string, string, *childEntity]) any { + return i.Entity().result + }) + captured = ai + return ai, nil + } + + parent := state.Forge[string, string, *trec]("outparent"). + State("idle"). + State("supervising").InvokeActor("outchild", + state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). + State("complete"). + Initial("idle"). + CurrentStateFn(func(*trec) string { return "idle" }). + Transition("idle").On("start").GoTo("supervising"). + Transition("supervising").On("childDone").GoTo("complete"). + Quench() + + p := parent.Cast(&trec{}, state.WithInitialState("idle")) + sys := state.NewActorSystem(p).Register("outchild", behavior) + ctx := context.Background() + + res := p.Fire(ctx, "start") + sys.Absorb(ctx, res.Effects) + if captured == nil { + t.Fatal("ActorBehavior was not invoked; no ActorInstance captured") + } + + // Before the child reaches its final state, Output() must be nil. + if got := captured.Output(); got != nil { + t.Fatalf("Output() before final = %v, want nil", got) + } + + id := state.ActorID(parent.Name(), "supervising", 0) + ref, ok := sys.Ref(id) + if !ok { + t.Fatalf("no actor ref for id %q", id) + } + if !sys.Deliver(ctx, ref, "finish") { + t.Fatal("Deliver(finish) returned false; actor not running") + } + + // After the child reached "done" (final), Output() returns the extracted value. + if got := captured.Output(); got != "done-output" { + t.Fatalf("Output() after final = %v, want %q", got, "done-output") + } + if p.Current() != "complete" { + t.Fatalf("parent state = %q, want complete", p.Current()) + } +} + +// TestWithSpawnInput_ReachesSpawnedActor pins WithSpawnInput: the input map +// declared on a dynamic Spawn must arrive verbatim at the spawned actor's +// ActorBehavior. We capture the input the behavior receives and assert each key +// round-trips. The child is then driven to completion as an end-to-end sanity +// check that the spawn produced a live, addressable actor. +func TestWithSpawnInput_ReachesSpawnedActor(t *testing.T) { + cm := state.Forge[string, string, *childEntity]("inworker"). + State("working"). + State("done").Final(). + Initial("working"). + Transition("working").On("finish").GoTo("done"). + Quench() + + var received map[string]any + behavior := func(input map[string]any) (state.ActorInstance, error) { + received = input + inst := cm.Cast(&childEntity{}, state.WithInitialState("working")) + return state.NewActor(inst, nil), nil + } + + m := state.Forge[string, string, *trec]("inspawner"). + State("idle"). + State("active"). + Initial("idle"). + CurrentStateFn(func(*trec) string { return "idle" }). + Transition("idle").On("go").GoTo("active"). + Spawn("inworker", "worker-1", + state.WithSpawnInput(map[string]any{"greeting": "hello", "count": float64(42)}), + state.WithSpawnOnDone("workerDone"), state.WithSpawnOnError("workerErr")). + Transition("active").On("workerDone").GoTo("idle"). + Quench() + + parent := m.Cast(&trec{}, state.WithInitialState("idle")) + sys := state.NewActorSystem(parent).Register("inworker", behavior) + ctx := context.Background() + + res := parent.Fire(ctx, "go") + sys.Absorb(ctx, res.Effects) + + if received == nil { + t.Fatal("spawned ActorBehavior received nil input; WithSpawnInput did not reach the actor") + } + if got := received["greeting"]; got != "hello" { + t.Fatalf("input[greeting] = %v, want %q", got, "hello") + } + if got := received["count"]; got != float64(42) { + t.Fatalf("input[count] = %v, want %v", got, float64(42)) + } + if len(received) != 2 { + t.Fatalf("input has %d keys (%v), want exactly 2", len(received), received) + } + + // The spawn produced a live, addressable actor under the explicit id. + ref, ok := sys.Ref("worker-1") + if !ok { + t.Fatal("spawned actor worker-1 is not running/addressable") + } + if !sys.Deliver(ctx, ref, "finish") { + t.Fatal("Deliver(finish) returned false; spawned actor not running") + } + if sys.Running() != 0 { + t.Fatalf("running actors after completion = %d, want 0", sys.Running()) + } +} diff --git a/state/forbid_any_test.go b/state/forbid_any_test.go new file mode 100644 index 0000000..a6507b1 --- /dev/null +++ b/state/forbid_any_test.go @@ -0,0 +1,73 @@ +package state_test + +import ( + "context" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// This file pins ForbidAny (kernel.go:1255): a forbidden wildcard on a state +// consumes EVERY event not otherwise handled there, ignoring it in place instead +// of bubbling to an ancestor that would handle it. It is the wildcard counterpart +// of Forbid: where Forbid blocks one named event, ForbidAny blocks all unhandled +// events. + +// TestForbidAny_ConsumesEveryUnhandledEventWithoutBubbling asserts the ForbidAny +// contract against an ancestor that would otherwise handle the events. The child +// "work" declares ForbidAny. The parent "running" handles "stop" and "kick". Both +// are unhandled at the child, so — unlike a child with NO handler (which lets the +// event bubble), and unlike a plain Forbid (which blocks only one named event) — +// the forbidden wildcard must consume both in place. Neither bubbles to the parent. +func TestForbidAny_ConsumesEveryUnhandledEventWithoutBubbling(t *testing.T) { + m := provide(state.Forge[string, string, *trec]("forbidany"). + State("idle"). + Transition("idle").On("start").GoTo("running"). + SuperState("running"). + Initial("work"). + Transition("running").On("stop").GoTo("halted"). + Transition("running").On("kick").GoTo("halted"). + SubState("work"). + ForbidAny(). + EndSuperState(). + State("halted"). + Initial("idle")) + + inst := m.Cast(&trec{}, state.WithInitialState("running")) + if got := inst.Current(); got != "work" { + t.Fatalf("setup: Current() = %q, want work", got) + } + + // "stop" is handled by the parent but unhandled at the child: ForbidAny consumes + // it in place, so it must NOT bubble to the parent's stop -> halted. + res := inst.Fire(context.Background(), "stop") + if res.Err != nil { + t.Fatalf("Fire(stop) err = %v, want nil (forbidden wildcard ignores it)", res.Err) + } + if res.NewState == "halted" { + t.Fatalf("forbidden-wildcard event 'stop' bubbled to ancestor: NewState = halted") + } + if inst.Current() != "work" { + t.Fatalf("Current() = %q, want work (forbidden wildcard consumed 'stop' in place)", inst.Current()) + } + + // "kick" is also handled by the parent but unhandled at the child: unlike plain + // Forbid (which blocks only one named event and lets other events bubble), + // ForbidAny blocks this one too — the defining difference between the two. + res = inst.Fire(context.Background(), "kick") + if res.Err != nil { + t.Fatalf("Fire(kick) err = %v, want nil (forbidden wildcard ignores it)", res.Err) + } + if res.NewState == "halted" { + t.Fatalf("forbidden-wildcard event 'kick' bubbled to ancestor: NewState = halted") + } + if inst.Current() != "work" { + t.Fatalf("Current() = %q, want work (forbidden wildcard consumed 'kick' in place)", inst.Current()) + } + + // The outcome of a forbidden (consumed) event is a clean success, mirroring the + // specific-Forbid contract (TestFireFromState_ForbiddenEventIsConsumed). + if res.Trace.Outcome != state.OutcomeSuccess { + t.Fatalf("forbidden-wildcard outcome = %v, want OutcomeSuccess", res.Trace.Outcome) + } +} diff --git a/state/guard_inspect_test.go b/state/guard_inspect_test.go new file mode 100644 index 0000000..2098910 --- /dev/null +++ b/state/guard_inspect_test.go @@ -0,0 +1,145 @@ +package state_test + +import ( + "testing" + + "github.com/stablekernel/crucible/state" +) + +// This file pins the guard-expression inspection accessors tooling depends on: +// +// - GuardNode.LeafRefs (guard.go:222): the named-ref leaves of a composite +// guard, left-to-right, with the config-aware stateIn built-in omitted. +// - GuardNode.StateInTargets (guard.go:227): the target states of every stateIn +// leaf, left-to-right. +// +// Both walk an And/Or/Not tree mixing named-ref leaves and stateIn leaves. + +// TestGuardNode_LeafRefs extracts the named-ref leaves of composite guard trees, +// asserting left-to-right order and that stateIn leaves (which carry no host ref) +// are omitted. +func TestGuardNode_LeafRefs(t *testing.T) { + cases := []struct { + name string + expr state.GuardNode[string] + want []string + }{ + { + name: "single named leaf", + expr: state.Guard[string]("a"), + want: []string{"a"}, + }, + { + name: "stateIn leaf carries no ref", + expr: state.StateIn("X"), + want: nil, + }, + { + name: "And preserves left-to-right ref order", + expr: state.And(state.Guard[string]("a"), state.Guard[string]("b")), + want: []string{"a", "b"}, + }, + { + name: "Or over nested And, stateIn omitted", + expr: state.Or( + state.And(state.Guard[string]("a"), state.StateIn("X")), + state.Guard[string]("b"), + ), + want: []string{"a", "b"}, + }, + { + name: "Not wrapping a named leaf", + expr: state.Not(state.Guard[string]("a")), + want: []string{"a"}, + }, + { + name: "deep mix of And/Or/Not and stateIn", + expr: state.And( + state.Guard[string]("a"), + state.Or(state.StateIn("X"), state.Not(state.Guard[string]("b"))), + state.Guard[string]("c"), + ), + want: []string{"a", "b", "c"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expr := tc.expr + refs := expr.LeafRefs() + got := make([]string, len(refs)) + for i, r := range refs { + got[i] = r.Name + } + if !equalStrings(got, tc.want) { + t.Fatalf("LeafRefs() names = %v, want %v", got, tc.want) + } + }) + } +} + +// TestGuardNode_StateInTargets extracts the target states of every stateIn leaf in +// a composite guard, asserting left-to-right order and that named-ref leaves +// contribute nothing. +func TestGuardNode_StateInTargets(t *testing.T) { + cases := []struct { + name string + expr state.GuardNode[string] + want []string + }{ + { + name: "single stateIn", + expr: state.StateIn("X"), + want: []string{"X"}, + }, + { + name: "named leaf has no stateIn target", + expr: state.Guard[string]("a"), + want: nil, + }, + { + name: "And preserves left-to-right stateIn order", + expr: state.And(state.StateIn("X"), state.StateIn("Y")), + want: []string{"X", "Y"}, + }, + { + name: "Or over nested And/Not, named leaves omitted", + expr: state.Or( + state.And(state.Guard[string]("a"), state.StateIn("X")), + state.Not(state.StateIn("Y")), + ), + want: []string{"X", "Y"}, + }, + { + name: "deep mix yields stateIn targets in spine order", + expr: state.And( + state.StateIn("X"), + state.Or(state.Guard[string]("a"), state.StateIn("Y")), + state.StateIn("Z"), + ), + want: []string{"X", "Y", "Z"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expr := tc.expr + got := expr.StateInTargets() + if !equalStrings(got, tc.want) { + t.Fatalf("StateInTargets() = %v, want %v", got, tc.want) + } + }) + } +} + +// equalStrings reports whether two string slices are element-wise equal, treating +// nil and empty as equal (an absence of refs/targets). +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/state/parallel_in_parallel_cascade_test.go b/state/parallel_in_parallel_cascade_test.go new file mode 100644 index 0000000..033a2b1 --- /dev/null +++ b/state/parallel_in_parallel_cascade_test.go @@ -0,0 +1,133 @@ +package state_test + +import ( + "context" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// This file is the headline parallel-in-parallel done-cascade pin. It builds an +// OUTER parallel "par0" whose region "A" goes final directly and whose region "B" +// holds a COMPOUND "bcomp" that contains an INNER parallel "par1" whose own regions +// all go final. The done-cascade must walk the whole enclosing spine +// innermost-first: +// +// par1.OnDone (P1done) — inner parallel completes +// bcomp.OnDone (Bdone) — its enclosing compound becomes stateComplete +// par0.OnDone (P0done) — both outer regions complete (A final; B's compound done) +// +// Each fires EXACTLY once, in that order. The completion is driven by TWO events, +// reflecting the engine's dispatch: an event is delivered to the DEEPEST active +// parallel shared by the live leaves (here par1), so a single event cannot also +// advance par0's sibling region "A". The inner-completing event "e" drives par1 to +// final (emitting P1done then Bdone, since bcomp becomes stateComplete); the +// outer-completing event "f" — which par1's now-final regions decline, so it +// bubbles outward to par0's region A — advances "a1" to "af", at which point par0 +// is stateComplete and par0.OnDone fires. Collected across both fires the cascade +// must be innermost-first: P1done, then Bdone, then P0done. +// +// This pins the recursive upward cascade (the settleParallelDone / +// settleEnclosingDone path) across a parallel nested inside a compound nested +// inside a parallel — the deepest composite the v1.0 freeze must get right. The +// cascade gates on stateComplete (a parallel is never IsFinal), so a nested +// parallel propagates done to its enclosing compound and on up to the enclosing +// parallel exactly as a nested compound does. +// +// REGRESSION PIN: matches the cascade contract documented in the CHANGELOG +// ("a parallel state that completes inside an enclosing compound now cascades that +// compound's OnDone up the spine ... the parallel's own OnDone still fires exactly +// once"). It must NOT be weakened to a dropped (0-count) enclosing OnDone. +func TestParallelInParallel_NestedDone_CascadesInnermostFirst(t *testing.T) { + note := func(s string) state.ActionFn[prCtx] { + return func(state.ActionCtx[prCtx]) (state.Effect, error) { return s, nil } + } + m := state.Forge[string, string, prCtx]("par-in-par"). + Action("P1done", note("P1done")). + Action("Bdone", note("Bdone")). + Action("P0done", note("P0done")). + State("off"). + Transition("off").On("go").GoTo("par0"). + // Outer parallel par0: region A goes final directly; region B holds a + // compound whose content is the inner parallel par1. + SuperState("par0").OnDone("P0done"). + Region("A"). + Initial("a1"). + SubState("a1"). + SubState("af").Final(). + EndRegion(). + Region("B"). + Initial("bcomp"). + SuperState("bcomp").OnDone("Bdone"). + Initial("par1"). + SuperState("par1").OnDone("P1done"). + Region("x").Initial("x1").SubState("x1").SubState("xf").Final().EndRegion(). + Region("y").Initial("y1").SubState("y1").SubState("yf").Final().EndRegion(). + EndSuperState(). // close par1 (inner parallel) + EndSuperState(). // close bcomp (compound in region B) + EndRegion(). // close region B + EndSuperState(). // close par0 (outer parallel) + Initial("off"). + CurrentStateFn(func(prCtx) string { return "off" }). + // "e" completes the inner parallel par1's regions; "f" completes par0's + // sibling region A (it bubbles outward once par1's regions decline it). + Transition("a1").On("f").GoTo("af"). + Transition("x1").On("e").GoTo("xf"). + Transition("y1").On("e").GoTo("yf"). + Quench() + + inst := m.Cast(prCtx{}, state.WithInitialState("off")) + ctx := context.Background() + if res := inst.Fire(ctx, "go"); res.Err != nil { + t.Fatalf("entering outer parallel: %v", res.Err) + } + + // First event: completes the inner parallel par1. Its OnDone fires, and bcomp + // (par1's enclosing compound) becomes stateComplete, so Bdone fires too. par0 is + // NOT yet complete — its region A is still in "a1" — so P0done must not fire yet. + resE := inst.Fire(ctx, "e") + if resE.Err != nil { + t.Fatalf("e errored: %v (config=%v)", resE.Err, inst.Configuration()) + } + if got := countEffect(resE.Effects, "P1done"); got != 1 { + t.Fatalf("after e: P1done fired %d times, want 1 (effects=%v)", got, stringEffects(resE.Effects)) + } + if got := countEffect(resE.Effects, "Bdone"); got != 1 { + t.Fatalf("after e: Bdone fired %d times, want 1 — the inner parallel's enclosing compound must settle (effects=%v)", + got, stringEffects(resE.Effects)) + } + if got := countEffect(resE.Effects, "P0done"); got != 0 { + t.Fatalf("after e: P0done fired %d times, want 0 — par0's region A is still active (effects=%v)", + got, stringEffects(resE.Effects)) + } + + // Second event: completes par0's region A (a1 -> af). par0 is now stateComplete, + // so par0.OnDone fires — exactly once. The inner OnDones must not re-fire. + resF := inst.Fire(ctx, "f") + if resF.Err != nil { + t.Fatalf("f errored: %v (config=%v)", resF.Err, inst.Configuration()) + } + if got := countEffect(resF.Effects, "P0done"); got != 1 { + t.Fatalf("after f: P0done fired %d times, want exactly 1 — the outer parallel must settle once its last region completes (effects=%v)", + got, stringEffects(resF.Effects)) + } + for _, stale := range []string{"P1done", "Bdone"} { + if got := countEffect(resF.Effects, stale); got != 0 { + t.Fatalf("after f: %s fired %d times, want 0 — already-settled inner OnDones must not re-fire (effects=%v)", + stale, got, stringEffects(resF.Effects)) + } + } + + // Innermost-first across the whole sequence: P1done (e) before Bdone (e) before + // P0done (f). + combined := append(stringEffects(resE.Effects), stringEffects(resF.Effects)...) + idx := map[string]int{"P1done": -1, "Bdone": -1, "P0done": -1} + for i, e := range combined { + if _, ok := idx[e]; ok { + idx[e] = i + } + } + if idx["P1done"] >= idx["Bdone"] || idx["Bdone"] >= idx["P0done"] { + t.Fatalf("effect order = %v, want P1done < Bdone < P0done (innermost-first up the spine)", combined) + } +} diff --git a/state/systemclock_test.go b/state/systemclock_test.go new file mode 100644 index 0000000..df9b122 --- /dev/null +++ b/state/systemclock_test.go @@ -0,0 +1,137 @@ +package state_test + +import ( + "context" + "testing" + "time" + + "github.com/stablekernel/crucible/state" +) + +// This file pins the production wall-clock seam, SystemClock (scheduler.go:217), +// and its Now / After methods. The deterministic FakeClock-backed tests +// (scheduler_test.go) never touch this path: they advance a fake clock and never +// wait on real time. These tests exercise the REAL systemClock — once driving a +// genuine `after` transition to fire through a Scheduler, and once asserting the +// raw Now / After contract — with tiny real durations so they stay fast and +// non-flaky. + +// TestSystemClock_DrivesRealAfterTransition drives a delayed (`after`) transition +// with the production SystemClock (no FakeClock): entering "armed" schedules a +// real timer, and after a tiny real wait the Scheduler's Tick reads the wall clock +// (systemClock.Now), finds the timer due, and fires the delayed event so the +// instance lands in the target state. This is the only test that exercises the +// real SystemClock-backed elapse path the fake-clock tests bypass. +func TestSystemClock_DrivesRealAfterTransition(t *testing.T) { + const delay = 5 * time.Millisecond + m := state.Forge[string, string, *trec]("realtimed"). + State("idle"). + State("armed"). + State("fired"). + Initial("idle"). + CurrentStateFn(func(*trec) string { return "idle" }). + Transition("idle").On("go").GoTo("armed"). + Transition("armed").After(delay).On("elapsed").GoTo("fired"). + Quench() + + // Wire the production SystemClock explicitly to pin the SystemClock() entry + // point; omitting WithClock would default to the same systemClock{}, so this + // drives the real wall-clock path either way. + clk := state.SystemClock() + inst := m.Cast(&trec{}, state.WithInitialState("idle"), state.WithClock[string](clk)) + sch := state.NewScheduler(inst) + ctx := context.Background() + + res := inst.Fire(ctx, "go") + sch.Absorb(ctx, res.Effects) + if inst.Current() != "armed" { + t.Fatalf("after go, want armed, got %q", inst.Current()) + } + wantID := state.ScheduleID("realtimed", "armed", 0) + if !sch.HasPending(wantID) { + t.Fatalf("entering armed should arm timer %q; pending=%d", wantID, sch.Pending()) + } + + // Before the real delay elapses, ticking fires nothing (the wall clock has not + // advanced past the due time yet). This is racy if the machine is slow, so we + // only assert the negative immediately after arming. + if fired := sch.Tick(ctx); len(fired) != 0 { + t.Fatalf("tick before real delay fired %d events", len(fired)) + } + if inst.Current() != "armed" { + t.Fatalf("before real delay, want armed, got %q", inst.Current()) + } + + // Wait for real time to pass the delay (with margin), then Tick: systemClock.Now + // now reports a time at/after the due time, so the timer is due and fires. + deadline := time.Now().Add(time.Second) + for { + if time.Now().After(deadline) { + t.Fatalf("real after-transition did not fire within 1s; current=%q pending=%d", + inst.Current(), sch.Pending()) + } + time.Sleep(2 * time.Millisecond) + fired := sch.Tick(ctx) + if len(fired) > 0 { + if len(fired) != 1 { + t.Fatalf("tick past real delay fired %d events, want 1", len(fired)) + } + break + } + } + + if inst.Current() != "fired" { + t.Fatalf("after real delay, want fired, got %q", inst.Current()) + } + if sch.Pending() != 0 { + t.Fatalf("timer should be consumed after firing; pending=%d", sch.Pending()) + } +} + +// TestSystemClock_NowTracksWallClock asserts SystemClock().Now reports the real +// wall clock: two reads bracketing a real sleep are non-decreasing and the second +// is at least the slept duration after the first. +func TestSystemClock_NowTracksWallClock(t *testing.T) { + clk := state.SystemClock() + const pause = 5 * time.Millisecond + + before := clk.Now() + wall := time.Now() + if d := before.Sub(wall); d < -time.Second || d > time.Second { + t.Fatalf("SystemClock.Now = %v drifts from time.Now = %v by %v, want near-zero", before, wall, d) + } + + time.Sleep(pause) + after := clk.Now() + if !after.After(before) { + t.Fatalf("SystemClock.Now did not advance: before=%v after=%v", before, after) + } + if elapsed := after.Sub(before); elapsed < pause { + t.Fatalf("SystemClock.Now advanced %v across a %v sleep, want >= %v", elapsed, pause, pause) + } +} + +// TestSystemClock_AfterFiresAfterDelay asserts SystemClock().After returns a +// channel that receives once a real duration elapses (mirroring time.After), the +// method the Scheduler conformance contract relies on for a host that selects on +// the channel rather than polling Now+Tick. +func TestSystemClock_AfterFiresAfterDelay(t *testing.T) { + clk := state.SystemClock() + const delay = 5 * time.Millisecond + + start := time.Now() + ch := clk.After(delay) + + select { + case got := <-ch: + if elapsed := time.Since(start); elapsed < delay { + t.Fatalf("After channel fired after %v, want >= %v", elapsed, delay) + } + // time.After delivers the instant the timer fired; it must be at/after start. + if got.Before(start) { + t.Fatalf("After delivered instant %v before call time %v", got, start) + } + case <-time.After(time.Second): + t.Fatal("SystemClock.After channel did not fire within 1s") + } +}