From 4c237b20acdf71ac7bfb2513e677f0351bdfdb17 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 4 Jun 2026 23:23:50 -0400 Subject: [PATCH] test: cover pre-1.0 gaps and drop dead skip scaffolding Signed-off-by: Joshua Temple --- source/memsource/batch_test.go | 306 +++++++++++++++++++++++++++++++++ state/canonical_test.go | 94 ++++++++++ state/example_pre1_test.go | 160 +++++++++++++++++ state/guard_test.go | 65 +++++++ state/hardening_test.go | 25 +-- state/kernel_test.go | 51 +----- state/roundtrip_test.go | 10 +- state/verify_test.go | 15 +- 8 files changed, 643 insertions(+), 83 deletions(-) create mode 100644 source/memsource/batch_test.go create mode 100644 state/canonical_test.go create mode 100644 state/example_pre1_test.go diff --git a/source/memsource/batch_test.go b/source/memsource/batch_test.go new file mode 100644 index 0000000..f05d362 --- /dev/null +++ b/source/memsource/batch_test.go @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: Apache-2.0 + +package memsource_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stablekernel/crucible/source" + "github.com/stablekernel/crucible/source/memsource" +) + +// TestHarness_RunBatch_DrivesBatchLane drives the harness batch lane across a +// range of size/count combinations and asserts batch contents, the per-call +// grouping, and a clean drain of every queued message. It exercises the +// RunBatch/RunBatchFor harness entry points from the memsource package itself. +func TestHarness_RunBatch_DrivesBatchLane(t *testing.T) { + t.Parallel() + tests := []struct { + name string + size int + count int + wantSizes []int + }{ + {name: "single full batch", size: 3, count: 3, wantSizes: []int{3}}, + {name: "two full batches", size: 2, count: 4, wantSizes: []int{2, 2}}, + {name: "trailing partial batch", size: 3, count: 5, wantSizes: []int{3, 2}}, + {name: "size exceeds count", size: 10, count: 4, wantSizes: []int{4}}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + msgs := make([]memsource.Msg, tt.count) + for i := range msgs { + // One key keeps every message in a single ordered lane so the + // batch sizes are deterministic. + msgs[i] = memsource.Msg{Key: "k", Value: []byte(fmt.Sprintf("%d", i))} + } + + var ( + mu sync.Mutex + gotSizes []int + gotValues []string + ) + h := memsource.NewHarness(t, + []source.Option{source.WithBatch(tt.size, 0)}, + msgs..., + ) + h.RunBatch(func(_ context.Context, ms []source.Message) []source.Result { + res := make([]source.Result, len(ms)) + mu.Lock() + gotSizes = append(gotSizes, len(ms)) + for i, m := range ms { + gotValues = append(gotValues, string(m.Value())) + res[i] = source.Ack() + } + mu.Unlock() + return res + }) + + if !equalInts(gotSizes, tt.wantSizes) { + t.Fatalf("batch sizes = %v, want %v", gotSizes, tt.wantSizes) + } + // The single lane preserves arrival order across the whole run. + wantValues := make([]string, tt.count) + for i := range wantValues { + wantValues[i] = fmt.Sprintf("%d", i) + } + if !equalStrings(gotValues, wantValues) { + t.Fatalf("delivered values = %v, want %v", gotValues, wantValues) + } + h.AssertCounts(memsource.Counts{Acked: tt.count}) + h.AssertSettled(tt.count) + }) + } +} + +// TestHarness_RunBatch_SettleByResult proves the harness settles each message in +// a batch by its own result, not a single batch-wide outcome: a mixed result +// slice acks, naks, and terms the corresponding positions. +func TestHarness_RunBatch_SettleByResult(t *testing.T) { + t.Parallel() + h := memsource.NewHarness(t, + []source.Option{source.WithBatch(3, 0)}, + memsource.Msg{Key: "k", Value: []byte("0")}, + memsource.Msg{Key: "k", Value: []byte("1")}, + memsource.Msg{Key: "k", Value: []byte("2")}, + ) + h.RunBatch(func(_ context.Context, ms []source.Message) []source.Result { + res := make([]source.Result, len(ms)) + for i := range ms { + switch i { + case 0: + res[i] = source.Ack() + case 1: + res[i] = source.Nak(fmt.Errorf("retry %d", i)) + default: + res[i] = source.Term(fmt.Errorf("poison %d", i)) + } + } + return res + }) + h.AssertCounts(memsource.Counts{Acked: 1, Nak: 1, Term: 1}) +} + +// TestHarness_RunBatchFor_DrainsOnTimeout confirms RunBatchFor passes an explicit +// timeout through and still drains every queued message under it. +func TestHarness_RunBatchFor_DrainsOnTimeout(t *testing.T) { + t.Parallel() + h := memsource.NewHarness(t, + []source.Option{source.WithBatch(2, 0)}, + memsource.Msg{Key: "k"}, + memsource.Msg{Key: "k"}, + memsource.Msg{Key: "k"}, + ) + h.RunBatchFor(2*time.Second, func(_ context.Context, ms []source.Message) []source.Result { + res := make([]source.Result, len(ms)) + for i := range ms { + res[i] = source.Ack() + } + return res + }) + h.AssertCounts(memsource.Counts{Acked: 3}) +} + +// TestHarness_RunBatch_BatchedCapability drives the WithBatched subscription so +// the engine takes the whole-batch NextBatch/SettleBatch path. It asserts the +// batched inlet still delivers and settles every queued message in order. +func TestHarness_RunBatch_BatchedCapability(t *testing.T) { + t.Parallel() + const count = 12 + msgs := make([]memsource.Msg, count) + for i := range msgs { + msgs[i] = memsource.Msg{Key: "k", Value: []byte(fmt.Sprintf("%d", i))} + } + + var ( + mu sync.Mutex + delivered []string + ) + h := memsource.NewHarnessWith(t, + []source.Option{source.WithBatch(8, 0)}, + []memsource.Option{memsource.WithBatched()}, + msgs..., + ) + h.RunBatch(func(_ context.Context, ms []source.Message) []source.Result { + res := make([]source.Result, len(ms)) + mu.Lock() + for i, m := range ms { + delivered = append(delivered, string(m.Value())) + res[i] = source.Ack() + } + mu.Unlock() + return res + }) + + want := make([]string, count) + for i := range want { + want[i] = fmt.Sprintf("%d", i) + } + if !equalStrings(delivered, want) { + t.Fatalf("batched delivery = %v, want %v", delivered, want) + } + h.AssertCounts(memsource.Counts{Acked: count}) + h.AssertSettled(count) +} + +// TestSubscription_NextBatch_DrainsQueued exercises the batched subscription's +// NextBatch directly: it blocks for the first message then drains whatever else +// is queued without blocking, capped at the limit, and never exceeds the queue. +func TestSubscription_NextBatch_DrainsQueued(t *testing.T) { + t.Parallel() + in := memsource.New(memsource.WithBatched()) + in.Queue( + memsource.Msg{Key: "k", Value: []byte("a")}, + memsource.Msg{Key: "k", Value: []byte("b")}, + memsource.Msg{Key: "k", Value: []byte("c")}, + ) + sub, err := in.Subscribe(context.Background(), source.SubscribeConfig{}) + if err != nil { + t.Fatalf("Subscribe err = %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + + batched, ok := sub.(source.Batched) + if !ok { + t.Fatalf("WithBatched subscription does not satisfy source.Batched") + } + + // limit below 1 is normalized to 1: a single message comes back. + first, err := batched.NextBatch(context.Background(), 0) + if err != nil { + t.Fatalf("NextBatch(0) err = %v", err) + } + if len(first) != 1 || string(first[0].Value()) != "a" { + t.Fatalf("NextBatch(0) = %v, want one message 'a'", values(first)) + } + + // A larger limit drains the remaining two without blocking, capped at queue. + rest, err := batched.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch(10) err = %v", err) + } + if got := values(rest); !equalStrings(got, []string{"b", "c"}) { + t.Fatalf("NextBatch(10) = %v, want [b c]", got) + } + + // SettleBatch records every message in the slice on the ledger. + all := append(append([]source.Message{}, first...), rest...) + if err := batched.SettleBatch(context.Background(), all, source.Ack()); err != nil { + t.Fatalf("SettleBatch err = %v", err) + } + if got := in.Ledger().Len(); got != 3 { + t.Fatalf("ledger len after SettleBatch = %d, want 3", got) + } + if c := in.Ledger().Counts(); c != (memsource.Counts{Acked: 3}) { + t.Fatalf("counts after SettleBatch = %+v, want Acked:3", c) + } +} + +// TestSubscription_NextBatch_DrainedReportsErr confirms NextBatch surfaces +// ErrDrained once the subscription is closed and its queue is empty, the signal +// the batch run loop uses to exit cleanly. +func TestSubscription_NextBatch_DrainedReportsErr(t *testing.T) { + t.Parallel() + in := memsource.New(memsource.WithBatched()) + sub, err := in.Subscribe(context.Background(), source.SubscribeConfig{}) + if err != nil { + t.Fatalf("Subscribe err = %v", err) + } + batched := sub.(source.Batched) + _ = sub.Close() + + if _, err := batched.NextBatch(context.Background(), 4); err != source.ErrDrained { + t.Fatalf("NextBatch after close err = %v, want ErrDrained", err) + } +} + +// TestMessage_AccessorsAndAsEscapeHatch asserts the in-memory message exposes +// its Key and Headers, and that the As escape hatch declines a target type it +// does not recognize (the documented narrow contract: it matches only the +// concrete **message). +func TestMessage_AccessorsAndAsEscapeHatch(t *testing.T) { + t.Parallel() + h := memsource.NewHarness(t, nil, memsource.Msg{ + Key: "route-key", + Value: []byte("payload"), + Headers: source.Headers{{Key: "tenant", Value: "acme"}}, + }) + h.Run(func(_ context.Context, m source.Message) source.Result { + if got := string(m.Key()); got != "route-key" { + t.Errorf("Key() = %q, want route-key", got) + } + if got, ok := m.Headers().Get("tenant"); !ok || got != "acme" { + t.Errorf("Headers.Get(tenant) = %q,%v, want acme,true", got, ok) + } + // As declines a target it does not recognize; the concrete **message + // target is unexported, so an external caller cannot match it. + var other *int + if m.As(&other) { + t.Error("As matched an unrelated target type") + } + return source.Ack() + }) + h.AssertCounts(memsource.Counts{Acked: 1}) +} + +// equalInts reports whether two int slices have identical contents in order. +func equalInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// equalStrings reports whether two string slices have identical contents in +// order. +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 +} + +// values extracts the string values of a message slice for assertion messages. +func values(ms []source.Message) []string { + out := make([]string, len(ms)) + for i, m := range ms { + out[i] = string(m.Value()) + } + return out +} diff --git a/state/canonical_test.go b/state/canonical_test.go new file mode 100644 index 0000000..55455a7 --- /dev/null +++ b/state/canonical_test.go @@ -0,0 +1,94 @@ +package state_test + +import ( + "testing" + + "github.com/stablekernel/crucible/state" +) + +// snapRegistry binds the host behavior the representative machines reference, so +// a rehydrated IR re-Quenches without unbound refs. It mirrors what the DSL +// registers for the flat machine ("bump"); the hierarchical and parallel +// machines carry no refs and are unaffected. +func snapRegistry() *state.Registry[*snapCtx] { + return state.NewRegistry[*snapCtx](). + Action("bump", func(c state.ActionCtx[*snapCtx]) (state.Effect, error) { + c.Entity.Count++ + c.Entity.Notes = append(c.Entity.Notes, "bumped") + return nil, nil + }) +} + +// TestCanonicalForm_WithoutSrcPosIsReDecodeIdempotent asserts the canonical-form +// invariant that evolution.DiffMachines relies on: ToJSON(WithoutSrcPos) is +// stable across a full re-decode. Serializing a machine, loading it back, +// re-providing host behavior, re-Quenching, and serializing again must yield +// byte-identical canonical bytes. A divergence would make DiffMachines report a +// phantom change between a machine and its own round-trip, so the property is a +// correctness invariant of the diff/evolution pipeline. +// +// This is a deterministic property test over representative machine shapes +// (flat-with-action, hierarchical, parallel) rather than a go test -fuzz +// harness: the generic Provide/Quench API needs concrete S/E/C types and a +// matching registry, which a byte-level fuzzer cannot synthesize for arbitrary +// machines. The byte-level fuzzing of the JSON front-end lives in FuzzRoundTrip; +// this test pins the WithoutSrcPos canonical form specifically. +func TestCanonicalForm_WithoutSrcPosIsReDecodeIdempotent(t *testing.T) { + reg := snapRegistry() + tests := []struct { + name string + machine func() *state.Machine[string, string, *snapCtx] + }{ + {name: "flat with action", machine: flatSnapMachine}, + {name: "hierarchical", machine: hsmSnapMachine}, + {name: "parallel", machine: parallelSnapMachine}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := tt.machine() + + first, err := m.ToJSON(state.WithoutSrcPos()) + if err != nil { + t.Fatalf("first ToJSON err = %v", err) + } + if len(first) == 0 { + t.Fatal("first ToJSON produced empty bytes") + } + + ir, err := state.LoadFromJSON[string, string, *snapCtx](first) + if err != nil { + t.Fatalf("LoadFromJSON err = %v", err) + } + m2 := ir.Provide(reg).Quench() + if m2 == nil { + t.Fatal("Provide().Quench() returned nil") + } + + second, err := m2.ToJSON(state.WithoutSrcPos()) + if err != nil { + t.Fatalf("second ToJSON err = %v", err) + } + + if string(first) != string(second) { + t.Fatalf("canonical form not re-decode idempotent:\n first=%s\nsecond=%s", first, second) + } + + // A second full re-decode must reproduce the same bytes again: the + // canonical form is a true fixed point, not merely stable for one hop. + ir2, err := state.LoadFromJSON[string, string, *snapCtx](second) + if err != nil { + t.Fatalf("second LoadFromJSON err = %v", err) + } + m3 := ir2.Provide(reg).Quench() + third, err := m3.ToJSON(state.WithoutSrcPos()) + if err != nil { + t.Fatalf("third ToJSON err = %v", err) + } + if string(second) != string(third) { + t.Fatalf("canonical form not a fixed point:\nsecond=%s\n third=%s", second, third) + } + }) + } +} diff --git a/state/example_pre1_test.go b/state/example_pre1_test.go new file mode 100644 index 0000000..fcc07d2 --- /dev/null +++ b/state/example_pre1_test.go @@ -0,0 +1,160 @@ +package state_test + +import ( + "context" + "fmt" + + "github.com/stablekernel/crucible/state" +) + +// gate is the entity the finalize-seam example guards and assigns against. +type gate struct { + approved bool + stamped bool +} + +// ExampleBuilder_Quench walks the full Forge -> Temper -> Quench finalize seam: +// Forge opens a builder, Temper lints it without freezing (returning diagnostics +// a tool can surface), and Quench binds refs and freezes the builder into an +// immutable Machine ready to Cast. A fully specified machine tempers with no +// findings and quenches without panicking. +func ExampleBuilder_Quench() { + b := state.Forge[string, string, gate]("turnstile"). + Guard("approved", func(c state.GuardCtx[gate]) bool { return c.Entity.approved }). + // CurrentStateFn lets the kernel derive the current state from the entity, + // which keeps Temper clean (no "missing CurrentStateFn" warning). + CurrentStateFn(func(g gate) string { + if g.stamped { + return "open" + } + return "locked" + }). + State("locked"). + Transition("locked").On("push").GoTo("open").When("approved"). + State("open"). + Initial("locked") + + // Temper lints without freezing: a tool can show findings before committing. + fmt.Println("temper findings:", len(b.Temper())) + + // Quench binds and freezes into an immutable Machine ready to Cast. + m := b.Quench() + + denied := m.Cast(gate{approved: false}) + denied.Fire(context.Background(), "push") + fmt.Println("denied:", denied.Current()) + + allowed := m.Cast(gate{approved: true}) + allowed.Fire(context.Background(), "push") + fmt.Println("allowed:", allowed.Current()) + // Output: + // temper findings: 0 + // denied: locked + // allowed: open +} + +// ExampleIR_Provide shows the JSON-rehydrate-then-run story: a machine authored +// in code is serialized with ToJSON, reloaded with LoadFromJSON into a behavior- +// free IR, and only then bound to host behavior with Provide before Quench. The +// guard func is supplied at Provide time, after the JSON was loaded, proving the +// structure travels as data while the Go behavior is re-attached by the host. +func ExampleIR_Provide() { + // Author and freeze a machine in code, then serialize its structure. The + // guard func itself is not serializable; only its name travels in the JSON. + authored := state.Forge[string, string, gate]("turnstile"). + Guard("approved", func(c state.GuardCtx[gate]) bool { return c.Entity.approved }). + State("locked"). + Transition("locked").On("push").GoTo("open").When("approved"). + State("open"). + Initial("locked"). + Quench() + + jsonBytes, err := authored.ToJSON(state.WithoutSrcPos()) + if err != nil { + fmt.Println("ToJSON err:", err) + return + } + + // Reload the structure with no behavior attached. + ir, err := state.LoadFromJSON[string, string, gate](jsonBytes) + if err != nil { + fmt.Println("LoadFromJSON err:", err) + return + } + + // Bind the guard func AFTER loading, supplying host behavior by name. + reg := state.NewRegistry[gate](). + Guard("approved", func(c state.GuardCtx[gate]) bool { return c.Entity.approved }) + m := ir.Provide(reg).Quench() + + allowed := m.Cast(gate{approved: true}, state.WithInitialState("locked")) + allowed.Fire(context.Background(), "push") + fmt.Println("rehydrated:", allowed.Current()) + // Output: + // rehydrated: open +} + +// pingPong is the child-actor entity: it counts the pings it receives before it +// is told to finish. +type pingPong struct { + pings int +} + +// ExampleActorSystem exercises the actor lifecycle end to end: a parent state +// dynamically Spawns a child-machine actor, the host delivers events to it by id +// and observes its progress, and the host Stops it explicitly. The ActorSystem is +// the host-side driver that turns a parent's SpawnActor/StopActor effects into +// running child actors and routes their completion back through the parent. +func ExampleActorSystem() { + // The child machine counts pings, then completes on "finish". + child := state.Forge[string, string, *pingPong]("counter"). + Action("count", func(c state.ActionCtx[*pingPong]) (state.Effect, error) { + c.Entity.pings++ + return nil, nil + }). + State("counting"). + State("done").Final(). + Initial("counting"). + Transition("counting").On("ping").GoTo("counting").Do("count"). + Transition("counting").On("finish").GoTo("done"). + Quench() + + // The parent spawns the child on "start" and reacts to its completion. + parent := state.Forge[string, string, *pingPong]("supervisor"). + State("idle"). + State("active"). + Initial("idle"). + Transition("idle").On("start").GoTo("active"). + Spawn("counter", "worker", state.WithSpawnOnDone("workerDone")). + Transition("active").On("workerDone").GoTo("idle"). + Quench() + + ctx := context.Background() + root := parent.Cast(&pingPong{}, state.WithInitialState("idle")) + + // A spawn behavior Casts a fresh child per spawn and exposes its ping count. + behavior := func(map[string]any) (state.ActorInstance, error) { + inst := child.Cast(&pingPong{}, state.WithInitialState("counting")) + return state.NewActor(inst, func(i *state.Instance[string, string, *pingPong]) any { + return i.Entity().pings + }), nil + } + sys := state.NewActorSystem(root).Register("counter", behavior) + + // Firing "start" emits the SpawnActor effect; Absorb spawns the child actor. + res := root.Fire(ctx, "start") + sys.Absorb(ctx, res.Effects) + fmt.Println("running after spawn:", sys.Running()) + + // Deliver two pings to the child by id; each steps it through its counter. + sys.DeliverByID(ctx, "worker", "ping") + sys.DeliverByID(ctx, "worker", "ping") + + // Stop the actor explicitly through the ref the host tracks. + ref, _ := sys.Ref("worker") + sys.Stop(ref) + fmt.Println("running after stop:", sys.Running()) + // Output: + // running after spawn: 1 + // running after stop: 0 +} diff --git a/state/guard_test.go b/state/guard_test.go index 94b4a9d..67eea10 100644 --- a/state/guard_test.go +++ b/state/guard_test.go @@ -464,6 +464,71 @@ func TestGuardExpr_MalformedAndPanicsAtQuench(t *testing.T) { Quench() } +// TestEventlessGuard_PanicDoesNotEnableTransition asserts the eventless safety +// property guardsPass guarantees: a guard that panics on an Always (eventless) +// transition is treated as not-passing, so the run-to-completion loop never +// silently auto-fires the transition. The instance settles in the event-target +// state and never advances through the guarded eventless edge. A swallowed panic +// that enabled the transition would corrupt the macrostep, so the property is a +// correctness invariant, not cosmetics. +// +// This is distinct from an event-triggered guard panic (covered by +// TestCompositeGuard_PanicSurfacesTyped), which surfaces a typed +// *GuardPanicError; the eventless selector is deliberately quieter — it must not +// turn a faulty guard into an unguarded auto-transition. +func TestEventlessGuard_PanicDoesNotEnableTransition(t *testing.T) { + tests := []struct { + name string + guard func(*state.Builder[string, string, gctx]) *state.Builder[string, string, gctx] + }{ + { + name: "plain guard ref panics", + guard: func(b *state.Builder[string, string, gctx]) *state.Builder[string, string, gctx] { + return b.When("boom") + }, + }, + { + name: "composite guard expr panics", + guard: func(b *state.Builder[string, string, gctx]) *state.Builder[string, string, gctx] { + return b.WhenExpr(state.Not(state.Guard[string]("boom"))) + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + m := tt.guard( + state.Forge[string, string, gctx]("eventless-guard"). + Guard("boom", func(state.GuardCtx[gctx]) bool { panic("kaboom") }). + State("from"). + Transition("from").On("go").GoTo("mid"). + State("mid"). + Always().GoTo("done"), + ). + State("done"). + Initial("from"). + Quench() + + inst := m.Cast(gctx{}, state.WithInitialState("from")) + res := inst.Fire(context.Background(), "go") + + // The panicking eventless guard is not-passing: the auto-transition to + // "done" never fires, so the macrostep settles in the event-target. + if got := inst.Current(); got != "mid" { + t.Fatalf("current = %q, want mid (eventless edge must not auto-fire)", got) + } + // The swallowed panic does not poison the macrostep: the triggering + // event settled cleanly. + if res.Err != nil { + t.Fatalf("Fire err = %v, want nil (eventless guard panic is swallowed, not surfaced)", res.Err) + } + if res.Trace.Outcome != state.OutcomeSuccess { + t.Fatalf("outcome = %v, want OutcomeSuccess", res.Trace.Outcome) + } + }) + } +} + func contains(ss []string, want string) bool { for _, s := range ss { if s == want { diff --git a/state/hardening_test.go b/state/hardening_test.go index 6e64683..c8b7b56 100644 --- a/state/hardening_test.go +++ b/state/hardening_test.go @@ -33,10 +33,7 @@ func TestImportGraph_StdlibOnly(t *testing.T) { // TestFireEach_FansAcrossInstances asserts FireEach drives one event across an // explicit set of instances, preserving per-instance attribution. func TestFireEach_FansAcrossInstances(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() a := m.Cast(&Document{Status: Draft}) b := m.Cast(&Document{Status: Draft}) results := state.FireEach(context.Background(), []*state.Instance[DocState, DocEvent, *Document]{a, b}, Submit) @@ -56,10 +53,7 @@ func TestFireEach_FansAcrossInstances(t *testing.T) { // TestFireEach_StopsAtFirstError asserts the default fail-fast batch semantics: // firing an event invalid for the instances stops at the first error. func TestFireEach_StopsAtFirstError(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() a := m.Cast(&Document{Status: Published}) // Submit is invalid from Published b := m.Cast(&Document{Status: Draft}) results := state.FireEach(context.Background(), []*state.Instance[DocState, DocEvent, *Document]{a, b}, Submit) @@ -73,10 +67,7 @@ func TestFireEach_StopsAtFirstError(t *testing.T) { // TestFireEach_CollectAll asserts CollectAll runs every instance. func TestFireEach_CollectAll(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() a := m.Cast(&Document{Status: Published}) b := m.Cast(&Document{Status: Draft}) results := state.FireEach( @@ -94,10 +85,7 @@ func TestFireEach_CollectAll(t *testing.T) { // byte-for-byte: ToJSON -> LoadFromJSON -> Provide -> Quench -> ToJSON yields // identical bytes (structure preserved losslessly). func TestRoundTrip_ByteIdentity(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() b1, err := m.ToJSON() if err != nil { t.Fatalf("ToJSON: %v", err) @@ -119,10 +107,7 @@ func TestRoundTrip_ByteIdentity(t *testing.T) { // TestHistory_RecordsEveryFire asserts an instance accumulates a trace per Fire // when unbounded history retention is enabled. func TestHistory_RecordsEveryFire(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() inst := m.Cast(&Document{Status: Draft}, state.WithUnboundedHistory[DocState]()) inst.Fire(context.Background(), Submit) inst.Fire(context.Background(), Archive) // Submitted->Archive is a valid transition diff --git a/state/kernel_test.go b/state/kernel_test.go index 708de4b..7fe8bb6 100644 --- a/state/kernel_test.go +++ b/state/kernel_test.go @@ -8,24 +8,10 @@ import ( "github.com/stablekernel/crucible/state" ) -// safeBuild recovers a panic from the not-yet-implemented Quench so a single -// test can assert on a specific later step without aborting the whole run with -// an un-recovered panic. When the kernel is implemented, recovered will be nil -// and the returned machine is real. -func safeBuild(t *testing.T) (m *state.Machine[DocState, DocEvent, *Document], recovered any) { - t.Helper() - defer func() { recovered = recover() }() - m = buildDocMachine() - return m, nil -} - // TestForgeQuench_BuildsMachine asserts the foundry build path // (Forge -> ... -> Quench) yields a usable, named machine. func TestForgeQuench_BuildsMachine(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Fatalf("Quench panicked (expected once implemented): %v", rec) - } + m := buildDocMachine() if m == nil { t.Fatal("Quench returned nil machine") } @@ -37,10 +23,7 @@ func TestForgeQuench_BuildsMachine(t *testing.T) { // TestCastFire_HappyPath asserts the core foundry step: Cast an instance and // Fire an event, advancing state and emitting effects with a success trace. func TestCastFire_HappyPath(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() doc := &Document{Status: Draft, ReviewerID: strptr("rev-1")} inst := m.Cast(doc) res := inst.Fire(context.Background(), Submit) @@ -61,10 +44,7 @@ func TestCastFire_HappyPath(t *testing.T) { // TestFire_TraceAlwaysNonNil asserts the invariant that Fire records a trace // even on a failing transition. func TestFire_TraceAlwaysNonNil(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() inst := m.Cast(&Document{Status: Draft}) // Approve is not a valid event from Draft. res := inst.Fire(context.Background(), Approve) @@ -81,10 +61,7 @@ func TestFire_TraceAlwaysNonNil(t *testing.T) { // TestFire_InvalidTransition asserts the typed InvalidTransitionError. func TestFire_InvalidTransition(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() inst := m.Cast(&Document{Status: Published}) res := inst.Fire(context.Background(), Submit) var it *state.InvalidTransitionError @@ -96,10 +73,7 @@ func TestFire_InvalidTransition(t *testing.T) { // TestFire_GuardFailed asserts the typed GuardFailedError when a guard returns // false (no reviewer on the document). func TestFire_GuardFailed(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() inst := m.Cast(&Document{Status: Submitted}) // no reviewer set on this entity res := inst.Fire(context.Background(), Approve) var gf *state.GuardFailedError @@ -113,10 +87,7 @@ func TestFire_GuardFailed(t *testing.T) { // TestPlanPath asserts BFS path planning returns the shortest event sequence. func TestPlanPath(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() doc := &Document{ReviewerID: strptr("rev-1")} path, err := m.PlanPath(Draft, Published, doc) if err != nil { @@ -135,10 +106,7 @@ func TestPlanPath(t *testing.T) { // TestPlanPath_NoPath asserts the typed NoPathError when no sequence connects. func TestPlanPath_NoPath(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() doc := &Document{} _, err := m.PlanPath(Archived, Draft, doc) var np *state.NoPathError @@ -150,10 +118,7 @@ func TestPlanPath_NoPath(t *testing.T) { // TestFireSeq drives a sequence of events into one instance under // run-to-completion, threading intermediate state. func TestFireSeq(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() doc := &Document{Status: Draft, ReviewerID: strptr("rev-1")} inst := m.Cast(doc) // Approve carries the hasReviewer guard, which reads the entity bound at diff --git a/state/roundtrip_test.go b/state/roundtrip_test.go index 0531e04..432a1d2 100644 --- a/state/roundtrip_test.go +++ b/state/roundtrip_test.go @@ -22,10 +22,7 @@ func docRegistry() *state.Registry[*Document] { // machine Forged in code and the same machine after ToJSON -> LoadFromJSON -> // Provide -> Quench behave identically. func TestRoundTrip_Identity(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() jsonBytes, err := m.ToJSON() if err != nil { @@ -61,10 +58,7 @@ func TestRoundTrip_Identity(t *testing.T) { // TestProvide_UnboundRef asserts Provide fails with *UnboundRefError when a ref // name in the IR has no registry binding. func TestProvide_UnboundRef(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() jsonBytes, err := m.ToJSON() if err != nil { t.Fatalf("ToJSON err = %v", err) diff --git a/state/verify_test.go b/state/verify_test.go index 1470f97..ce2d55e 100644 --- a/state/verify_test.go +++ b/state/verify_test.go @@ -10,10 +10,7 @@ import ( // TestVerify_FailFast asserts Verify returns *VerifyError with a single failure in // the default fail-fast mode when the entity does not satisfy the state. func TestVerify_FailFast(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() // A document in Approved state requires a reviewer; this one has none. doc := &Document{} err := m.Verify(Approved, doc) @@ -29,10 +26,7 @@ func TestVerify_FailFast(t *testing.T) { // TestVerify_Aggregate asserts Aggregate collects all failing requirements // and that the error type is uniform (*VerifyError) across both modes. func TestVerify_Aggregate(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() doc := &Document{} err := m.Verify(Approved, doc, state.Aggregate()) var ae *state.VerifyError @@ -47,10 +41,7 @@ func TestVerify_Aggregate(t *testing.T) { // TestVerify_Satisfied asserts Verify returns nil when the entity satisfies the // state's requirements. func TestVerify_Satisfied(t *testing.T) { - m, rec := safeBuild(t) - if rec != nil { - t.Skipf("build not implemented yet: %v", rec) - } + m := buildDocMachine() doc := &Document{ReviewerID: strptr("rev-1")} if err := m.Verify(Approved, doc); err != nil { t.Fatalf("Verify err = %v, want nil", err)