Skip to content

Commit 827d4c0

Browse files
Make MachineTest transition evidence exact
1 parent 324ae69 commit 827d4c0

10 files changed

Lines changed: 445 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+
Make `MachineTest.coverage` report transition definitions and their exact branches separately. Read definition coverage through `coverage.transitions.definitions` and conditional branch coverage through `coverage.transitions.branches`.
6+
7+
Replace the `targetBounds` verification law group with `definitions`. The new laws validate the declared startup root, transition registration, retained `branchIndex`, and the selected branch's exact target kind and scope.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ encoded snapshot.
547547
The testing entrypoint provides complementary layers:
548548

549549
- `MachineTest.run` and `verify` inspect pure planner traces;
550+
- `coverage` reports exact transition-definition and conditional-branch hits;
550551
- invariants and generated scenarios check application laws;
551552
- `explore` performs bounded breadth-first state-space exploration;
552553
- `probe` causally acknowledges live runtime commands;

examples/playground/src/examples/media-player/machine.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ describe("media-player statechart model", () => {
103103

104104
assert.strictEqual(coverage.events.hit, everyPublicEvent.length)
105105
assert.strictEqual(coverage.events.missing, 0)
106-
assert.strictEqual(coverage.transitions.hit > 0, true)
106+
assert.strictEqual(coverage.transitions.definitions.hit > 0, true)
107+
assert.strictEqual(coverage.transitions.branches.hit > 0, true)
107108

108109
const activated = new Set(coverage.states.activation.hits.map(({ path }) => path))
109110
assert.strictEqual(activated.has("Player.transport.Empty"), true)

src/internal/testing/machine/verification.ts

Lines changed: 106 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import type {
2929
StateCoverageItem,
3030
Trace,
3131
TraceStep,
32-
TransitionCoverageItem,
32+
TransitionBranchCoverageItem,
33+
TransitionDefinitionCoverageItem,
3334
VerificationLaw,
3435
VerificationLawGroup,
3536
VerificationViolation,
@@ -469,21 +470,33 @@ const normalizeTraces = <M extends AnyMachine>(
469470
): ReadonlyArray<Trace<M>> =>
470471
Array.isArray(traceOrTraces) ? traceOrTraces as ReadonlyArray<Trace<M>> : [traceOrTraces as Trace<M>]
471472

472-
const sameCoverageTrigger = (
473+
const sameTransitionTrigger = (
473474
left: Machine.Machine.TransitionTrigger,
474475
right: Machine.Machine.TransitionTrigger
475-
): boolean =>
476-
left.type === right.type && (left.type !== "event" || right.type === "event" && left.event === right.event)
476+
): boolean => {
477+
if (left.type !== right.type) return false
478+
if (left.type === "event") return right.type === "event" && left.event === right.event
479+
if (left.type === "invoke") {
480+
return right.type === "invoke" && left.id === right.id && left.outcome === right.outcome
481+
}
482+
return true
483+
}
477484

478-
const targetWithinDeclaredBranches = (
485+
const targetWithinSelection = (
479486
target: string | undefined,
480-
branches: ReadonlyArray<Machine.Machine.TransitionBranch>
481-
): boolean =>
482-
branches.some((branch) =>
483-
branch.target === undefined ?
484-
target === undefined
485-
: target !== undefined && (target === branch.target || target.startsWith(`${branch.target}.`))
486-
)
487+
branch: Machine.Machine.TransitionBranch,
488+
nodeByPath: ReadonlyMap<string, Machine.Machine.StateNode>
489+
): boolean => {
490+
const selection = branch.selection
491+
if (selection.kind === "none") return target === undefined
492+
if (target === undefined || selection.path === undefined) return false
493+
if (target === selection.path) return true
494+
const selectedNode = nodeByPath.get(selection.path)
495+
return selection.kind === "state" &&
496+
(selection.scope === "local" || selection.scope === "branch") &&
497+
(selectedNode?.type === "compound" || selectedNode?.type === "parallel") &&
498+
target.startsWith(`${selection.path}.`)
499+
}
487500

488501
const finiteTagValues = (ast: SchemaAST.AST): ReadonlyArray<PropertyKey> | undefined => {
489502
if (SchemaAST.isLiteral(ast)) {
@@ -567,12 +580,12 @@ export const coverage = <M extends AnyMachine>(
567580
(
568581
definition,
569582
index
570-
): TransitionCoverageItem<
583+
): TransitionDefinitionCoverageItem<
571584
StateNodePath<M>,
572585
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
573586
StateNodePath<M>
574587
> => ({
575-
id: `transition:${index}:${formatValue(definition)}`,
588+
id: `transition:${index}`,
576589
index,
577590
source: definition.source,
578591
trigger: definition.trigger,
@@ -581,6 +594,30 @@ export const coverage = <M extends AnyMachine>(
581594
})
582595
)
583596
const transitionHits = new Set<number>()
597+
const branchOffsets: Array<number> = []
598+
const branches: Array<
599+
TransitionBranchCoverageItem<
600+
StateNodePath<M>,
601+
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
602+
StateNodePath<M>
603+
>
604+
> = []
605+
for (let definitionIndex = 0; definitionIndex < definitions.length; definitionIndex++) {
606+
branchOffsets.push(branches.length)
607+
const definition = definitions[definitionIndex]!
608+
definition.branches.forEach((branch, branchIndex) => {
609+
branches.push({
610+
id: `transition:${definitionIndex}:branch:${branchIndex}`,
611+
definitionIndex,
612+
branchIndex,
613+
source: definition.source,
614+
trigger: definition.trigger,
615+
reenter: definition.reenter,
616+
branch
617+
})
618+
})
619+
}
620+
const branchHits = new Set<number>()
584621

585622
const declaredEvents = publicEventTags(machine)
586623
const declaredEventTags = declaredEvents.tags
@@ -658,10 +695,17 @@ export const coverage = <M extends AnyMachine>(
658695
const definitionIndex = definitions.findIndex((definition) =>
659696
definition.source === retained.source &&
660697
definition.reenter === retained.reenter &&
661-
sameCoverageTrigger(definition.trigger, retained.trigger) &&
662-
targetWithinDeclaredBranches(retained.target, definition.branches)
698+
sameTransitionTrigger(definition.trigger, retained.trigger)
663699
)
664-
if (definitionIndex !== -1) transitionHits.add(definitionIndex)
700+
if (definitionIndex === -1) continue
701+
transitionHits.add(definitionIndex)
702+
const definition = definitions[definitionIndex]!
703+
if (
704+
Number.isSafeInteger(retained.branchIndex) && retained.branchIndex >= 0 &&
705+
retained.branchIndex < definition.branches.length
706+
) {
707+
branchHits.add(branchOffsets[definitionIndex]! + retained.branchIndex)
708+
}
665709
}
666710
}
667711

@@ -701,7 +745,10 @@ export const coverage = <M extends AnyMachine>(
701745
entry: coverageSummary(activeNodes, entryHits),
702746
exit: coverageSummary(activeNodes, exitHits)
703747
},
704-
transitions: coverageSummary(definitions, transitionHits),
748+
transitions: {
749+
definitions: coverageSummary(definitions, transitionHits),
750+
branches: coverageSummary(branches, branchHits)
751+
},
705752
events: declaredEvents.diagnostics.length === 0
706753
? {
707754
available: true,
@@ -969,12 +1016,6 @@ const sameValue = (left: unknown, right: unknown): boolean => formatValue(left)
9691016
const samePaths = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
9701017
left.length === right.length && left.every((path, index) => path === right[index])
9711018

972-
const sameTrigger = (
973-
left: Machine.Machine.TransitionTrigger,
974-
right: Machine.Machine.TransitionTrigger
975-
): boolean =>
976-
left.type === right.type && (left.type !== "event" || right.type === "event" && left.event === right.event)
977-
9781019
const makeNodeUtilities = (nodes: ReadonlyArray<PublicStateNode>) => {
9791020
const byPath = new Map(nodes.map((node) => [node.path, node]))
9801021
const depth = (path: string): number => {
@@ -1018,9 +1059,10 @@ export const verify = <M extends AnyMachine>(
10181059
options: VerifyOptions = {}
10191060
): Effect.Effect<void, VerificationError> => {
10201061
const selected = new Set<VerificationLawGroup>(
1021-
options.laws ?? ["configuration", "microsteps", "completion", "history", "targetBounds"]
1062+
options.laws ?? ["configuration", "microsteps", "completion", "history", "definitions"]
10221063
)
10231064
const nodes = Machine.stateNodes(machine) as ReadonlyArray<PublicStateNode>
1065+
const initialDefinition = Machine.initialDefinition(machine)
10241066
const definitions = Machine.transitionDefinitions(machine)
10251067
const { ancestors, byPath, depth, isDescendantOrSelf } = makeNodeUtilities(nodes)
10261068
const violations: Array<VerificationViolation> = []
@@ -1496,33 +1538,47 @@ export const verify = <M extends AnyMachine>(
14961538
return direction === "entry" ? leftOrder - rightOrder : rightOrder - leftOrder
14971539
})
14981540

1499-
const validateTransitionBounds = (
1541+
const validateTransitionDefinition = (
15001542
transition: Microstep<M>["transitions"][number],
15011543
location: VerificationLocation
15021544
): void => {
1503-
if (!selected.has("targetBounds")) return
1545+
if (!selected.has("definitions")) return
15041546
const definition = definitions.find((candidate) =>
15051547
candidate.source === transition.source && candidate.reenter === transition.reenter &&
1506-
sameTrigger(candidate.trigger, transition.trigger)
1548+
sameTransitionTrigger(candidate.trigger, transition.trigger)
15071549
)
15081550
if (definition === undefined) {
15091551
add(
1510-
"targetBounds.definition",
1552+
"definitions.transition",
15111553
location,
15121554
`retained transition from "${transition.source}" has no public definition`,
15131555
transition.source
15141556
)
15151557
return
15161558
}
1517-
if (targetWithinDeclaredBranches(transition.target, definition.branches)) return
1518-
const targets = definition.branches.flatMap((branch) => branch.target === undefined ? [] : [branch.target])
1519-
if (!targets.some((bound) => isDescendantOrSelf(String(transition.target), String(bound)))) {
1559+
if (
1560+
!Number.isSafeInteger(transition.branchIndex) || transition.branchIndex < 0 ||
1561+
transition.branchIndex >= definition.branches.length
1562+
) {
1563+
add(
1564+
"definitions.branchIndex",
1565+
location,
1566+
`retained transition from "${transition.source}" selected invalid branch index ${transition.branchIndex}`,
1567+
transition.source
1568+
)
1569+
return
1570+
}
1571+
const branch = definition.branches[transition.branchIndex]!
1572+
if (!targetWithinSelection(transition.target, branch, byPath)) {
1573+
const expected = branch.selection.kind === "none"
1574+
? "an explicitly targetless result"
1575+
: `selection ${branch.selection.kind}:${branch.selection.scope}:${String(branch.selection.path)}`
15201576
add(
1521-
"targetBounds.target",
1577+
"definitions.selection",
15221578
location,
1523-
`transition target "${String(transition.target)}" is outside declared bounds ` +
1524-
`[${targets.join(", ")}]`,
1525-
String(transition.target)
1579+
`transition branch ${transition.branchIndex} from "${transition.source}" returned ` +
1580+
`target "${String(transition.target)}" outside ${expected}`,
1581+
transition.target === undefined ? transition.source : String(transition.target)
15261582
)
15271583
}
15281584
}
@@ -1679,7 +1735,7 @@ export const verify = <M extends AnyMachine>(
16791735
}
16801736
}
16811737
}
1682-
for (const transition of microstep.transitions) validateTransitionBounds(transition, location)
1738+
for (const transition of microstep.transitions) validateTransitionDefinition(transition, location)
16831739
}
16841740

16851741
const validatePlanCompletion = (
@@ -1715,6 +1771,18 @@ export const verify = <M extends AnyMachine>(
17151771

17161772
const initialLocation: VerificationLocation = { eventIndex: undefined }
17171773
const starting = inspectSnapshot(trace.initial.startingState, initialLocation, "initial starting state")
1774+
if (selected.has("definitions")) {
1775+
const startingRoots = starting.paths.filter((path) => byPath.get(path)?.parent === undefined)
1776+
if (startingRoots.length !== 1 || startingRoots[0] !== initialDefinition.target) {
1777+
add(
1778+
"definitions.initial",
1779+
initialLocation,
1780+
`initial starting state selected roots [${startingRoots.join(", ")}] instead of ` +
1781+
`declared root "${initialDefinition.target}"`,
1782+
startingRoots[0] ?? initialDefinition.target
1783+
)
1784+
}
1785+
}
17181786
validateSnapshotMetadata(starting, initialLocation, "initial starting state")
17191787
validateTraceConfiguration(
17201788
starting,
@@ -1849,6 +1917,7 @@ const formatMicrosteps = <M extends AnyMachine>(microsteps: ReadonlyArray<Micros
18491917
source: transition.source,
18501918
trigger: transition.trigger,
18511919
reenter: transition.reenter,
1920+
branchIndex: transition.branchIndex,
18521921
target: transition.target,
18531922
resolvedTarget: transition.resolvedTarget
18541923
}))

src/testing/MachineTest.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,9 +1677,9 @@ export interface StateCoverage<Path extends string = string> {
16771677
* One stable transition-definition identity in definition order.
16781678
*
16791679
* @category models
1680-
* @since 0.4.0
1680+
* @since 0.14.0
16811681
*/
1682-
export interface TransitionCoverageItem<
1682+
export interface TransitionDefinitionCoverageItem<
16831683
SourcePath extends string = string,
16841684
EventTag extends PropertyKey = PropertyKey,
16851685
TargetPath extends string = SourcePath
@@ -1692,6 +1692,42 @@ export interface TransitionCoverageItem<
16921692
readonly branches: ReadonlyArray<Machine.Machine.TransitionBranch<TargetPath>>
16931693
}
16941694

1695+
/**
1696+
* One statically declared transition branch together with its owning
1697+
* definition identity.
1698+
*
1699+
* @category models
1700+
* @since 0.14.0
1701+
*/
1702+
export interface TransitionBranchCoverageItem<
1703+
SourcePath extends string = string,
1704+
EventTag extends PropertyKey = PropertyKey,
1705+
TargetPath extends string = SourcePath
1706+
> {
1707+
readonly id: string
1708+
readonly definitionIndex: number
1709+
readonly branchIndex: number
1710+
readonly source: SourcePath
1711+
readonly trigger: Machine.Machine.TransitionTrigger<EventTag>
1712+
readonly reenter: boolean
1713+
readonly branch: Machine.Machine.TransitionBranch<TargetPath>
1714+
}
1715+
1716+
/**
1717+
* Definition-level and exact branch-level transition coverage.
1718+
*
1719+
* @category models
1720+
* @since 0.14.0
1721+
*/
1722+
export interface TransitionCoverage<
1723+
SourcePath extends string = string,
1724+
EventTag extends PropertyKey = PropertyKey,
1725+
TargetPath extends string = SourcePath
1726+
> {
1727+
readonly definitions: CoverageSummary<TransitionDefinitionCoverageItem<SourcePath, EventTag, TargetPath>>
1728+
readonly branches: CoverageSummary<TransitionBranchCoverageItem<SourcePath, EventTag, TargetPath>>
1729+
}
1730+
16951731
/**
16961732
* One public event tag declared by the machine.
16971733
*
@@ -1812,12 +1848,10 @@ export interface HistoryCoverageEvidence<Path extends string = string> {
18121848
*/
18131849
export interface Coverage<M extends AnyMachine> {
18141850
readonly states: StateCoverage<StatePath<M>>
1815-
readonly transitions: CoverageSummary<
1816-
TransitionCoverageItem<
1817-
StateNodePath<M>,
1818-
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
1819-
StateNodePath<M>
1820-
>
1851+
readonly transitions: TransitionCoverage<
1852+
StateNodePath<M>,
1853+
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
1854+
StateNodePath<M>
18211855
>
18221856
readonly events: EventCoverage<Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>>
18231857
readonly scenarios: ScenarioCoverage
@@ -1832,10 +1866,10 @@ export interface Coverage<M extends AnyMachine> {
18321866
}
18331867

18341868
/**
1835-
* Computes deterministic, definition-aware coverage from completed planner
1836-
* traces. Finite declared sets report hits and misses; scenarios and logical
1837-
* configurations report observations only because their complete spaces are
1838-
* generally infinite.
1869+
* Computes deterministic, definition- and branch-aware coverage from
1870+
* completed planner traces. Finite declared sets report hits and misses;
1871+
* scenarios and logical configurations report observations only because their
1872+
* complete spaces are generally infinite.
18391873
*
18401874
* **Example**
18411875
*
@@ -1979,7 +2013,7 @@ export type VerificationLawGroup =
19792013
| "microsteps"
19802014
| "completion"
19812015
| "history"
1982-
| "targetBounds"
2016+
| "definitions"
19832017

19842018
/**
19852019
* Stable identifiers for individual planner laws.
@@ -2012,8 +2046,10 @@ export type VerificationLaw =
20122046
| "history.value"
20132047
| "history.shallow"
20142048
| "history.deep"
2015-
| "targetBounds.definition"
2016-
| "targetBounds.target"
2049+
| "definitions.initial"
2050+
| "definitions.transition"
2051+
| "definitions.branchIndex"
2052+
| "definitions.selection"
20172053

20182054
/**
20192055
* One independently observed violation in a planner trace.
@@ -2050,7 +2086,9 @@ export interface VerifyOptions {
20502086

20512087
/**
20522088
* Verifies an executed trace using only public machine inspection and raw
2053-
* snapshot data. The verifier deliberately does not reuse planner
2089+
* snapshot data. Retained transitions are checked against their exact static
2090+
* branch and target selection, and startup is checked against the declared
2091+
* initial root. The verifier deliberately does not reuse planner
20542092
* normalization, encoding, finality, or other internal helpers.
20552093
*
20562094
* Every selected law is evaluated and returned in one structured error so a

0 commit comments

Comments
 (0)