Skip to content

Commit 324ae69

Browse files
Prepare core transition evidence for MachineTest (#128)
1 parent 944cdb5 commit 324ae69

15 files changed

Lines changed: 412 additions & 68 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Retain exact static and runtime transition evidence for testing and visualization. Transition branch inspection now includes the selected target kind and scope, retained planner transitions identify the zero-based branch that executed, and `Machine.initialDefinition` exposes the root startup selection without executing its resolver.
6+
7+
Use `branchIndex` to associate a retained transition with the corresponding entry in `Machine.transitionDefinitions(machine).branches`. Direct transitions use index `0`; conditional cases retain their declaration index and `otherwise` follows the final case.

examples/platformer/src/machine.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,12 @@ describe("platformer history integration", () => {
164164
reenter: true,
165165
branches: [{
166166
type: "direct",
167-
target: "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock"
167+
target: "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock",
168+
selection: {
169+
path: "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock",
170+
kind: "state",
171+
scope: "local"
172+
}
168173
}]
169174
})
170175
expect(definitions.every(({ branches }) => branches.length > 0)).toBe(true)

src/Machine.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ export interface Machine<
259259

260260
/** @internal */
261261
readonly initial: (...args: [...Machine.InputArgs<Input>]) => Machine.InitialResult<States, InitialE, InitialR>
262+
/** @internal */
263+
readonly initialDefinition: Machine.InitialDefinition
262264
}
263265

