@@ -21,10 +21,11 @@ require upgrading Effect in lockstep; do not override the peer to another beta.
2121import { Machine } from " @typeonce/effect-machine"
2222import { ClusterMachine } from " @typeonce/effect-machine/cluster"
2323import { AtomMachine } from " @typeonce/effect-machine/reactivity"
24+ import { MachineTest } from " @typeonce/effect-machine/testing"
2425```
2526
2627Each ESM entrypoint is independent and tree-shakeable. Importing the root does
27- not load the reactivity or cluster adapters .
28+ not load the reactivity, cluster, or testing modules .
2829
2930## First machine
3031
@@ -655,6 +656,124 @@ restrictions, checkpoint planning, and delivery guarantees are documented on
655656that API. ` Machine.resume ` is logical resumption, not durable process or cluster
656657restoration.
657658
659+ ## Property-based semantic invariants
660+
661+ ` MachineTest.verify ` checks statechart structure and planner lifecycle laws.
662+ Application semantics belong in invariants that can be reused across generated
663+ scenarios and, in future, bounded exploration:
664+
665+ ``` ts
666+ import { MachineTest } from " @typeonce/effect-machine/testing"
667+ import { Effect } from " effect"
668+
669+ const invariant = MachineTest .invariants (accountMachine )
670+ const laws = [
671+ invariant .state (
672+ " balance is never negative" ,
673+ ({ snapshot }) =>
674+ snapshot .value .balance >= 0 ||
675+ ` negative balance: ${snapshot .value .balance } `
676+ ),
677+ invariant .step (
678+ " withdrawal removes exactly its amount" ,
679+ ({ before , event , after }) =>
680+ event ._tag !== " Withdraw" ||
681+ after .value .balance === before .value .balance - event .amount
682+ )
683+ ]
684+
685+ const generated = MachineTest .scenarios (accountMachine , {
686+ minEvents: 0 ,
687+ maxEvents: 30
688+ })
689+
690+ it .effect .prop (
691+ " preserves account laws" ,
692+ { scenario: generated .arbitrary },
693+ ({ scenario }) =>
694+ MachineTest .run (accountMachine , scenario ).pipe (
695+ Effect .tap ((trace ) => MachineTest .verify (accountMachine , trace )),
696+ Effect .flatMap ((trace ) => MachineTest .assertInvariants (accountMachine , trace , laws ))
697+ )
698+ )
699+ ```
700+
701+ State invariants observe settled startup and public-event states by default.
702+ Set ` observe ` to ` "microsteps" ` , ` "all" ` , or ` "final" ` for a different scope.
703+ Use ` when ` for conditional laws. A condition with no matches is reported as
704+ ` untested ` ; add ` require: { minObservations: 1 } ` when a particular trace must
705+ exercise it. ` checkInvariants ` returns this report, while ` assertInvariants `
706+ returns ` void ` for direct use in property tests. Failures retain the complete
707+ shrunk trace and precise event, microstep, configuration, and observation
708+ location.
709+
710+ These APIs inspect planner evidence. Staged action effects, invokes, timing,
711+ and process scheduling require the runtime command-model APIs instead.
712+
713+ Use bounded exploration when random scenarios should be complemented by a
714+ systematic search over concrete event representatives:
715+
716+ ``` ts
717+ const explored = yield * MachineTest .explore (accountMachine , {
718+ events : ({ snapshot }) => [
719+ new Deposit ({ amount: 1 }),
720+ new Withdraw ({ amount: snapshot .value .balance }),
721+ new Withdraw ({ amount: snapshot .value .balance + 1 })
722+ ],
723+ stateKey : ({ snapshot }) => ` ${snapshot .value ._tag }:${snapshot .value .balance } ` ,
724+ limits: {
725+ maxDepth: 20 ,
726+ maxStates: 1_000 ,
727+ maxTransitions: 10_000
728+ },
729+ invariants: laws
730+ })
731+
732+ const rejected = yield * MachineTest .assertReachable (
733+ explored ,
734+ " insufficient funds rejection" ,
735+ ({ configuration }) => configuration .includes (" Rejected" )
736+ )
737+
738+ console .log (rejected .trace .scenario .events ) // shortest witness
739+ ```
740+
741+ Exploration is breadth-first, so each retained node owns its shortest trace.
742+ It is exhaustive only for the concrete events returned by ` events ` and the
743+ equivalence relation defined by ` stateKey ` . Equal keys intentionally collapse
744+ snapshots and only the first representative is expanded. Results distinguish
745+ ` Complete ` from ` Truncated ` and retain the depth, state, or transition frontier
746+ that hit a limit. An unreachability assertion succeeds only for a complete
747+ result; otherwise it fails as inconclusive. Cycles are retained as graph edges,
748+ but exploration does not enumerate every cyclic path. Invariants are checked
749+ on startup and on each planned edge extending a node's shortest trace.
750+
751+ ## Causal runtime probes
752+
753+ Pure traces do not execute invokes or the managed runtime. When a test needs to
754+ prove that one live event has actually left the mailbox, attach a testing-only
755+ probe to a statechart reference:
756+
757+ ``` ts
758+ const ref = yield * Machine .start (machine )
759+ const probe = yield * MachineTest .probe (machine , ref )
760+
761+ const step = yield * probe .sendAndAwait (new CancelRequested ({}))
762+
763+ assert .strictEqual (step .handled , false )
764+ assert .deepStrictEqual (step .before , step .after )
765+ ```
766+
767+ ` sendAndAwait ` completes after that event's synchronous macrostep and managed
768+ commit work. It also completes for ignored events, which publish no snapshot
769+ and therefore cannot be synchronized by waiting for ` ref.changes ` .
770+
771+ The step retains the exact runtime plan, before/after logical snapshots, and
772+ whether the event was handled or changed/reentered the active configuration.
773+ It does not wait for timers or invoked processes to finish. Production code
774+ continues to use enqueue-only ` ref.send ` ; probes are exported only from the
775+ separate testing entry point.
776+
658777## Current limits
659778
660779Declarative first-class guards are not part of the current API. Ordinary
0 commit comments