264266
export {
@@ -2413,6 +2415,8 @@ export declare namespace Machine {
24132415
readonly invoke: any
24142416
/** @internal */
24152417
readonly initial: any
2418+
/** @internal */
2419+
readonly initialDefinition: InitialDefinition
24162420
}
24172421

24182422
/**
@@ -3157,22 +3161,42 @@ export declare namespace Machine {
31573161
readonly outcome: "done" | "failure" | "snapshot"
31583162
}
31593163

3164+
/** Static topology selected by a transition branch. */
3165+
export interface TransitionTargetSelection<
3166+
Path extends string | undefined = string | undefined,
3167+
Kind extends Topology.TargetSelectionKind = Topology.TargetSelectionKind,
3168+
Scope extends Topology.TargetSelectionScope | undefined = Topology.TargetSelectionScope | undefined
3169+
> {
3170+
readonly path: Path
3171+
readonly kind: Kind
3172+
readonly scope: Scope
3173+
}
3174+
31603175
/** One statically captured branch of a transition definition. */
31613176
export type TransitionBranch<Path extends string = string> =
31623177
| {
31633178
readonly type: "direct"
31643179
readonly target: Path | undefined
3180+
readonly selection: TransitionTargetSelection<Path | undefined>
31653181
}
31663182
| {
31673183
readonly type: "case"
31683184
readonly title: string
31693185
readonly target: Path | undefined
3186+
readonly selection: TransitionTargetSelection<Path | undefined>
31703187
}
31713188
| {
31723189
readonly type: "otherwise"
31733190
readonly target: Path | undefined
3191+
readonly selection: TransitionTargetSelection<Path | undefined>
31743192
}
31753193

3194+
/** The statically selected root entry for machine startup. */
3195+
export interface InitialDefinition<Path extends string = string> {
3196+
readonly target: Path
3197+
readonly selection: TransitionTargetSelection<Path, "state" | "initial", "initial">
3198+
}
3199+
31763200
/**
31773201
* Inspectable registration for a transition handler.
31783202
*
@@ -3223,6 +3247,8 @@ export declare namespace Machine {
32233247
readonly source: SourcePath
32243248
readonly trigger: TransitionTrigger<EventTag>
32253249
readonly reenter: boolean
3250+
/** Zero-based index of the selected static branch. */
3251+
readonly branchIndex: number
32263252
/** Path returned by the handler, including a history pseudo-state. */
32273253
readonly target: TargetPath | undefined
32283254
/** Concrete path used after resolving history, otherwise equal to `target`. */
@@ -8232,6 +8258,20 @@ export const stateNodes: <M extends Machine.Any>(machine: M) => ReadonlyArray<
82328258
>
82338259
> = internal.stateNodes
82348260

8261+
/**
8262+
* Returns the statically selected root entry used during machine startup.
8263+
*
8264+
* This function does not execute the initial resolver or require machine
8265+
* input. `kind: "initial"` means that the selected root enters its declared
8266+
* initial configuration.
8267+
*
8268+
* @category getters
8269+
* @since 0.14.0
8270+
*/
8271+
export const initialDefinition: <M extends Machine.Any>(machine: M) => Machine.InitialDefinition<
8272+
Machine.RootStateIdentifier<Machine.StateIdentifier<Machine.States<M>>>
8273+
> = internal.initialDefinition
8274+
82358275
/**
82368276
* Returns every registered transition handler in state definition order.
82378277
*

src/internal/machine/executionPlan.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,12 +424,13 @@ export interface ExecutionMacrostep<State = unknown> {
424424
const collectIndexedTransition = (
425425
machine: Machine.Any,
426426
transition: TransitionHandler<any, any, any, any>,
427-
context: any
427+
context: any,
428+
evaluate?: NonNullable<MicrostepTransition<any, any, any, any>["evaluate"]>
428429
) => {
429430
let commands: Array<RuntimeCommand> | undefined
430431
let raisedEvents: Array<any> | undefined
431432
let emittedEvents: Array<unknown> | undefined
432-
const result = transition(context, {
433+
const enqueue = {
433434
raise: (event: unknown) => {
434435
;(raisedEvents ??= []).push(decodeEventSync(machine, event))
435436
},
@@ -442,9 +443,13 @@ const collectIndexedTransition = (
442443
stop: (child: unknown) => {
443444
;(commands ??= []).push({ _tag: "Stop", child: child as any })
444445
}
445-
})
446+
}
447+
const evaluated = evaluate === undefined
448+
? { result: transition(context, enqueue), branchIndex: 0 }
449+
: evaluate(context, enqueue)
446450
return {
447-
state: isNoTarget(result) ? undefined : result,
451+
state: isNoTarget(evaluated.result) ? undefined : evaluated.result,
452+
branchIndex: evaluated.branchIndex,
448453
commands: commands ?? emptyExecutionValues,
449454
raisedEvents: raisedEvents ?? emptyExecutionValues,
450455
emittedEvents: emittedEvents ?? emptyExecutionValues
@@ -531,7 +536,12 @@ const collectIndexedEvaluatedTransition = (
531536
state: OwnedIndexedState,
532537
selection: IndexedSelectedTransition
533538
): IndexedEvaluatedTransition => {
534-
const transitionResult = collectIndexedTransition(machine, selection.transition.transition, selection.context)
539+
const transitionResult = collectIndexedTransition(
540+
machine,
541+
selection.transition.transition,
542+
selection.context,
543+
selection.transition.evaluate
544+
)
535545
const unresolvedTarget = transitionResult.state
536546
validateDeclaredTransitionTarget(
537547
selection.sourcePath,
@@ -558,6 +568,7 @@ const collectIndexedEvaluatedTransition = (
558568
if (!changed) {
559569
return {
560570
selection,
571+
branchIndex: transitionResult.branchIndex,
561572
unresolvedTarget: unresolvedTarget as any,
562573
target: target as any,
563574
next,
@@ -581,6 +592,7 @@ const collectIndexedEvaluatedTransition = (
581592
: naturalBoundary
582593
return {
583594
selection,
595+
branchIndex: transitionResult.branchIndex,
584596
unresolvedTarget: unresolvedTarget as any,
585597
target: target as any,
586598
next,
@@ -606,6 +618,7 @@ const indexedMicrostep = (
606618
source: transition.selection.sourcePath,
607619
trigger: transition.selection.trigger,
608620
reenter: transition.selection.transition.reenter,
621+
branchIndex: transition.branchIndex,
609622
target: transition.unresolvedTarget === undefined ? undefined : getTargetNodePath(transition.unresolvedTarget),
610623
resolvedTarget: transition.target === undefined ? undefined : getTargetNodePath(transition.target)
611624
})
@@ -740,7 +753,8 @@ const planIndexedFlatState = (
740753
event,
741754
snapshot: snapshotFromIndexedState(descriptor, current),
742755
target: getTargetBuilder(machine, sourcePath)
743-
}
756+
},
757+
transition.evaluate
744758
)
745759
const target = transitionResult.state
746760
validateDeclaredTransitionTarget(
@@ -789,6 +803,7 @@ const planIndexedFlatState = (
789803
source: sourcePath,
790804
trigger: { type: "event", event: event._tag },
791805
reenter: transition.reenter,
806+
branchIndex: transitionResult.branchIndex,
792807
target: target === undefined ? undefined : getTargetNodePath(target as any),
793808
resolvedTarget: target === undefined ? undefined : getTargetNodePath(target as any)
794809
}]

src/internal/machine/machine.ts

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const cloneWithHandlers = (
119119
machine.input = self.input
120120
machine.id = self.id
121121
machine.initial = self.initial
122+
machine.initialDefinition = self.initialDefinition
122123
machine.stateNodes = self.stateNodes
123124
machine.makeTargetBuilder = self.makeTargetBuilder
124125
machine.handlers = handlers
@@ -139,6 +140,15 @@ type CapturedBranch = DefinitionBranch & {
139140
readonly selection: Topology.TargetSelection
140141
}
141142

143+
const transitionTargetSelection = (
144+
selection: Topology.TargetSelection
145+
): Machine.TransitionTargetSelection =>
146+
Object.freeze({
147+
path: selection.path,
148+
kind: selection.kind,
149+
scope: selection.scope
150+
})
151+
142152
const makeSelectionMethod = (
143153
kind: Topology.TargetSelectionKind,
144154
path: string | undefined,
@@ -349,6 +359,27 @@ const captureTransition = (
349359
return captured
350360
})
351361
const otherwise = captureDefinitionBranch(definition.otherwise, selector, path, trigger)
362+
const evaluate = (context: Record<string, any>, enqueue: unknown) => {
363+
const predicateContext = { ...context }
364+
delete predicateContext.target
365+
for (let branchIndex = 0; branchIndex < cases.length; branchIndex++) {
366+
const branch = cases[branchIndex]!
367+
const result = branch.when!(predicateContext)
368+
if (!Option.isOption(result)) {
369+
throw new Error(`Machine conditional transition case "${branch.title}" must return Option`)
370+
}
371+
if (Option.isSome(result)) {
372+
return {
373+
result: runCapturedBranch(branch, context, enqueue, stateNodes, path, { value: result.value }),
374+
branchIndex
375+
}
376+
}
377+
}
378+
return {
379+
result: runCapturedBranch(otherwise, context, enqueue, stateNodes, path),
380+
branchIndex: cases.length
381+
}
382+
}
352383
return {
353384
reenter,
354385
targets: [
@@ -357,32 +388,37 @@ const captureTransition = (
357388
)
358389
],
359390
branches: [
360-
...cases.map((branch) => ({ type: "case" as const, title: branch.title!, target: branch.selection.path })),
361-
{ type: "otherwise" as const, target: otherwise.selection.path }
362-
],
363-
transition: (context: Record<string, any>, enqueue: unknown) => {
364-
const predicateContext = { ...context }
365-
delete predicateContext.target
366-
for (const branch of cases) {
367-
const result = branch.when!(predicateContext)
368-
if (!Option.isOption(result)) {
369-
throw new Error(`Machine conditional transition case "${branch.title}" must return Option`)
370-
}
371-
if (Option.isSome(result)) {
372-
return runCapturedBranch(branch, context, enqueue, stateNodes, path, { value: result.value })
373-
}
391+
...cases.map((branch) => ({
392+
type: "case" as const,
393+
title: branch.title!,
394+
target: branch.selection.path,
395+
selection: transitionTargetSelection(branch.selection)
396+
})),
397+
{
398+
type: "otherwise" as const,
399+
target: otherwise.selection.path,
400+
selection: transitionTargetSelection(otherwise.selection)
374401
}
375-
return runCapturedBranch(otherwise, context, enqueue, stateNodes, path)
376-
}
402+
],
403+
evaluate,
404+
transition: (context: Record<string, any>, enqueue: unknown) => evaluate(context, enqueue).result
377405
}
378406
}
379407
const branch = captureDefinitionBranch(transition, selector, path, trigger)
408+
const evaluate = (context: Record<string, any>, enqueue: unknown) => ({
409+
result: runCapturedBranch(branch, context, enqueue, stateNodes, path),
410+
branchIndex: 0
411+
})
380412
return {
381413
reenter,
382414
targets: branch.selection.path === undefined ? [] : [branch.selection.path],
383-
branches: [{ type: "direct" as const, target: branch.selection.path }],
384-
transition: (context: Record<string, any>, enqueue: unknown) =>
385-
runCapturedBranch(branch, context, enqueue, stateNodes, path)
415+
branches: [{
416+
type: "direct" as const,
417+
target: branch.selection.path,
418+
selection: transitionTargetSelection(branch.selection)
419+
}],
420+
evaluate,
421+
transition: (context: Record<string, any>, enqueue: unknown) => evaluate(context, enqueue).result
386422
}
387423
}
388424

@@ -1067,19 +1103,28 @@ const compileInitial = (
10671103
definition: unknown,
10681104
states: Machine.StateTree,
10691105
stateNodes: Machine.StateNodes
1070-
): (input?: unknown) => unknown => {
1106+
): {
1107+
readonly initial: (input?: unknown) => unknown
1108+
readonly definition: Machine.InitialDefinition
1109+
} => {
10711110
if (typeof definition !== "object" || definition === null) {
10721111
throw new Error("Machine initial definition must be an object")
10731112
}
10741113
const selector = makeInitialSelector(stateNodes)
10751114
const initialBuilder = makeSnapshotBuilder(states, { mode: "initial", prefix: "" }) as Record<string, any>
10761115
const branch = captureInitialBranch(definition, selector, initialBuilder)
1077-
return (input?: unknown) => {
1078-
const result = branch.resolve === undefined
1079-
? branch.builder()
1080-
: branch.resolve({ input, target: branch.builder }, undefined)
1081-
validateInitialSelection(result, branch.selection)
1082-
return result
1116+
return {
1117+
initial: (input?: unknown) => {
1118+
const result = branch.resolve === undefined
1119+
? branch.builder()
1120+
: branch.resolve({ input, target: branch.builder }, undefined)
1121+
validateInitialSelection(result, branch.selection)
1122+
return result
1123+
},
1124+
definition: Object.freeze({
1125+
target: branch.selection.path!,
1126+
selection: transitionTargetSelection(branch.selection) as Machine.InitialDefinition["selection"]
1127+
})
10831128
}
10841129
}
10851130

@@ -1221,7 +1266,9 @@ export const make: Make = (<
12211266
self.input = config.input
12221267
self.id = config.id
12231268
self.stateNodes = Topology.compileStateNodes(config.states)
1224-
self.initial = compileInitial(config.initial, config.states, self.stateNodes)
1269+
const compiledInitial = compileInitial(config.initial, config.states, self.stateNodes)
1270+
self.initial = compiledInitial.initial
1271+
self.initialDefinition = compiledInitial.definition
12251272
self.makeTargetBuilder = makeTargetBuilder(config.states, self.stateNodes)
12261273
self.handlers = Object.create(null)
12271274
self.handle = makeHandle(self)
@@ -1427,6 +1474,15 @@ export const stateNodes = <M extends Machine.Any>(
14271474
>
14281475
>
14291476

1477+
export const initialDefinition = <M extends Machine.Any>(
1478+
machine: M
1479+
): Machine.InitialDefinition<
1480+
Machine.RootStateIdentifier<Machine.StateIdentifier<Machine.States<M>>>
1481+
> =>
1482+
machine.initialDefinition as Machine.InitialDefinition<
1483+
Machine.RootStateIdentifier<Machine.StateIdentifier<Machine.States<M>>>
1484+
>
1485+
14301486
export const transitionDefinitions = <M extends Machine.Any>(
14311487
machine: M
14321488
): ReadonlyArray<

0 commit comments

Comments
 (0)