@typeonce/effect-machine 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +8 -8
- package/src/Machine.ts +6873 -0
- package/src/index.ts +1 -0
- package/src/internal/machine/activities.ts +108 -0
- package/src/internal/machine/atom.ts +636 -0
- package/src/internal/machine/cluster.ts +394 -0
- package/src/internal/machine/command.ts +58 -0
- package/src/internal/machine/commandRuntime.ts +43 -0
- package/src/internal/machine/configuration.ts +1331 -0
- package/src/internal/machine/errors.ts +87 -0
- package/src/internal/machine/executionPlan.ts +996 -0
- package/src/internal/machine/invocation.ts +119 -0
- package/src/internal/machine/machine.ts +1747 -0
- package/src/internal/machine/planner.ts +1933 -0
- package/src/internal/machine/process.ts +906 -0
- package/src/internal/machine/protocol.ts +322 -0
- package/src/internal/machine/readiness.ts +10 -0
- package/src/internal/machine/runtime.ts +2512 -0
- package/src/internal/machine/serialization.ts +498 -0
- package/src/internal/machine/stateDefinition.ts +270 -0
- package/src/internal/machine/symbols.ts +2 -0
- package/src/internal/machine/topology.ts +479 -0
- package/src/internal/testing/machine/arbitrary.ts +102 -0
- package/src/internal/testing/machine/exploration.ts +331 -0
- package/src/internal/testing/machine/finiteModel.ts +1498 -0
- package/src/internal/testing/machine/invariant.ts +372 -0
- package/src/internal/testing/machine/probe.ts +79 -0
- package/src/internal/testing/machine/referenceModel.ts +1505 -0
- package/src/internal/testing/machine/runtime.ts +1710 -0
- package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
- package/src/internal/testing/machine/trace.ts +150 -0
- package/src/internal/testing/machine/verification.ts +1890 -0
- package/src/testing/MachineTest.ts +2067 -0
- package/src/testing/index.ts +7 -0
- package/src/unstable/cluster/ClusterMachine.ts +390 -0
- package/src/unstable/cluster/index.ts +1 -0
- package/src/unstable/reactivity/AtomMachine.ts +649 -0
- package/src/unstable/reactivity/index.ts +1 -0
|
@@ -0,0 +1,1890 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-based scenario generation and planner trace utilities.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.4.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Data from "effect/Data"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Graph from "effect/Graph"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import * as SchemaAST from "effect/SchemaAST"
|
|
12
|
+
import { FastCheck } from "effect/testing"
|
|
13
|
+
import * as Machine from "../../../Machine.js"
|
|
14
|
+
import type {
|
|
15
|
+
Coverage,
|
|
16
|
+
CoverageSummary,
|
|
17
|
+
EventCoverageItem,
|
|
18
|
+
InitialTrace,
|
|
19
|
+
Microstep,
|
|
20
|
+
ObservedGraph,
|
|
21
|
+
ObservedGraphEdge,
|
|
22
|
+
ObservedGraphNode,
|
|
23
|
+
PlanCompletion,
|
|
24
|
+
RunFailure,
|
|
25
|
+
Scenario,
|
|
26
|
+
ScenarioOptions,
|
|
27
|
+
Scenarios,
|
|
28
|
+
SchemaArbitraryDiagnostic,
|
|
29
|
+
StateCoverageItem,
|
|
30
|
+
Trace,
|
|
31
|
+
TraceStep,
|
|
32
|
+
TransitionCoverageItem,
|
|
33
|
+
VerificationLaw,
|
|
34
|
+
VerificationLawGroup,
|
|
35
|
+
VerificationViolation,
|
|
36
|
+
VerifyOptions
|
|
37
|
+
} from "../../../testing/MachineTest.js"
|
|
38
|
+
import { toArbitraryWithReport } from "./arbitrary.js"
|
|
39
|
+
import type { FiniteModel } from "./finiteModel.js"
|
|
40
|
+
import * as ReferenceModel from "./referenceModel.js"
|
|
41
|
+
import { rawConfigurationPaths, run } from "./trace.js"
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
advanceCommand,
|
|
45
|
+
type CausalRuntimeAssertionContext,
|
|
46
|
+
type CausalRuntimeCommandActual,
|
|
47
|
+
CausalRuntimeCommandFailure,
|
|
48
|
+
type CausalRuntimeCommandRecord,
|
|
49
|
+
type CausalRuntimeCommandResult,
|
|
50
|
+
type CausalRuntimeInspectionContext,
|
|
51
|
+
type CausalRuntimeModelOptions,
|
|
52
|
+
type CausalRuntimeModelStep,
|
|
53
|
+
type CausalRuntimeTranscript,
|
|
54
|
+
type CausalVerificationAwaitContext,
|
|
55
|
+
type CausalVerificationOptions,
|
|
56
|
+
type CausalVerificationTranscript,
|
|
57
|
+
checkpointCommand,
|
|
58
|
+
type EnqueuedRuntimeAssertionContext,
|
|
59
|
+
type EnqueuedRuntimeCommandActual,
|
|
60
|
+
type EnqueuedRuntimeCommandRecord,
|
|
61
|
+
type EnqueuedRuntimeInspectionContext,
|
|
62
|
+
type EnqueuedRuntimeModelOptions,
|
|
63
|
+
type EnqueuedRuntimeModelStep,
|
|
64
|
+
type EnqueuedRuntimeTranscript,
|
|
65
|
+
formatCausalTranscript,
|
|
66
|
+
formatEnqueuedTranscript,
|
|
67
|
+
formatRuntimeTranscript,
|
|
68
|
+
runCausalCommands,
|
|
69
|
+
runEnqueuedCommands,
|
|
70
|
+
runRuntimeCommands,
|
|
71
|
+
type RuntimeAssertionContext,
|
|
72
|
+
type RuntimeAwait,
|
|
73
|
+
type RuntimeCommand,
|
|
74
|
+
type RuntimeCommandActual,
|
|
75
|
+
RuntimeCommandFailure,
|
|
76
|
+
type RuntimeCommandRecord,
|
|
77
|
+
type RuntimeCommandResult,
|
|
78
|
+
type RuntimeCommands,
|
|
79
|
+
runtimeCommands,
|
|
80
|
+
type RuntimeCommandsDiagnostics,
|
|
81
|
+
type RuntimeCommandsOptions,
|
|
82
|
+
type RuntimeInspectionContext,
|
|
83
|
+
type RuntimeModelOptions,
|
|
84
|
+
type RuntimeModelStep,
|
|
85
|
+
RuntimeObservationError,
|
|
86
|
+
RuntimeSynchronization,
|
|
87
|
+
type RuntimeTranscript,
|
|
88
|
+
sendCommand,
|
|
89
|
+
stopCommand,
|
|
90
|
+
verifyCausalCommands
|
|
91
|
+
} from "./runtime.js"
|
|
92
|
+
|
|
93
|
+
export type { SchemaArbitraryOpaqueFilterWarning, SchemaArbitraryReport, SchemaArbitraryWarning } from "./arbitrary.js"
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
compileModel,
|
|
97
|
+
type FiniteAtomicState,
|
|
98
|
+
type FiniteAutomaticTransition,
|
|
99
|
+
type FiniteCompoundState,
|
|
100
|
+
type FiniteEventTransition,
|
|
101
|
+
type FiniteFinalState,
|
|
102
|
+
type FiniteHistoryMutation,
|
|
103
|
+
type FiniteHistoryScenario,
|
|
104
|
+
type FiniteHistoryState,
|
|
105
|
+
type FiniteHistoryTransfer,
|
|
106
|
+
type FiniteModel,
|
|
107
|
+
type FiniteModelDiagnostics,
|
|
108
|
+
type FiniteModelOptions,
|
|
109
|
+
type FiniteModels,
|
|
110
|
+
finiteModels,
|
|
111
|
+
type FiniteParallelState,
|
|
112
|
+
type FiniteState,
|
|
113
|
+
type FiniteTransition,
|
|
114
|
+
type FiniteTransitionTrigger
|
|
115
|
+
} from "./finiteModel.js"
|
|
116
|
+
|
|
117
|
+
export {
|
|
118
|
+
ModelVerificationError,
|
|
119
|
+
type ModelVerificationField,
|
|
120
|
+
type ModelVerificationLocation,
|
|
121
|
+
type ModelVerificationMismatch,
|
|
122
|
+
type ReferenceCompletion,
|
|
123
|
+
type ReferenceHistoryRecord,
|
|
124
|
+
type ReferenceInitialStep,
|
|
125
|
+
type ReferenceMicrostep,
|
|
126
|
+
type ReferenceState,
|
|
127
|
+
type ReferenceStateValue,
|
|
128
|
+
type ReferenceStep,
|
|
129
|
+
type ReferenceTrace,
|
|
130
|
+
type ReferenceTransition
|
|
131
|
+
} from "./referenceModel.js"
|
|
132
|
+
|
|
133
|
+
export { assertInvariants, checkInvariants, Invariant, InvariantError, invariants } from "./invariant.js"
|
|
134
|
+
|
|
135
|
+
export {
|
|
136
|
+
assertPlannerRuntimeAgreement,
|
|
137
|
+
assertRuntimeInvariants,
|
|
138
|
+
checkRuntimeInvariants,
|
|
139
|
+
PlannerRuntimeAgreementError,
|
|
140
|
+
RuntimeInvariantError,
|
|
141
|
+
runtimeInvariants
|
|
142
|
+
} from "./runtimeInvariant.js"
|
|
143
|
+
|
|
144
|
+
export { assertReachable, assertUnreachable, explore, findShortest, ReachabilityError } from "./exploration.js"
|
|
145
|
+
|
|
146
|
+
export { probe, ProbeUnavailableError } from "./probe.js"
|
|
147
|
+
|
|
148
|
+
export { run }
|
|
149
|
+
|
|
150
|
+
export const interpretModel = ReferenceModel.interpretModel
|
|
151
|
+
|
|
152
|
+
type AnyMachine = Machine.Machine.Any
|
|
153
|
+
|
|
154
|
+
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
|
|
155
|
+
|
|
156
|
+
type StatePath<M extends AnyMachine> = Machine.Machine.StateIdentifier<Machine.Machine.States<M>>
|
|
157
|
+
|
|
158
|
+
type StateNodePath<M extends AnyMachine> = Machine.Machine.StateNodeIdentifier<Machine.Machine.States<M>>
|
|
159
|
+
|
|
160
|
+
const validateLength = (name: "minEvents" | "maxEvents", value: number): void => {
|
|
161
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
162
|
+
throw new Error(`MachineTest.scenarios expected ${name} to be a non-negative safe integer`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const scenarios = <M extends AnyMachine>(
|
|
167
|
+
machine: M,
|
|
168
|
+
options: ScenarioOptions<M> = {} as ScenarioOptions<M>
|
|
169
|
+
): Scenarios<M> => {
|
|
170
|
+
const minEvents = options.minEvents ?? 0
|
|
171
|
+
const maxEvents = options.maxEvents ?? 50
|
|
172
|
+
if (options.eventsArbitrary === undefined) {
|
|
173
|
+
validateLength("minEvents", minEvents)
|
|
174
|
+
validateLength("maxEvents", maxEvents)
|
|
175
|
+
if (minEvents > maxEvents) {
|
|
176
|
+
throw new Error("MachineTest.scenarios expected minEvents to be less than or equal to maxEvents")
|
|
177
|
+
}
|
|
178
|
+
if (machine.events.length === 0 && minEvents > 0) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"MachineTest.scenarios cannot generate a non-empty event sequence for a machine without public events"
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const diagnostics: Array<SchemaArbitraryDiagnostic> = []
|
|
186
|
+
const eventArbitraries = options.eventsArbitrary === undefined
|
|
187
|
+
? machine.events.map((schema, index) => {
|
|
188
|
+
const derived = toArbitraryWithReport(schema)
|
|
189
|
+
diagnostics.push({
|
|
190
|
+
boundary: "event",
|
|
191
|
+
index,
|
|
192
|
+
report: derived.report
|
|
193
|
+
})
|
|
194
|
+
return derived.value as FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>
|
|
195
|
+
})
|
|
196
|
+
: []
|
|
197
|
+
|
|
198
|
+
const eventsArbitrary = options.eventsArbitrary ?? (eventArbitraries.length === 0
|
|
199
|
+
? FastCheck.constant<ReadonlyArray<Machine.Machine.InputEvent<M>>>([])
|
|
200
|
+
: FastCheck.array(
|
|
201
|
+
FastCheck.oneof(
|
|
202
|
+
...eventArbitraries as [
|
|
203
|
+
FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>,
|
|
204
|
+
...Array<FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>>
|
|
205
|
+
]
|
|
206
|
+
),
|
|
207
|
+
{ minLength: minEvents, maxLength: maxEvents }
|
|
208
|
+
))
|
|
209
|
+
|
|
210
|
+
if (machine.input === undefined || machine.input === Schema.Void) {
|
|
211
|
+
if (options.inputArbitrary !== undefined) {
|
|
212
|
+
throw new Error("MachineTest.scenarios cannot override input for a machine without an input schema")
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
arbitrary: eventsArbitrary.map((events) => ({ events }) as Scenario<M>),
|
|
216
|
+
diagnostics: {
|
|
217
|
+
input: "none",
|
|
218
|
+
events: options.eventsArbitrary !== undefined ? "override" : eventArbitraries.length === 0 ? "empty" : "schema",
|
|
219
|
+
schemas: diagnostics
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let inputArbitrary: FastCheck.Arbitrary<InputValue<M>>
|
|
225
|
+
if (options.inputArbitrary !== undefined) {
|
|
226
|
+
inputArbitrary = options.inputArbitrary
|
|
227
|
+
} else {
|
|
228
|
+
const derived = toArbitraryWithReport(machine.input)
|
|
229
|
+
diagnostics.unshift({
|
|
230
|
+
boundary: "input",
|
|
231
|
+
index: undefined,
|
|
232
|
+
report: derived.report
|
|
233
|
+
})
|
|
234
|
+
inputArbitrary = derived.value as FastCheck.Arbitrary<InputValue<M>>
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
arbitrary: FastCheck.tuple(inputArbitrary, eventsArbitrary).map(([input, events]) =>
|
|
239
|
+
({
|
|
240
|
+
input,
|
|
241
|
+
events
|
|
242
|
+
}) as Scenario<M>
|
|
243
|
+
),
|
|
244
|
+
diagnostics: {
|
|
245
|
+
input: options.inputArbitrary !== undefined ? "override" : "schema",
|
|
246
|
+
events: options.eventsArbitrary !== undefined ? "override" : eventArbitraries.length === 0 ? "empty" : "schema",
|
|
247
|
+
schemas: diagnostics
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const verifyModel = <M extends AnyMachine>(
|
|
253
|
+
model: FiniteModel,
|
|
254
|
+
actualTrace: Trace<M>
|
|
255
|
+
): Effect.Effect<void, ReferenceModel.ModelVerificationError> => ReferenceModel.verifyModelTrace(model, actualTrace)
|
|
256
|
+
|
|
257
|
+
const canonicalize = (value: unknown, active: WeakSet<object>): unknown => {
|
|
258
|
+
if (value === undefined) return { $undefined: true }
|
|
259
|
+
if (typeof value === "bigint") return { $bigint: String(value) }
|
|
260
|
+
if (typeof value === "symbol") return { $symbol: String(value) }
|
|
261
|
+
if (typeof value === "function") return { $function: value.name || "anonymous" }
|
|
262
|
+
if (typeof value !== "object" || value === null) return value
|
|
263
|
+
if (active.has(value)) return { $circular: true }
|
|
264
|
+
active.add(value)
|
|
265
|
+
let result: unknown
|
|
266
|
+
if (value instanceof Error) {
|
|
267
|
+
result = {
|
|
268
|
+
$error: value.name,
|
|
269
|
+
message: value.message,
|
|
270
|
+
...Object.fromEntries(
|
|
271
|
+
Object.keys(value).sort().map((
|
|
272
|
+
key
|
|
273
|
+
) => [key, canonicalize((value as unknown as Record<string, unknown>)[key], active)])
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
} else if (Array.isArray(value)) {
|
|
277
|
+
result = value.map((item) => canonicalize(item, active))
|
|
278
|
+
} else if (value instanceof Date) {
|
|
279
|
+
result = { $date: value.toISOString() }
|
|
280
|
+
} else if (value instanceof Map) {
|
|
281
|
+
result = {
|
|
282
|
+
$map: Array.from(value, ([key, item]) => [canonicalize(key, active), canonicalize(item, active)]).sort((a, b) =>
|
|
283
|
+
JSON.stringify(a).localeCompare(JSON.stringify(b))
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
} else if (value instanceof Set) {
|
|
287
|
+
result = {
|
|
288
|
+
$set: Array.from(value, (item) => canonicalize(item, active)).sort((a, b) =>
|
|
289
|
+
JSON.stringify(a).localeCompare(JSON.stringify(b))
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
result = Object.fromEntries(
|
|
294
|
+
Object.keys(value).sort().map((key) => [key, canonicalize((value as Record<string, unknown>)[key], active)])
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
active.delete(value)
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const formatValue = (value: unknown): string => JSON.stringify(canonicalize(value, new WeakSet()))
|
|
302
|
+
|
|
303
|
+
const structuralFingerprint = (value: unknown): string => {
|
|
304
|
+
const references = new WeakMap<object, number>()
|
|
305
|
+
let nextReference = 0
|
|
306
|
+
const visit = (current: unknown): unknown => {
|
|
307
|
+
if (current === undefined) return ["undefined"]
|
|
308
|
+
if (current === null) return ["null"]
|
|
309
|
+
if (typeof current === "number") {
|
|
310
|
+
if (Number.isNaN(current)) return ["number", "NaN"]
|
|
311
|
+
if (Object.is(current, -0)) return ["number", "-0"]
|
|
312
|
+
return ["number", current]
|
|
313
|
+
}
|
|
314
|
+
if (typeof current === "bigint") return ["bigint", String(current)]
|
|
315
|
+
if (typeof current === "symbol") {
|
|
316
|
+
return [
|
|
317
|
+
"symbol",
|
|
318
|
+
Symbol.keyFor(current) === undefined ? "local" : "global",
|
|
319
|
+
Symbol.keyFor(current) ?? current.description
|
|
320
|
+
]
|
|
321
|
+
}
|
|
322
|
+
if (typeof current === "function") return ["function", current.name, current.length]
|
|
323
|
+
if (typeof current !== "object") return [typeof current, current]
|
|
324
|
+
|
|
325
|
+
const existing = references.get(current)
|
|
326
|
+
if (existing !== undefined) return ["reference", existing]
|
|
327
|
+
const reference = nextReference++
|
|
328
|
+
references.set(current, reference)
|
|
329
|
+
|
|
330
|
+
if (Array.isArray(current)) return ["array", reference, current.map(visit)]
|
|
331
|
+
if (current instanceof Date) return ["date", reference, current.getTime()]
|
|
332
|
+
if (current instanceof RegExp) return ["regexp", reference, current.source, current.flags]
|
|
333
|
+
if (current instanceof ArrayBuffer) {
|
|
334
|
+
return ["array-buffer", reference, Array.from(new Uint8Array(current))]
|
|
335
|
+
}
|
|
336
|
+
if (typeof SharedArrayBuffer !== "undefined" && current instanceof SharedArrayBuffer) {
|
|
337
|
+
return ["shared-array-buffer", reference, Array.from(new Uint8Array(current))]
|
|
338
|
+
}
|
|
339
|
+
if (ArrayBuffer.isView(current)) {
|
|
340
|
+
return [
|
|
341
|
+
"array-buffer-view",
|
|
342
|
+
reference,
|
|
343
|
+
current.constructor.name,
|
|
344
|
+
current.byteOffset,
|
|
345
|
+
current.byteLength,
|
|
346
|
+
Array.from(new Uint8Array(current.buffer, current.byteOffset, current.byteLength))
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
if (current instanceof Error) {
|
|
350
|
+
return ["error", reference, current.name, current.message, visit(Object.fromEntries(Object.entries(current)))]
|
|
351
|
+
}
|
|
352
|
+
if (current instanceof Map) {
|
|
353
|
+
return ["map", reference, Array.from(current, ([key, item]) => [visit(key), visit(item)])]
|
|
354
|
+
}
|
|
355
|
+
if (current instanceof Set) return ["set", reference, Array.from(current, visit)]
|
|
356
|
+
|
|
357
|
+
const stringKeys = Object.getOwnPropertyNames(current).sort()
|
|
358
|
+
const symbolKeys = Object.getOwnPropertySymbols(current).sort((left, right) =>
|
|
359
|
+
String(Symbol.keyFor(left) ?? left.description).localeCompare(String(Symbol.keyFor(right) ?? right.description))
|
|
360
|
+
)
|
|
361
|
+
return [
|
|
362
|
+
"object",
|
|
363
|
+
reference,
|
|
364
|
+
Object.getPrototypeOf(current)?.constructor?.name ?? null,
|
|
365
|
+
stringKeys.map((key) => [key, visit((current as Record<string, unknown>)[key])]),
|
|
366
|
+
symbolKeys.map((key) => [visit(key), visit((current as Record<symbol, unknown>)[key])])
|
|
367
|
+
]
|
|
368
|
+
}
|
|
369
|
+
return JSON.stringify(visit(value))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const structurallyEqual = (left: unknown, right: unknown): boolean => {
|
|
373
|
+
const leftToRight = new WeakMap<object, object>()
|
|
374
|
+
const rightToLeft = new WeakMap<object, object>()
|
|
375
|
+
const compare = (a: unknown, b: unknown): boolean => {
|
|
376
|
+
if (Object.is(a, b)) return true
|
|
377
|
+
if (typeof a !== typeof b || a === null || b === null) return false
|
|
378
|
+
if (typeof a !== "object" || typeof b !== "object") return false
|
|
379
|
+
const knownRight = leftToRight.get(a)
|
|
380
|
+
const knownLeft = rightToLeft.get(b)
|
|
381
|
+
if (knownRight !== undefined || knownLeft !== undefined) return knownRight === b && knownLeft === a
|
|
382
|
+
leftToRight.set(a, b)
|
|
383
|
+
rightToLeft.set(b, a)
|
|
384
|
+
|
|
385
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false
|
|
386
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
|
|
387
|
+
if (a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags
|
|
388
|
+
if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) {
|
|
389
|
+
return a.byteLength === b.byteLength &&
|
|
390
|
+
new Uint8Array(a).every((byte, index) => byte === new Uint8Array(b)[index])
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
typeof SharedArrayBuffer !== "undefined" && a instanceof SharedArrayBuffer && b instanceof SharedArrayBuffer
|
|
394
|
+
) {
|
|
395
|
+
return a.byteLength === b.byteLength &&
|
|
396
|
+
new Uint8Array(a).every((byte, index) => byte === new Uint8Array(b)[index])
|
|
397
|
+
}
|
|
398
|
+
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
|
|
399
|
+
if (a.constructor !== b.constructor || a.byteOffset !== b.byteOffset || a.byteLength !== b.byteLength) {
|
|
400
|
+
return false
|
|
401
|
+
}
|
|
402
|
+
const leftBytes = new Uint8Array(a.buffer, a.byteOffset, a.byteLength)
|
|
403
|
+
const rightBytes = new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
|
|
404
|
+
return leftBytes.every((byte, index) => byte === rightBytes[index])
|
|
405
|
+
}
|
|
406
|
+
if (a instanceof Error && b instanceof Error && (a.name !== b.name || a.message !== b.message)) return false
|
|
407
|
+
if (a instanceof Map && b instanceof Map) {
|
|
408
|
+
if (a.size !== b.size) return false
|
|
409
|
+
const leftEntries = Array.from(a)
|
|
410
|
+
const rightEntries = Array.from(b)
|
|
411
|
+
return leftEntries.every(([key, item], index) =>
|
|
412
|
+
compare(key, rightEntries[index]![0]) && compare(item, rightEntries[index]![1])
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
if (a instanceof Set && b instanceof Set) {
|
|
416
|
+
if (a.size !== b.size) return false
|
|
417
|
+
const rightValues = Array.from(b)
|
|
418
|
+
return Array.from(a).every((item, index) => compare(item, rightValues[index]))
|
|
419
|
+
}
|
|
420
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
421
|
+
if (!Array.isArray(a) && Object.getPrototypeOf(a) !== Object.prototype && Object.getPrototypeOf(a) !== null) {
|
|
422
|
+
return false
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const leftKeys = Reflect.ownKeys(a).sort((first, second) => String(first).localeCompare(String(second)))
|
|
426
|
+
const rightKeys = Reflect.ownKeys(b).sort((first, second) => String(first).localeCompare(String(second)))
|
|
427
|
+
return leftKeys.length === rightKeys.length &&
|
|
428
|
+
leftKeys.every((key, index) =>
|
|
429
|
+
Object.is(key, rightKeys[index]) && compare(
|
|
430
|
+
(a as Record<PropertyKey, unknown>)[key],
|
|
431
|
+
(b as Record<PropertyKey, unknown>)[rightKeys[index]!]
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
return compare(left, right)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const makeStructuralIdentityIndex = () => {
|
|
439
|
+
const buckets = new Map<string, Array<{ readonly id: string; readonly value: unknown }>>()
|
|
440
|
+
return (value: unknown): string => {
|
|
441
|
+
const fingerprint = structuralFingerprint(value)
|
|
442
|
+
let bucket = buckets.get(fingerprint)
|
|
443
|
+
if (bucket === undefined) buckets.set(fingerprint, bucket = [])
|
|
444
|
+
const existing = bucket.find((candidate) => structurallyEqual(candidate.value, value))
|
|
445
|
+
if (existing !== undefined) return existing.id
|
|
446
|
+
const id = bucket.length === 0 ? fingerprint : `${fingerprint}#collision:${bucket.length}`
|
|
447
|
+
bucket.push({ id, value })
|
|
448
|
+
return id
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const coverageSummary = <Item>(declared: ReadonlyArray<Item>, hit: ReadonlySet<number>): CoverageSummary<Item> => {
|
|
453
|
+
const hits: Array<Item> = []
|
|
454
|
+
const misses: Array<Item> = []
|
|
455
|
+
declared.forEach((item, index) => (hit.has(index) ? hits : misses).push(item))
|
|
456
|
+
return {
|
|
457
|
+
total: declared.length,
|
|
458
|
+
hit: hits.length,
|
|
459
|
+
missing: misses.length,
|
|
460
|
+
hits,
|
|
461
|
+
misses
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const normalizeTraces = <M extends AnyMachine>(
|
|
466
|
+
traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
|
|
467
|
+
): ReadonlyArray<Trace<M>> =>
|
|
468
|
+
Array.isArray(traceOrTraces) ? traceOrTraces as ReadonlyArray<Trace<M>> : [traceOrTraces as Trace<M>]
|
|
469
|
+
|
|
470
|
+
const sameCoverageTrigger = (
|
|
471
|
+
left: Machine.Machine.TransitionTrigger,
|
|
472
|
+
right: Machine.Machine.TransitionTrigger
|
|
473
|
+
): boolean =>
|
|
474
|
+
left.type === right.type && (left.type !== "event" || right.type === "event" && left.event === right.event)
|
|
475
|
+
|
|
476
|
+
const targetWithinDeclaredBounds = (
|
|
477
|
+
target: string | undefined,
|
|
478
|
+
bounds: Machine.Machine.TransitionTargets
|
|
479
|
+
): boolean =>
|
|
480
|
+
target === undefined || bounds.type === "dynamic" ||
|
|
481
|
+
bounds.paths.some((path) => target === path || target.startsWith(`${path}.`))
|
|
482
|
+
|
|
483
|
+
const finiteTagValues = (ast: SchemaAST.AST): ReadonlyArray<PropertyKey> | undefined => {
|
|
484
|
+
if (SchemaAST.isLiteral(ast)) {
|
|
485
|
+
return typeof ast.literal === "string" || typeof ast.literal === "number" ? [ast.literal] : undefined
|
|
486
|
+
}
|
|
487
|
+
if (SchemaAST.isUniqueSymbol(ast)) return [ast.symbol]
|
|
488
|
+
if (SchemaAST.isUnion(ast)) {
|
|
489
|
+
const values: Array<PropertyKey> = []
|
|
490
|
+
for (const member of ast.types) {
|
|
491
|
+
const memberValues = finiteTagValues(member)
|
|
492
|
+
if (memberValues === undefined) return undefined
|
|
493
|
+
for (const value of memberValues) if (!values.includes(value)) values.push(value)
|
|
494
|
+
}
|
|
495
|
+
return values
|
|
496
|
+
}
|
|
497
|
+
if (SchemaAST.isObjects(ast)) {
|
|
498
|
+
const tag = ast.propertySignatures.find(({ name }) => name === "_tag")?.type
|
|
499
|
+
return tag === undefined ? undefined : finiteTagValues(tag)
|
|
500
|
+
}
|
|
501
|
+
if (SchemaAST.isDeclaration(ast)) {
|
|
502
|
+
const sentinels = ast.annotations?.["~sentinels"]
|
|
503
|
+
if (Array.isArray(sentinels)) {
|
|
504
|
+
const tag = sentinels.find((sentinel): sentinel is { readonly key: "_tag"; readonly literal: PropertyKey } =>
|
|
505
|
+
typeof sentinel === "object" && sentinel !== null && sentinel.key === "_tag" &&
|
|
506
|
+
(typeof sentinel.literal === "string" ||
|
|
507
|
+
typeof sentinel.literal === "number" ||
|
|
508
|
+
typeof sentinel.literal === "symbol")
|
|
509
|
+
)
|
|
510
|
+
if (tag !== undefined) return [tag.literal]
|
|
511
|
+
}
|
|
512
|
+
for (const parameter of ast.typeParameters) {
|
|
513
|
+
const values = finiteTagValues(parameter)
|
|
514
|
+
if (values !== undefined) return values
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (SchemaAST.isSuspend(ast)) return finiteTagValues(ast.thunk())
|
|
518
|
+
return undefined
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const publicEventTags = <M extends AnyMachine>(machine: M): {
|
|
522
|
+
readonly tags: ReadonlyArray<Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>>
|
|
523
|
+
readonly diagnostics: ReadonlyArray<{ readonly schemaIndex: number; readonly message: string }>
|
|
524
|
+
} => {
|
|
525
|
+
const tags: Array<Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>> = []
|
|
526
|
+
const diagnostics: Array<{ readonly schemaIndex: number; readonly message: string }> = []
|
|
527
|
+
machine.events.forEach((schema, schemaIndex) => {
|
|
528
|
+
const values = finiteTagValues(SchemaAST.toType(schema.ast))
|
|
529
|
+
if (values === undefined) {
|
|
530
|
+
diagnostics.push({
|
|
531
|
+
schemaIndex,
|
|
532
|
+
message: "The decoded _tag schema is not a finite literal, unique symbol, or finite union"
|
|
533
|
+
})
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
for (const value of values) {
|
|
537
|
+
const tag = value as Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>
|
|
538
|
+
if (!tags.includes(tag)) tags.push(tag)
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
return { tags, diagnostics }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export const coverage = <M extends AnyMachine>(
|
|
545
|
+
machine: M,
|
|
546
|
+
traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
|
|
547
|
+
): Coverage<M> => {
|
|
548
|
+
const traces = normalizeTraces(traceOrTraces)
|
|
549
|
+
const stateNodes = Machine.stateNodes(machine)
|
|
550
|
+
const activeNodes = stateNodes.filter((node) => node.type !== "history" && node.type !== "choice").map(
|
|
551
|
+
(node): StateCoverageItem<StatePath<M>> => ({
|
|
552
|
+
path: node.path as StatePath<M>,
|
|
553
|
+
type: node.type as StateCoverageItem["type"]
|
|
554
|
+
})
|
|
555
|
+
)
|
|
556
|
+
const activeIndex = new Map<string, number>(activeNodes.map((node, index) => [node.path, index]))
|
|
557
|
+
const activationHits = new Set<number>()
|
|
558
|
+
const entryHits = new Set<number>()
|
|
559
|
+
const exitHits = new Set<number>()
|
|
560
|
+
|
|
561
|
+
const definitions = Machine.transitionDefinitions(machine).map(
|
|
562
|
+
(
|
|
563
|
+
definition,
|
|
564
|
+
index
|
|
565
|
+
): TransitionCoverageItem<
|
|
566
|
+
StateNodePath<M>,
|
|
567
|
+
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
|
|
568
|
+
StateNodePath<M>
|
|
569
|
+
> => ({
|
|
570
|
+
id: `transition:${index}:${formatValue(definition)}`,
|
|
571
|
+
index,
|
|
572
|
+
source: definition.source,
|
|
573
|
+
trigger: definition.trigger,
|
|
574
|
+
reenter: definition.reenter,
|
|
575
|
+
targets: definition.targets
|
|
576
|
+
})
|
|
577
|
+
)
|
|
578
|
+
const transitionHits = new Set<number>()
|
|
579
|
+
|
|
580
|
+
const declaredEvents = publicEventTags(machine)
|
|
581
|
+
const declaredEventTags = declaredEvents.tags
|
|
582
|
+
const eventCounts = new Map<PropertyKey, number>(declaredEventTags.map((tag) => [tag, 0]))
|
|
583
|
+
const logicalConfigurationIdentities = new Set<string>()
|
|
584
|
+
const logicalConfigurationIdentity = makeStructuralIdentityIndex()
|
|
585
|
+
let configurationObservations = 0
|
|
586
|
+
let scenarioEvents = 0
|
|
587
|
+
let emptyScenarios = 0
|
|
588
|
+
let startupWithMicrosteps = 0
|
|
589
|
+
let microsteps = 0
|
|
590
|
+
let changedMicrosteps = 0
|
|
591
|
+
let targetlessTransitions = 0
|
|
592
|
+
let raisedEvents = 0
|
|
593
|
+
let emittedEvents = 0
|
|
594
|
+
let eventTriggered = 0
|
|
595
|
+
let alwaysTriggered = 0
|
|
596
|
+
let doneTriggered = 0
|
|
597
|
+
let choiceTriggered = 0
|
|
598
|
+
let donePlans = 0
|
|
599
|
+
let completionRecordObservations = 0
|
|
600
|
+
const completionPaths = new Set<string>()
|
|
601
|
+
let historyRecordObservations = 0
|
|
602
|
+
const historyModes = new Map<string, Set<"shallow" | "deep">>()
|
|
603
|
+
let historyTargets = 0
|
|
604
|
+
let resolvedHistoryTargets = 0
|
|
605
|
+
const nodeByPath = new Map(stateNodes.map((node) => [node.path, node]))
|
|
606
|
+
|
|
607
|
+
const hitPaths = (paths: ReadonlyArray<string>, hits: Set<number>): void => {
|
|
608
|
+
for (const path of paths) {
|
|
609
|
+
const index = activeIndex.get(path)
|
|
610
|
+
if (index !== undefined) hits.add(index)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const observeSnapshot = (snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>): void => {
|
|
615
|
+
const paths = rawConfigurationPaths(machine, snapshot) as ReadonlyArray<string>
|
|
616
|
+
hitPaths(paths, activationHits)
|
|
617
|
+
configurationObservations += 1
|
|
618
|
+
logicalConfigurationIdentities.add(logicalConfigurationIdentity(snapshot))
|
|
619
|
+
const metadata = snapshot as unknown as {
|
|
620
|
+
readonly completed?: ReadonlyArray<{ readonly path: string }>
|
|
621
|
+
readonly history?: Readonly<Record<string, { readonly mode: "shallow" | "deep" }>>
|
|
622
|
+
}
|
|
623
|
+
for (const completion of metadata.completed ?? []) {
|
|
624
|
+
completionRecordObservations += 1
|
|
625
|
+
completionPaths.add(completion.path)
|
|
626
|
+
}
|
|
627
|
+
for (const [path, record] of Object.entries(metadata.history ?? {})) {
|
|
628
|
+
historyRecordObservations += 1
|
|
629
|
+
let modes = historyModes.get(path)
|
|
630
|
+
if (modes === undefined) historyModes.set(path, modes = new Set())
|
|
631
|
+
modes.add(record.mode)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const observeMicrostep = (microstep: Microstep<M, any>): void => {
|
|
636
|
+
microsteps += 1
|
|
637
|
+
if (microstep.changed) changedMicrosteps += 1
|
|
638
|
+
raisedEvents += microstep.raisedEvents.length
|
|
639
|
+
emittedEvents += microstep.emittedEvents.length
|
|
640
|
+
hitPaths(microstep.entryPaths, entryHits)
|
|
641
|
+
hitPaths(microstep.exitPaths, exitHits)
|
|
642
|
+
observeSnapshot(microstep.next)
|
|
643
|
+
for (const retained of microstep.transitions) {
|
|
644
|
+
if (retained.target === undefined) targetlessTransitions += 1
|
|
645
|
+
if (retained.trigger.type === "event") eventTriggered += 1
|
|
646
|
+
else if (retained.trigger.type === "always") alwaysTriggered += 1
|
|
647
|
+
else if (retained.trigger.type === "done") doneTriggered += 1
|
|
648
|
+
else choiceTriggered += 1
|
|
649
|
+
if (retained.target !== undefined && nodeByPath.get(retained.target)?.type === "history") {
|
|
650
|
+
historyTargets += 1
|
|
651
|
+
if (retained.resolvedTarget !== undefined) resolvedHistoryTargets += 1
|
|
652
|
+
}
|
|
653
|
+
const definitionIndex = definitions.findIndex((definition) =>
|
|
654
|
+
definition.source === retained.source &&
|
|
655
|
+
definition.reenter === retained.reenter &&
|
|
656
|
+
sameCoverageTrigger(definition.trigger, retained.trigger) &&
|
|
657
|
+
targetWithinDeclaredBounds(retained.target, definition.targets)
|
|
658
|
+
)
|
|
659
|
+
if (definitionIndex !== -1) transitionHits.add(definitionIndex)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
for (const trace of traces) {
|
|
664
|
+
scenarioEvents += trace.scenario.events.length
|
|
665
|
+
if (trace.scenario.events.length === 0) emptyScenarios += 1
|
|
666
|
+
for (const event of trace.scenario.events) {
|
|
667
|
+
const tag = event._tag
|
|
668
|
+
eventCounts.set(tag, (eventCounts.get(tag) ?? 0) + 1)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
observeSnapshot(trace.initial.startingState)
|
|
672
|
+
hitPaths(trace.initial.initialEntryPaths, entryHits)
|
|
673
|
+
if (trace.initial.plan.microsteps.length > 0) startupWithMicrosteps += 1
|
|
674
|
+
for (const microstep of trace.initial.plan.microsteps) observeMicrostep(microstep)
|
|
675
|
+
observeSnapshot(trace.initial.plan.state)
|
|
676
|
+
if (trace.initial.plan.done) donePlans += 1
|
|
677
|
+
|
|
678
|
+
for (const step of trace.steps) {
|
|
679
|
+
observeSnapshot(step.before)
|
|
680
|
+
for (const microstep of step.plan.microsteps) observeMicrostep(microstep)
|
|
681
|
+
observeSnapshot(step.after)
|
|
682
|
+
if (step.plan.done) donePlans += 1
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const eventItems = declaredEventTags.map((tag): EventCoverageItem<any> => ({
|
|
687
|
+
tag,
|
|
688
|
+
count: eventCounts.get(tag) ?? 0
|
|
689
|
+
}))
|
|
690
|
+
const eventHits = eventItems.filter(({ count }) => count > 0)
|
|
691
|
+
const eventMisses = eventItems.filter(({ count }) => count === 0)
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
states: {
|
|
695
|
+
activation: coverageSummary(activeNodes, activationHits),
|
|
696
|
+
entry: coverageSummary(activeNodes, entryHits),
|
|
697
|
+
exit: coverageSummary(activeNodes, exitHits)
|
|
698
|
+
},
|
|
699
|
+
transitions: coverageSummary(definitions, transitionHits),
|
|
700
|
+
events: declaredEvents.diagnostics.length === 0
|
|
701
|
+
? {
|
|
702
|
+
available: true,
|
|
703
|
+
total: eventItems.length,
|
|
704
|
+
hit: eventHits.length,
|
|
705
|
+
missing: eventMisses.length,
|
|
706
|
+
hits: eventHits,
|
|
707
|
+
misses: eventMisses,
|
|
708
|
+
observed: eventItems,
|
|
709
|
+
diagnostics: []
|
|
710
|
+
}
|
|
711
|
+
: {
|
|
712
|
+
available: false,
|
|
713
|
+
total: undefined,
|
|
714
|
+
hit: undefined,
|
|
715
|
+
missing: undefined,
|
|
716
|
+
hits: undefined,
|
|
717
|
+
misses: undefined,
|
|
718
|
+
observed: Array.from(eventCounts, ([tag, count]) => ({ tag, count })) as ReadonlyArray<EventCoverageItem<any>>,
|
|
719
|
+
diagnostics: declaredEvents.diagnostics
|
|
720
|
+
},
|
|
721
|
+
scenarios: {
|
|
722
|
+
traces: traces.length,
|
|
723
|
+
events: scenarioEvents,
|
|
724
|
+
empty: emptyScenarios
|
|
725
|
+
},
|
|
726
|
+
logicalConfigurations: {
|
|
727
|
+
observations: configurationObservations,
|
|
728
|
+
hit: logicalConfigurationIdentities.size,
|
|
729
|
+
identities: Array.from(logicalConfigurationIdentities).sort()
|
|
730
|
+
},
|
|
731
|
+
startup: {
|
|
732
|
+
traces: traces.length,
|
|
733
|
+
withMicrosteps: startupWithMicrosteps
|
|
734
|
+
},
|
|
735
|
+
microsteps: {
|
|
736
|
+
total: microsteps,
|
|
737
|
+
changed: changedMicrosteps,
|
|
738
|
+
targetless: targetlessTransitions,
|
|
739
|
+
raised: raisedEvents,
|
|
740
|
+
emitted: emittedEvents,
|
|
741
|
+
eventTriggered,
|
|
742
|
+
alwaysTriggered,
|
|
743
|
+
doneTriggered,
|
|
744
|
+
choiceTriggered
|
|
745
|
+
},
|
|
746
|
+
completion: {
|
|
747
|
+
donePlans,
|
|
748
|
+
recordObservations: completionRecordObservations,
|
|
749
|
+
paths: Array.from(completionPaths).sort() as unknown as ReadonlyArray<StatePath<M>>
|
|
750
|
+
},
|
|
751
|
+
history: {
|
|
752
|
+
recordObservations: historyRecordObservations,
|
|
753
|
+
recorded: Array.from(historyModes, ([path, modes]) => ({
|
|
754
|
+
path: path as StateNodePath<M>,
|
|
755
|
+
modes: Array.from(modes).sort()
|
|
756
|
+
})).sort((left, right) => left.path.localeCompare(right.path)),
|
|
757
|
+
targets: historyTargets,
|
|
758
|
+
resolvedTargets: resolvedHistoryTargets
|
|
759
|
+
}
|
|
760
|
+
} as Coverage<M>
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
type SnapshotObservationRole = "startup" | "event" | "microstep"
|
|
764
|
+
|
|
765
|
+
interface SnapshotOccurrence<M extends AnyMachine> {
|
|
766
|
+
readonly snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
|
|
767
|
+
readonly role: SnapshotObservationRole
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
interface GraphMicrostepDraft<M extends AnyMachine> {
|
|
771
|
+
readonly nextOccurrence: number
|
|
772
|
+
readonly microstep: Microstep<M, any>
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
type GraphEdgeDraftData<M extends AnyMachine> = ObservedGraphEdge<M> extends infer Edge
|
|
776
|
+
? Edge extends ObservedGraphEdge<M> ? Omit<Edge, "microsteps"> & {
|
|
777
|
+
readonly microsteps: ReadonlyArray<GraphMicrostepDraft<M>>
|
|
778
|
+
}
|
|
779
|
+
: never
|
|
780
|
+
: never
|
|
781
|
+
|
|
782
|
+
interface GraphEdgeDraft<M extends AnyMachine> {
|
|
783
|
+
readonly sourceOccurrence: number
|
|
784
|
+
readonly targetOccurrence: number
|
|
785
|
+
readonly edge: GraphEdgeDraftData<M>
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export const observedGraph: <M extends AnyMachine>(
|
|
789
|
+
machine: M,
|
|
790
|
+
traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
|
|
791
|
+
) => Effect.Effect<
|
|
792
|
+
ObservedGraph<M>,
|
|
793
|
+
Machine.MachineSchemaEncodeError,
|
|
794
|
+
Machine.Machine.SnapshotEncodingServices<Machine.Machine.States<M>>
|
|
795
|
+
> = Effect.fnUntraced(function*<M extends AnyMachine>(
|
|
796
|
+
machine: M,
|
|
797
|
+
traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
|
|
798
|
+
) {
|
|
799
|
+
const traces = normalizeTraces(traceOrTraces)
|
|
800
|
+
const occurrences: Array<SnapshotOccurrence<M>> = []
|
|
801
|
+
const edgeDrafts: Array<GraphEdgeDraft<M>> = []
|
|
802
|
+
const startOccurrences: Array<number> = []
|
|
803
|
+
const startupSourceOccurrences: Array<number> = []
|
|
804
|
+
const addOccurrence = (
|
|
805
|
+
snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>,
|
|
806
|
+
role: SnapshotObservationRole
|
|
807
|
+
): number => {
|
|
808
|
+
const index = occurrences.length
|
|
809
|
+
occurrences.push({ snapshot, role })
|
|
810
|
+
return index
|
|
811
|
+
}
|
|
812
|
+
const addMicrosteps = (microsteps: ReadonlyArray<Microstep<M, any>>): ReadonlyArray<GraphMicrostepDraft<M>> =>
|
|
813
|
+
microsteps.map((microstep) => ({
|
|
814
|
+
nextOccurrence: addOccurrence(microstep.next, "microstep"),
|
|
815
|
+
microstep
|
|
816
|
+
}))
|
|
817
|
+
|
|
818
|
+
traces.forEach((trace, traceIndex) => {
|
|
819
|
+
const start = addOccurrence(trace.initial.startingState, "startup")
|
|
820
|
+
startupSourceOccurrences.push(start)
|
|
821
|
+
const startupMicrosteps = addMicrosteps(trace.initial.plan.microsteps)
|
|
822
|
+
const initialized = addOccurrence(trace.initial.plan.state, "startup")
|
|
823
|
+
startOccurrences.push(initialized)
|
|
824
|
+
edgeDrafts.push({
|
|
825
|
+
sourceOccurrence: start,
|
|
826
|
+
targetOccurrence: initialized,
|
|
827
|
+
edge: {
|
|
828
|
+
_tag: "Startup",
|
|
829
|
+
traceIndex,
|
|
830
|
+
microsteps: startupMicrosteps,
|
|
831
|
+
completion: trace.initial.plan.done
|
|
832
|
+
? { done: true, output: trace.initial.plan.output }
|
|
833
|
+
: { done: false, output: undefined }
|
|
834
|
+
}
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
for (const step of trace.steps) {
|
|
838
|
+
const before = addOccurrence(step.before, "event")
|
|
839
|
+
const stepMicrosteps = addMicrosteps(step.plan.microsteps)
|
|
840
|
+
const after = addOccurrence(step.after, "event")
|
|
841
|
+
edgeDrafts.push({
|
|
842
|
+
sourceOccurrence: before,
|
|
843
|
+
targetOccurrence: after,
|
|
844
|
+
edge: {
|
|
845
|
+
_tag: "Event",
|
|
846
|
+
traceIndex,
|
|
847
|
+
eventIndex: step.index,
|
|
848
|
+
event: step.event,
|
|
849
|
+
microsteps: stepMicrosteps,
|
|
850
|
+
completion: step.plan.done
|
|
851
|
+
? { done: true, output: step.plan.output }
|
|
852
|
+
: { done: false, output: undefined }
|
|
853
|
+
}
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
const encoded = yield* Effect.forEach(
|
|
859
|
+
occurrences,
|
|
860
|
+
({ snapshot }) =>
|
|
861
|
+
(Machine.encodeSnapshot as any)(machine, snapshot) as Effect.Effect<
|
|
862
|
+
Machine.Machine.EncodedSnapshot,
|
|
863
|
+
Machine.MachineSchemaEncodeError,
|
|
864
|
+
Machine.Machine.SnapshotEncodingServices<Machine.Machine.States<M>>
|
|
865
|
+
>
|
|
866
|
+
)
|
|
867
|
+
const encodedIdentity = makeStructuralIdentityIndex()
|
|
868
|
+
const occurrenceIds = encoded.map(encodedIdentity)
|
|
869
|
+
const grouped = new Map<string, {
|
|
870
|
+
readonly encoded: Machine.Machine.EncodedSnapshot
|
|
871
|
+
readonly snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
|
|
872
|
+
startup: number
|
|
873
|
+
event: number
|
|
874
|
+
microstep: number
|
|
875
|
+
}>()
|
|
876
|
+
occurrences.forEach((occurrence, index) => {
|
|
877
|
+
const id = occurrenceIds[index]!
|
|
878
|
+
let group = grouped.get(id)
|
|
879
|
+
if (group === undefined) {
|
|
880
|
+
grouped.set(
|
|
881
|
+
id,
|
|
882
|
+
group = {
|
|
883
|
+
encoded: encoded[index]!,
|
|
884
|
+
snapshot: occurrence.snapshot,
|
|
885
|
+
startup: 0,
|
|
886
|
+
event: 0,
|
|
887
|
+
microstep: 0
|
|
888
|
+
}
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
group[occurrence.role] += 1
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
const nodesById = new Map<string, Graph.NodeIndex>()
|
|
895
|
+
const graph = Graph.directed<ObservedGraphNode<M>, ObservedGraphEdge<M>>((mutable) => {
|
|
896
|
+
for (const [id, group] of grouped) {
|
|
897
|
+
const node = Graph.addNode(mutable, {
|
|
898
|
+
id,
|
|
899
|
+
snapshot: group.snapshot,
|
|
900
|
+
encoded: group.encoded,
|
|
901
|
+
configuration: group.encoded.active.map(({ path }) => path) as unknown as ReadonlyArray<StatePath<M>>,
|
|
902
|
+
observations: {
|
|
903
|
+
total: group.startup + group.event + group.microstep,
|
|
904
|
+
startup: group.startup,
|
|
905
|
+
event: group.event,
|
|
906
|
+
microstep: group.microstep
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
nodesById.set(id, node)
|
|
910
|
+
}
|
|
911
|
+
for (const draft of edgeDrafts) {
|
|
912
|
+
const source = nodesById.get(occurrenceIds[draft.sourceOccurrence]!)!
|
|
913
|
+
const target = nodesById.get(occurrenceIds[draft.targetOccurrence]!)!
|
|
914
|
+
Graph.addEdge(mutable, source, target, {
|
|
915
|
+
...draft.edge,
|
|
916
|
+
microsteps: draft.edge.microsteps.map(({ microstep, nextOccurrence }) => ({
|
|
917
|
+
next: occurrenceIds[nextOccurrence]!,
|
|
918
|
+
event: microstep.event,
|
|
919
|
+
transitions: microstep.transitions,
|
|
920
|
+
raisedEvents: microstep.raisedEvents,
|
|
921
|
+
emittedEvents: microstep.emittedEvents,
|
|
922
|
+
exitPaths: microstep.exitPaths as ReadonlyArray<StatePath<M>>,
|
|
923
|
+
entryPaths: microstep.entryPaths as ReadonlyArray<StatePath<M>>,
|
|
924
|
+
changed: microstep.changed
|
|
925
|
+
}))
|
|
926
|
+
} as ObservedGraphEdge<M>)
|
|
927
|
+
}
|
|
928
|
+
})
|
|
929
|
+
return {
|
|
930
|
+
graph,
|
|
931
|
+
nodesById,
|
|
932
|
+
starts: Array.from(new Set(startOccurrences.map((index) => nodesById.get(occurrenceIds[index]!)!))),
|
|
933
|
+
startupSources: Array.from(
|
|
934
|
+
new Set(startupSourceOccurrences.map((index) => nodesById.get(occurrenceIds[index]!)!))
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
}) as any
|
|
938
|
+
|
|
939
|
+
export class VerificationError extends Data.TaggedError("MachineTestVerificationError")<{
|
|
940
|
+
readonly violations: ReadonlyArray<VerificationViolation>
|
|
941
|
+
}> {}
|
|
942
|
+
|
|
943
|
+
interface VerificationLocation {
|
|
944
|
+
readonly eventIndex: number | undefined
|
|
945
|
+
readonly microstepIndex?: number
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
interface SnapshotInspection {
|
|
949
|
+
readonly active: ReadonlySet<string>
|
|
950
|
+
readonly paths: ReadonlyArray<string>
|
|
951
|
+
readonly values: ReadonlyMap<string, unknown>
|
|
952
|
+
readonly root: Record<string, unknown> | undefined
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
type PublicStateNode = Machine.Machine.StateNode<string>
|
|
956
|
+
|
|
957
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
958
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
959
|
+
|
|
960
|
+
const hasOwn = (value: object, key: PropertyKey): boolean => Object.prototype.hasOwnProperty.call(value, key)
|
|
961
|
+
|
|
962
|
+
const sameValue = (left: unknown, right: unknown): boolean => formatValue(left) === formatValue(right)
|
|
963
|
+
|
|
964
|
+
const samePaths = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
|
|
965
|
+
left.length === right.length && left.every((path, index) => path === right[index])
|
|
966
|
+
|
|
967
|
+
const sameTrigger = (
|
|
968
|
+
left: Machine.Machine.TransitionTrigger,
|
|
969
|
+
right: Machine.Machine.TransitionTrigger
|
|
970
|
+
): boolean =>
|
|
971
|
+
left.type === right.type && (left.type !== "event" || right.type === "event" && left.event === right.event)
|
|
972
|
+
|
|
973
|
+
const makeNodeUtilities = (nodes: ReadonlyArray<PublicStateNode>) => {
|
|
974
|
+
const byPath = new Map(nodes.map((node) => [node.path, node]))
|
|
975
|
+
const depth = (path: string): number => {
|
|
976
|
+
let current = byPath.get(path)
|
|
977
|
+
let result = 0
|
|
978
|
+
const seen = new Set<string>()
|
|
979
|
+
while (current !== undefined && !seen.has(current.path)) {
|
|
980
|
+
seen.add(current.path)
|
|
981
|
+
result += 1
|
|
982
|
+
current = current.parent === undefined ? undefined : byPath.get(current.parent)
|
|
983
|
+
}
|
|
984
|
+
return result
|
|
985
|
+
}
|
|
986
|
+
const isDescendantOrSelf = (path: string, ancestor: string): boolean => {
|
|
987
|
+
let current = byPath.get(path)
|
|
988
|
+
const seen = new Set<string>()
|
|
989
|
+
while (current !== undefined && !seen.has(current.path)) {
|
|
990
|
+
if (current.path === ancestor) return true
|
|
991
|
+
seen.add(current.path)
|
|
992
|
+
current = current.parent === undefined ? undefined : byPath.get(current.parent)
|
|
993
|
+
}
|
|
994
|
+
return false
|
|
995
|
+
}
|
|
996
|
+
const ancestors = (path: string): ReadonlyArray<string> => {
|
|
997
|
+
const result: Array<string> = []
|
|
998
|
+
let current = byPath.get(path)
|
|
999
|
+
const seen = new Set<string>()
|
|
1000
|
+
while (current !== undefined && !seen.has(current.path)) {
|
|
1001
|
+
seen.add(current.path)
|
|
1002
|
+
result.unshift(current.path)
|
|
1003
|
+
current = current.parent === undefined ? undefined : byPath.get(current.parent)
|
|
1004
|
+
}
|
|
1005
|
+
return result
|
|
1006
|
+
}
|
|
1007
|
+
return { ancestors, byPath, depth, isDescendantOrSelf }
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export const verify = <M extends AnyMachine>(
|
|
1011
|
+
machine: M,
|
|
1012
|
+
trace: Trace<M>,
|
|
1013
|
+
options: VerifyOptions = {}
|
|
1014
|
+
): Effect.Effect<void, VerificationError> => {
|
|
1015
|
+
const selected = new Set<VerificationLawGroup>(
|
|
1016
|
+
options.laws ?? ["configuration", "microsteps", "completion", "history", "targetBounds"]
|
|
1017
|
+
)
|
|
1018
|
+
const nodes = Machine.stateNodes(machine) as ReadonlyArray<PublicStateNode>
|
|
1019
|
+
const definitions = Machine.transitionDefinitions(machine)
|
|
1020
|
+
const { ancestors, byPath, depth, isDescendantOrSelf } = makeNodeUtilities(nodes)
|
|
1021
|
+
const violations: Array<VerificationViolation> = []
|
|
1022
|
+
|
|
1023
|
+
const add = (
|
|
1024
|
+
law: VerificationLaw,
|
|
1025
|
+
location: VerificationLocation,
|
|
1026
|
+
message: string,
|
|
1027
|
+
path?: string
|
|
1028
|
+
): void => {
|
|
1029
|
+
violations.push({
|
|
1030
|
+
law,
|
|
1031
|
+
eventIndex: location.eventIndex,
|
|
1032
|
+
...(location.microstepIndex === undefined ? {} : { microstepIndex: location.microstepIndex }),
|
|
1033
|
+
...(path === undefined ? {} : { path }),
|
|
1034
|
+
message
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const schemaMatches = (schema: Schema.Top | undefined, value: unknown): boolean => {
|
|
1039
|
+
if (schema === undefined) return false
|
|
1040
|
+
try {
|
|
1041
|
+
return Schema.is(schema)(value)
|
|
1042
|
+
} catch {
|
|
1043
|
+
return false
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const inspectSnapshot = (
|
|
1048
|
+
snapshot: unknown,
|
|
1049
|
+
location: VerificationLocation,
|
|
1050
|
+
label: string
|
|
1051
|
+
): SnapshotInspection => {
|
|
1052
|
+
const active = new Set<string>()
|
|
1053
|
+
const paths: Array<string> = []
|
|
1054
|
+
const values = new Map<string, unknown>()
|
|
1055
|
+
const reportConfiguration = selected.has("configuration")
|
|
1056
|
+
const root = isRecord(snapshot) ? snapshot : undefined
|
|
1057
|
+
|
|
1058
|
+
const visit = (current: unknown, expectedParent: string | undefined, expectedPath?: string): void => {
|
|
1059
|
+
if (!isRecord(current)) {
|
|
1060
|
+
if (reportConfiguration) {
|
|
1061
|
+
add("configuration.shape", location, `${label} must contain an object snapshot`)
|
|
1062
|
+
}
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof current.path !== "string") {
|
|
1066
|
+
if (reportConfiguration) {
|
|
1067
|
+
add("configuration.path", location, `${label} contains a snapshot without a string path`)
|
|
1068
|
+
}
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
const path = current.path
|
|
1072
|
+
if (active.has(path)) {
|
|
1073
|
+
if (reportConfiguration) {
|
|
1074
|
+
add("configuration.duplicate", location, `${label} activates state "${path}" more than once`, path)
|
|
1075
|
+
}
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
active.add(path)
|
|
1079
|
+
paths.push(path)
|
|
1080
|
+
values.set(path, current.value)
|
|
1081
|
+
|
|
1082
|
+
const node = byPath.get(path)
|
|
1083
|
+
if (node === undefined) {
|
|
1084
|
+
if (reportConfiguration) {
|
|
1085
|
+
add("configuration.path", location, `${label} activates unknown state "${path}"`, path)
|
|
1086
|
+
}
|
|
1087
|
+
if (isRecord(current.state)) visit(current.state, path)
|
|
1088
|
+
if (isRecord(current.states)) {
|
|
1089
|
+
for (const child of Object.values(current.states)) visit(child, path)
|
|
1090
|
+
}
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
if (expectedPath !== undefined && path !== expectedPath && reportConfiguration) {
|
|
1094
|
+
add(
|
|
1095
|
+
"configuration.hierarchy",
|
|
1096
|
+
location,
|
|
1097
|
+
`${label} expected region "${expectedPath}" but found "${path}"`,
|
|
1098
|
+
path
|
|
1099
|
+
)
|
|
1100
|
+
}
|
|
1101
|
+
if (node.parent !== expectedParent && reportConfiguration) {
|
|
1102
|
+
add(
|
|
1103
|
+
"configuration.hierarchy",
|
|
1104
|
+
location,
|
|
1105
|
+
expectedParent === undefined
|
|
1106
|
+
? `${label} root state "${path}" is not a machine root`
|
|
1107
|
+
: `${label} state "${path}" is not a direct child of "${expectedParent}"`,
|
|
1108
|
+
path
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
if (node.type === "history" || node.type === "choice") {
|
|
1112
|
+
if (reportConfiguration) {
|
|
1113
|
+
add("configuration.path", location, `${label} activates ${node.type} pseudo-state "${path}"`, path)
|
|
1114
|
+
}
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
if (reportConfiguration && !schemaMatches(node.schema, current.value)) {
|
|
1118
|
+
add("configuration.schema", location, `${label} value for "${path}" does not match its schema`, path)
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (node.type === "compound") {
|
|
1122
|
+
if (!isRecord(current.state)) {
|
|
1123
|
+
if (reportConfiguration) {
|
|
1124
|
+
add(
|
|
1125
|
+
"configuration.compound",
|
|
1126
|
+
location,
|
|
1127
|
+
`${label} compound state "${path}" must have exactly one child`,
|
|
1128
|
+
path
|
|
1129
|
+
)
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
visit(current.state, path)
|
|
1133
|
+
}
|
|
1134
|
+
if (hasOwn(current, "states") && reportConfiguration) {
|
|
1135
|
+
add("configuration.compound", location, `${label} compound state "${path}" contains parallel regions`, path)
|
|
1136
|
+
}
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (node.type === "parallel") {
|
|
1141
|
+
if (!isRecord(current.states)) {
|
|
1142
|
+
if (reportConfiguration) {
|
|
1143
|
+
add("configuration.parallel", location, `${label} parallel state "${path}" has no region map`, path)
|
|
1144
|
+
}
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
const expectedKeys = new Set<string>()
|
|
1148
|
+
for (const childPath of node.children) {
|
|
1149
|
+
const child = byPath.get(childPath)
|
|
1150
|
+
if (child === undefined) continue
|
|
1151
|
+
expectedKeys.add(child.key)
|
|
1152
|
+
if (!hasOwn(current.states, child.key)) {
|
|
1153
|
+
if (reportConfiguration) {
|
|
1154
|
+
add(
|
|
1155
|
+
"configuration.parallel",
|
|
1156
|
+
location,
|
|
1157
|
+
`${label} parallel state "${path}" omits region "${child.key}"`,
|
|
1158
|
+
childPath
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
} else {
|
|
1162
|
+
visit(current.states[child.key], path, child.path)
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
for (const key of Object.keys(current.states)) {
|
|
1166
|
+
if (!expectedKeys.has(key)) {
|
|
1167
|
+
if (reportConfiguration) {
|
|
1168
|
+
add(
|
|
1169
|
+
"configuration.parallel",
|
|
1170
|
+
location,
|
|
1171
|
+
`${label} parallel state "${path}" contains extra region "${key}"`,
|
|
1172
|
+
path
|
|
1173
|
+
)
|
|
1174
|
+
}
|
|
1175
|
+
visit(current.states[key], path)
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (hasOwn(current, "state") && reportConfiguration) {
|
|
1179
|
+
add("configuration.parallel", location, `${label} parallel state "${path}" contains a compound child`, path)
|
|
1180
|
+
}
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if ((hasOwn(current, "state") || hasOwn(current, "states")) && reportConfiguration) {
|
|
1185
|
+
add("configuration.shape", location, `${label} leaf state "${path}" contains active children`, path)
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
visit(snapshot, undefined)
|
|
1190
|
+
return { active, paths, values, root }
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const validateTraceConfiguration = (
|
|
1194
|
+
expected: SnapshotInspection,
|
|
1195
|
+
actual: ReadonlyArray<string>,
|
|
1196
|
+
location: VerificationLocation,
|
|
1197
|
+
label: string
|
|
1198
|
+
): void => {
|
|
1199
|
+
if (selected.has("configuration") && !samePaths(expected.paths, actual)) {
|
|
1200
|
+
add(
|
|
1201
|
+
"configuration.trace",
|
|
1202
|
+
location,
|
|
1203
|
+
`${label} configuration [${actual.join(", ")}] does not match snapshot [${expected.paths.join(", ")}]`
|
|
1204
|
+
)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const validateHistory = (
|
|
1209
|
+
snapshot: SnapshotInspection,
|
|
1210
|
+
location: VerificationLocation,
|
|
1211
|
+
label: string
|
|
1212
|
+
): void => {
|
|
1213
|
+
if (!selected.has("history") || snapshot.root === undefined || !hasOwn(snapshot.root, "history")) return
|
|
1214
|
+
const history = snapshot.root.history
|
|
1215
|
+
if (!isRecord(history)) {
|
|
1216
|
+
add("history.record", location, `${label} history metadata must be a record`)
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
for (const [historyPath, unknownEntry] of Object.entries(history)) {
|
|
1220
|
+
const historyNode = byPath.get(historyPath)
|
|
1221
|
+
if (historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined) {
|
|
1222
|
+
add("history.path", location, `${label} contains unknown history record "${historyPath}"`, historyPath)
|
|
1223
|
+
continue
|
|
1224
|
+
}
|
|
1225
|
+
if (!isRecord(unknownEntry)) {
|
|
1226
|
+
add("history.record", location, `${label} history record "${historyPath}" must be an object`, historyPath)
|
|
1227
|
+
continue
|
|
1228
|
+
}
|
|
1229
|
+
const entry = unknownEntry
|
|
1230
|
+
if (entry.mode !== historyNode.history) {
|
|
1231
|
+
add(
|
|
1232
|
+
"history.mode",
|
|
1233
|
+
location,
|
|
1234
|
+
`${label} history record "${historyPath}" has mode "${
|
|
1235
|
+
String(entry.mode)
|
|
1236
|
+
}", expected "${historyNode.history}"`,
|
|
1237
|
+
historyPath
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
if (!Array.isArray(entry.active) || !entry.active.every((path) => typeof path === "string")) {
|
|
1241
|
+
add(
|
|
1242
|
+
"history.record",
|
|
1243
|
+
location,
|
|
1244
|
+
`${label} history record "${historyPath}" must contain string paths`,
|
|
1245
|
+
historyPath
|
|
1246
|
+
)
|
|
1247
|
+
continue
|
|
1248
|
+
}
|
|
1249
|
+
if (!isRecord(entry.values)) {
|
|
1250
|
+
add(
|
|
1251
|
+
"history.record",
|
|
1252
|
+
location,
|
|
1253
|
+
`${label} history record "${historyPath}" must contain a values record`,
|
|
1254
|
+
historyPath
|
|
1255
|
+
)
|
|
1256
|
+
continue
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const rememberedPaths = entry.active as ReadonlyArray<string>
|
|
1260
|
+
const remembered = new Set<string>()
|
|
1261
|
+
for (const path of rememberedPaths) {
|
|
1262
|
+
if (remembered.has(path)) {
|
|
1263
|
+
add("history.path", location, `${label} history record "${historyPath}" repeats "${path}"`, path)
|
|
1264
|
+
continue
|
|
1265
|
+
}
|
|
1266
|
+
remembered.add(path)
|
|
1267
|
+
const node = byPath.get(path)
|
|
1268
|
+
if (node === undefined || node.type === "history" || node.type === "choice") {
|
|
1269
|
+
add(
|
|
1270
|
+
"history.path",
|
|
1271
|
+
location,
|
|
1272
|
+
`${label} history record "${historyPath}" contains invalid state "${path}"`,
|
|
1273
|
+
path
|
|
1274
|
+
)
|
|
1275
|
+
continue
|
|
1276
|
+
}
|
|
1277
|
+
const inOwnerSubtree = isDescendantOrSelf(path, historyNode.parent)
|
|
1278
|
+
const inOwnerAncestry = ancestors(historyNode.parent).includes(path)
|
|
1279
|
+
if (!inOwnerSubtree && !inOwnerAncestry) {
|
|
1280
|
+
add(
|
|
1281
|
+
"history.path",
|
|
1282
|
+
location,
|
|
1283
|
+
`${label} history record "${historyPath}" contains state "${path}" outside its owner`,
|
|
1284
|
+
path
|
|
1285
|
+
)
|
|
1286
|
+
}
|
|
1287
|
+
if (!hasOwn(entry.values, path)) {
|
|
1288
|
+
add("history.value", location, `${label} history record "${historyPath}" omits value for "${path}"`, path)
|
|
1289
|
+
} else if (!schemaMatches(node.schema, entry.values[path])) {
|
|
1290
|
+
add(
|
|
1291
|
+
"history.value",
|
|
1292
|
+
location,
|
|
1293
|
+
`${label} history value for "${path}" does not match its state schema`,
|
|
1294
|
+
path
|
|
1295
|
+
)
|
|
1296
|
+
}
|
|
1297
|
+
if (
|
|
1298
|
+
entry.mode === "shallow" && isDescendantOrSelf(path, historyNode.parent) && path !== historyNode.parent &&
|
|
1299
|
+
node.parent !== historyNode.parent
|
|
1300
|
+
) {
|
|
1301
|
+
add(
|
|
1302
|
+
"history.shallow",
|
|
1303
|
+
location,
|
|
1304
|
+
`${label} shallow history record "${historyPath}" contains deep descendant "${path}"`,
|
|
1305
|
+
path
|
|
1306
|
+
)
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
for (const path of Object.keys(entry.values)) {
|
|
1310
|
+
if (!remembered.has(path)) {
|
|
1311
|
+
add("history.value", location, `${label} history record "${historyPath}" has extra value "${path}"`, path)
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (entry.mode === "deep") {
|
|
1315
|
+
for (const path of remembered) {
|
|
1316
|
+
if (!isDescendantOrSelf(path, historyNode.parent) || path === historyNode.parent) continue
|
|
1317
|
+
let parent: string | undefined = byPath.get(path)?.parent
|
|
1318
|
+
while (parent !== undefined && isDescendantOrSelf(parent, historyNode.parent)) {
|
|
1319
|
+
if (!remembered.has(parent)) {
|
|
1320
|
+
add(
|
|
1321
|
+
"history.deep",
|
|
1322
|
+
location,
|
|
1323
|
+
`${label} deep history record "${historyPath}" remembers "${path}" without ancestor "${parent}"`,
|
|
1324
|
+
path
|
|
1325
|
+
)
|
|
1326
|
+
}
|
|
1327
|
+
if (parent === historyNode.parent) break
|
|
1328
|
+
parent = byPath.get(parent)?.parent
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
for (const ancestor of ancestors(historyNode.parent)) {
|
|
1333
|
+
if (!remembered.has(ancestor)) {
|
|
1334
|
+
add(
|
|
1335
|
+
"history.path",
|
|
1336
|
+
location,
|
|
1337
|
+
`${label} history record "${historyPath}" omits owner ancestry state "${ancestor}"`,
|
|
1338
|
+
ancestor
|
|
1339
|
+
)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const validateRememberedControl = (path: string, recurse: boolean): void => {
|
|
1344
|
+
const node = byPath.get(path)
|
|
1345
|
+
if (node === undefined) return
|
|
1346
|
+
const activeChildren = node.children.filter((child) => remembered.has(child))
|
|
1347
|
+
if (node.type === "compound") {
|
|
1348
|
+
if (activeChildren.length !== 1) {
|
|
1349
|
+
add(
|
|
1350
|
+
entry.mode === "deep" ? "history.deep" : "history.shallow",
|
|
1351
|
+
location,
|
|
1352
|
+
`${label} history record "${historyPath}" must remember one child of compound state "${path}"`,
|
|
1353
|
+
path
|
|
1354
|
+
)
|
|
1355
|
+
} else if (recurse) {
|
|
1356
|
+
validateRememberedControl(activeChildren[0]!, true)
|
|
1357
|
+
}
|
|
1358
|
+
} else if (node.type === "parallel") {
|
|
1359
|
+
for (const child of node.children) {
|
|
1360
|
+
if (!remembered.has(child)) {
|
|
1361
|
+
add(
|
|
1362
|
+
entry.mode === "deep" ? "history.deep" : "history.shallow",
|
|
1363
|
+
location,
|
|
1364
|
+
`${label} history record "${historyPath}" omits parallel region "${child}"`,
|
|
1365
|
+
child
|
|
1366
|
+
)
|
|
1367
|
+
} else if (recurse) {
|
|
1368
|
+
validateRememberedControl(child, true)
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
validateRememberedControl(historyNode.parent, entry.mode === "deep")
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const isCompletedControl = (path: string, active: ReadonlySet<string>): boolean => {
|
|
1378
|
+
if (!active.has(path)) return false
|
|
1379
|
+
const node = byPath.get(path)
|
|
1380
|
+
if (node === undefined) return false
|
|
1381
|
+
if (node.type === "final") return true
|
|
1382
|
+
if (node.type === "compound") {
|
|
1383
|
+
const child = node.children.find((candidate) => active.has(candidate))
|
|
1384
|
+
return child !== undefined && byPath.get(child)?.type === "final"
|
|
1385
|
+
}
|
|
1386
|
+
if (node.type === "parallel") {
|
|
1387
|
+
return node.children.length > 0 && node.children.every((child) => isCompletedControl(child, active))
|
|
1388
|
+
}
|
|
1389
|
+
return false
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const completionSchema = (path: string, active: ReadonlySet<string>): Schema.Top | undefined => {
|
|
1393
|
+
const node = byPath.get(path)
|
|
1394
|
+
if (node === undefined) return undefined
|
|
1395
|
+
if (node.type === "compound") {
|
|
1396
|
+
const child = node.children.find((candidate) => active.has(candidate))
|
|
1397
|
+
return child === undefined ? undefined : completionSchema(child, active)
|
|
1398
|
+
}
|
|
1399
|
+
return node.output ?? Schema.Void
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const validateCompletions = (
|
|
1403
|
+
snapshot: SnapshotInspection,
|
|
1404
|
+
location: VerificationLocation,
|
|
1405
|
+
label: string,
|
|
1406
|
+
settled: boolean
|
|
1407
|
+
): ReadonlyMap<string, unknown> => {
|
|
1408
|
+
const result = new Map<string, unknown>()
|
|
1409
|
+
if (!selected.has("completion") || snapshot.root === undefined) {
|
|
1410
|
+
return result
|
|
1411
|
+
}
|
|
1412
|
+
if (hasOwn(snapshot.root, "completed")) {
|
|
1413
|
+
const completed = snapshot.root.completed
|
|
1414
|
+
if (!Array.isArray(completed)) {
|
|
1415
|
+
add("completion.record", location, `${label} completed metadata must be an array`)
|
|
1416
|
+
} else {
|
|
1417
|
+
for (const unknownEntry of completed) {
|
|
1418
|
+
if (!isRecord(unknownEntry) || typeof unknownEntry.path !== "string") {
|
|
1419
|
+
add("completion.record", location, `${label} contains an invalid completion record`)
|
|
1420
|
+
continue
|
|
1421
|
+
}
|
|
1422
|
+
const path = unknownEntry.path
|
|
1423
|
+
if (result.has(path)) {
|
|
1424
|
+
add("completion.record", location, `${label} repeats completion "${path}"`, path)
|
|
1425
|
+
continue
|
|
1426
|
+
}
|
|
1427
|
+
result.set(path, unknownEntry.output)
|
|
1428
|
+
if (!snapshot.active.has(path) || !isCompletedControl(path, snapshot.active)) {
|
|
1429
|
+
add("completion.record", location, `${label} completion "${path}" is not actively complete`, path)
|
|
1430
|
+
continue
|
|
1431
|
+
}
|
|
1432
|
+
const schema = completionSchema(path, snapshot.active)
|
|
1433
|
+
if (!schemaMatches(schema, unknownEntry.output)) {
|
|
1434
|
+
add(
|
|
1435
|
+
"completion.output",
|
|
1436
|
+
location,
|
|
1437
|
+
`${label} completion output for "${path}" does not match its schema`,
|
|
1438
|
+
path
|
|
1439
|
+
)
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (settled) {
|
|
1445
|
+
for (const path of snapshot.paths) {
|
|
1446
|
+
if (isCompletedControl(path, snapshot.active) && !result.has(path)) {
|
|
1447
|
+
add("completion.record", location, `${label} omits settled completion "${path}"`, path)
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return result
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const validateSnapshotMetadata = (
|
|
1455
|
+
snapshot: SnapshotInspection,
|
|
1456
|
+
location: VerificationLocation,
|
|
1457
|
+
label: string,
|
|
1458
|
+
settled = false
|
|
1459
|
+
): ReadonlyMap<string, unknown> => {
|
|
1460
|
+
validateHistory(snapshot, location, label)
|
|
1461
|
+
return validateCompletions(snapshot, location, label, settled)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const sameControl = (left: SnapshotInspection, right: SnapshotInspection): boolean => {
|
|
1465
|
+
if (!samePaths(left.paths, right.paths)) return false
|
|
1466
|
+
for (const path of left.paths) {
|
|
1467
|
+
if (!sameValue(left.values.get(path), right.values.get(path))) return false
|
|
1468
|
+
}
|
|
1469
|
+
return sameValue(left.root?.history, right.root?.history)
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const sameActivePaths = (left: SnapshotInspection, right: SnapshotInspection): boolean =>
|
|
1473
|
+
left.active.size === right.active.size && Array.from(left.active).every((path) => right.active.has(path))
|
|
1474
|
+
|
|
1475
|
+
const sortedPaths = (paths: ReadonlyArray<string>, direction: "entry" | "exit"): ReadonlyArray<string> =>
|
|
1476
|
+
[...new Set(paths)].sort((left, right) => {
|
|
1477
|
+
const depthDifference = direction === "entry" ? depth(left) - depth(right) : depth(right) - depth(left)
|
|
1478
|
+
if (depthDifference !== 0) return depthDifference
|
|
1479
|
+
const leftOrder = byPath.get(left)?.order ?? Number.MAX_SAFE_INTEGER
|
|
1480
|
+
const rightOrder = byPath.get(right)?.order ?? Number.MAX_SAFE_INTEGER
|
|
1481
|
+
return direction === "entry" ? leftOrder - rightOrder : rightOrder - leftOrder
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1484
|
+
const validateTransitionBounds = (
|
|
1485
|
+
transition: Microstep<M>["transitions"][number],
|
|
1486
|
+
location: VerificationLocation
|
|
1487
|
+
): void => {
|
|
1488
|
+
if (!selected.has("targetBounds")) return
|
|
1489
|
+
const definition = definitions.find((candidate) =>
|
|
1490
|
+
candidate.source === transition.source && candidate.reenter === transition.reenter &&
|
|
1491
|
+
sameTrigger(candidate.trigger, transition.trigger)
|
|
1492
|
+
)
|
|
1493
|
+
if (definition === undefined) {
|
|
1494
|
+
add(
|
|
1495
|
+
"targetBounds.definition",
|
|
1496
|
+
location,
|
|
1497
|
+
`retained transition from "${transition.source}" has no public definition`,
|
|
1498
|
+
transition.source
|
|
1499
|
+
)
|
|
1500
|
+
return
|
|
1501
|
+
}
|
|
1502
|
+
if (transition.target === undefined || definition.targets.type === "dynamic") return
|
|
1503
|
+
if (!definition.targets.paths.some((bound) => isDescendantOrSelf(String(transition.target), String(bound)))) {
|
|
1504
|
+
add(
|
|
1505
|
+
"targetBounds.target",
|
|
1506
|
+
location,
|
|
1507
|
+
`transition target "${String(transition.target)}" is outside declared bounds ` +
|
|
1508
|
+
`[${definition.targets.paths.join(", ")}]`,
|
|
1509
|
+
String(transition.target)
|
|
1510
|
+
)
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const validateMicrostep = (
|
|
1515
|
+
microstep: Microstep<M, any>,
|
|
1516
|
+
before: SnapshotInspection,
|
|
1517
|
+
after: SnapshotInspection,
|
|
1518
|
+
location: VerificationLocation
|
|
1519
|
+
): void => {
|
|
1520
|
+
if (selected.has("microsteps")) {
|
|
1521
|
+
const uniqueExit = new Set(microstep.exitPaths)
|
|
1522
|
+
const uniqueEntry = new Set(microstep.entryPaths)
|
|
1523
|
+
const reentering = microstep.transitions.filter((transition) => transition.reenter)
|
|
1524
|
+
const inReentryScope = (
|
|
1525
|
+
path: string,
|
|
1526
|
+
transition: Microstep<M>["transitions"][number]
|
|
1527
|
+
): boolean => {
|
|
1528
|
+
const target = transition.target === undefined ? undefined : byPath.get(String(transition.target))
|
|
1529
|
+
if (target?.type === "history" && target.parent !== undefined && before.active.has(target.parent)) {
|
|
1530
|
+
return isDescendantOrSelf(path, target.parent)
|
|
1531
|
+
}
|
|
1532
|
+
const parent = byPath.get(String(transition.source))?.parent
|
|
1533
|
+
return parent === undefined || path !== parent && isDescendantOrSelf(path, parent)
|
|
1534
|
+
}
|
|
1535
|
+
const commonLifecycleExplained = (path: string): boolean =>
|
|
1536
|
+
microstep.transitions.some((transition) => {
|
|
1537
|
+
if (transition.reenter) {
|
|
1538
|
+
const target = transition.target === undefined ? undefined : byPath.get(String(transition.target))
|
|
1539
|
+
if (target?.type === "history" && target.parent !== undefined && before.active.has(target.parent)) {
|
|
1540
|
+
// Reentry into an active history owner has a dedicated boundary:
|
|
1541
|
+
// only the owner subtree is exited and entered, regardless of
|
|
1542
|
+
// the ordinary source/resolved-target LCA.
|
|
1543
|
+
return isDescendantOrSelf(path, target.parent)
|
|
1544
|
+
}
|
|
1545
|
+
if (inReentryScope(path, transition)) return true
|
|
1546
|
+
}
|
|
1547
|
+
if (transition.resolvedTarget === undefined) return false
|
|
1548
|
+
const sourceAncestors = ancestors(String(transition.source))
|
|
1549
|
+
const targetAncestors = ancestors(String(transition.resolvedTarget))
|
|
1550
|
+
let boundary: string | undefined
|
|
1551
|
+
for (let index = 0; index < Math.min(sourceAncestors.length, targetAncestors.length); index++) {
|
|
1552
|
+
if (sourceAncestors[index] !== targetAncestors[index]) break
|
|
1553
|
+
boundary = sourceAncestors[index]
|
|
1554
|
+
}
|
|
1555
|
+
return boundary === undefined || path !== boundary && isDescendantOrSelf(path, boundary)
|
|
1556
|
+
})
|
|
1557
|
+
if (uniqueExit.size !== microstep.exitPaths.length) {
|
|
1558
|
+
add("microsteps.unique", location, "microstep exit paths contain duplicates")
|
|
1559
|
+
}
|
|
1560
|
+
if (uniqueEntry.size !== microstep.entryPaths.length) {
|
|
1561
|
+
add("microsteps.unique", location, "microstep entry paths contain duplicates")
|
|
1562
|
+
}
|
|
1563
|
+
if (!samePaths(microstep.exitPaths, sortedPaths(microstep.exitPaths, "exit"))) {
|
|
1564
|
+
add("microsteps.order", location, "microstep exit paths are not deepest-first in reverse document order")
|
|
1565
|
+
}
|
|
1566
|
+
if (!samePaths(microstep.entryPaths, sortedPaths(microstep.entryPaths, "entry"))) {
|
|
1567
|
+
add("microsteps.order", location, "microstep entry paths are not parent-first in document order")
|
|
1568
|
+
}
|
|
1569
|
+
for (const path of microstep.exitPaths) {
|
|
1570
|
+
if (!before.active.has(path)) {
|
|
1571
|
+
add("microsteps.activeBefore", location, `microstep exits inactive state "${path}"`, path)
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
for (const path of microstep.entryPaths) {
|
|
1575
|
+
if (!after.active.has(path)) {
|
|
1576
|
+
add("microsteps.activeAfter", location, `microstep enters state "${path}" absent from its next state`, path)
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
for (const path of before.paths) {
|
|
1580
|
+
if (!after.active.has(path) && !uniqueExit.has(path)) {
|
|
1581
|
+
add("microsteps.activeBefore", location, `removed state "${path}" is missing from exit paths`, path)
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
for (const path of after.paths) {
|
|
1585
|
+
if (!before.active.has(path) && !uniqueEntry.has(path)) {
|
|
1586
|
+
add("microsteps.activeAfter", location, `added state "${path}" is missing from entry paths`, path)
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
for (const transition of reentering) {
|
|
1590
|
+
for (const path of before.paths) {
|
|
1591
|
+
if (inReentryScope(path, transition) && !uniqueExit.has(path)) {
|
|
1592
|
+
add(
|
|
1593
|
+
"microsteps.reentry",
|
|
1594
|
+
location,
|
|
1595
|
+
`reentering transition from "${String(transition.source)}" omits exit lifecycle for "${path}"`,
|
|
1596
|
+
path
|
|
1597
|
+
)
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
for (const path of after.paths) {
|
|
1601
|
+
if (inReentryScope(path, transition) && !uniqueEntry.has(path)) {
|
|
1602
|
+
add(
|
|
1603
|
+
"microsteps.reentry",
|
|
1604
|
+
location,
|
|
1605
|
+
`reentering transition from "${String(transition.source)}" omits entry lifecycle for "${path}"`,
|
|
1606
|
+
path
|
|
1607
|
+
)
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
for (const path of before.paths) {
|
|
1612
|
+
if (!after.active.has(path) || !uniqueExit.has(path) && !uniqueEntry.has(path)) continue
|
|
1613
|
+
if (!commonLifecycleExplained(path)) {
|
|
1614
|
+
add(
|
|
1615
|
+
"microsteps.reentry",
|
|
1616
|
+
location,
|
|
1617
|
+
`common state "${path}" has lifecycle without a reentering transition`,
|
|
1618
|
+
path
|
|
1619
|
+
)
|
|
1620
|
+
} else if (!uniqueExit.has(path) || !uniqueEntry.has(path)) {
|
|
1621
|
+
add(
|
|
1622
|
+
"microsteps.reentry",
|
|
1623
|
+
location,
|
|
1624
|
+
`reentered common state "${path}" must have both exit and entry lifecycle`,
|
|
1625
|
+
path
|
|
1626
|
+
)
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (!microstep.changed) {
|
|
1630
|
+
if (microstep.exitPaths.length > 0 || microstep.entryPaths.length > 0) {
|
|
1631
|
+
add("microsteps.changed", location, "unchanged microstep contains entry or exit paths")
|
|
1632
|
+
}
|
|
1633
|
+
if (!sameActivePaths(before, after)) {
|
|
1634
|
+
add("microsteps.changed", location, "unchanged microstep changes its active state paths")
|
|
1635
|
+
}
|
|
1636
|
+
} else if (
|
|
1637
|
+
microstep.exitPaths.length === 0 && microstep.entryPaths.length === 0 && sameActivePaths(before, after)
|
|
1638
|
+
) {
|
|
1639
|
+
add("microsteps.changed", location, "changed microstep has no control-state change or reentry evidence")
|
|
1640
|
+
}
|
|
1641
|
+
for (const transition of microstep.transitions) {
|
|
1642
|
+
if (
|
|
1643
|
+
!before.active.has(String(transition.source)) &&
|
|
1644
|
+
byPath.get(String(transition.source))?.type !== "choice"
|
|
1645
|
+
) {
|
|
1646
|
+
add(
|
|
1647
|
+
"microsteps.activeBefore",
|
|
1648
|
+
location,
|
|
1649
|
+
`transition source "${String(transition.source)}" is inactive before the microstep`,
|
|
1650
|
+
String(transition.source)
|
|
1651
|
+
)
|
|
1652
|
+
}
|
|
1653
|
+
if (
|
|
1654
|
+
transition.resolvedTarget !== undefined && !after.active.has(String(transition.resolvedTarget)) &&
|
|
1655
|
+
microstep.transitions.length === 1
|
|
1656
|
+
) {
|
|
1657
|
+
add(
|
|
1658
|
+
"microsteps.activeAfter",
|
|
1659
|
+
location,
|
|
1660
|
+
`resolved target "${String(transition.resolvedTarget)}" is inactive after the microstep`,
|
|
1661
|
+
String(transition.resolvedTarget)
|
|
1662
|
+
)
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
for (const transition of microstep.transitions) validateTransitionBounds(transition, location)
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const validatePlanCompletion = (
|
|
1670
|
+
plan: PlanCompletion<M>,
|
|
1671
|
+
snapshot: SnapshotInspection,
|
|
1672
|
+
completions: ReadonlyMap<string, unknown>,
|
|
1673
|
+
location: VerificationLocation,
|
|
1674
|
+
label: string
|
|
1675
|
+
): void => {
|
|
1676
|
+
if (!selected.has("completion")) return
|
|
1677
|
+
const rootPath = snapshot.paths.find((path) => byPath.get(path)?.parent === undefined)
|
|
1678
|
+
const hasRootDoneTransition = rootPath !== undefined &&
|
|
1679
|
+
definitions.some((definition) => definition.source === rootPath && definition.trigger.type === "done")
|
|
1680
|
+
const terminal = rootPath !== undefined && isCompletedControl(rootPath, snapshot.active) && !hasRootDoneTransition
|
|
1681
|
+
if (plan.done !== terminal) {
|
|
1682
|
+
add(
|
|
1683
|
+
"completion.done",
|
|
1684
|
+
location,
|
|
1685
|
+
`${label} reports done=${String(plan.done)} for terminal=${String(terminal)}`,
|
|
1686
|
+
rootPath
|
|
1687
|
+
)
|
|
1688
|
+
}
|
|
1689
|
+
if (plan.done) {
|
|
1690
|
+
if (rootPath === undefined || !completions.has(rootPath)) {
|
|
1691
|
+
add("completion.output", location, `${label} done plan has no root completion output`, rootPath)
|
|
1692
|
+
} else if (!sameValue(plan.output, completions.get(rootPath))) {
|
|
1693
|
+
add("completion.output", location, `${label} output differs from its root completion`, rootPath)
|
|
1694
|
+
}
|
|
1695
|
+
} else if (plan.output !== undefined) {
|
|
1696
|
+
add("completion.output", location, `${label} non-done plan exposes an output`, rootPath)
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const initialLocation: VerificationLocation = { eventIndex: undefined }
|
|
1701
|
+
const starting = inspectSnapshot(trace.initial.startingState, initialLocation, "initial starting state")
|
|
1702
|
+
validateSnapshotMetadata(starting, initialLocation, "initial starting state")
|
|
1703
|
+
validateTraceConfiguration(
|
|
1704
|
+
starting,
|
|
1705
|
+
trace.initial.startingConfiguration as ReadonlyArray<string>,
|
|
1706
|
+
initialLocation,
|
|
1707
|
+
"initial starting"
|
|
1708
|
+
)
|
|
1709
|
+
if (selected.has("microsteps")) {
|
|
1710
|
+
if (new Set(trace.initial.initialEntryPaths).size !== trace.initial.initialEntryPaths.length) {
|
|
1711
|
+
add("microsteps.unique", initialLocation, "initial entry paths contain duplicates")
|
|
1712
|
+
}
|
|
1713
|
+
if (!samePaths(trace.initial.initialEntryPaths as ReadonlyArray<string>, starting.paths)) {
|
|
1714
|
+
add(
|
|
1715
|
+
"microsteps.order",
|
|
1716
|
+
initialLocation,
|
|
1717
|
+
"initial entry paths do not cover the starting configuration in definition order"
|
|
1718
|
+
)
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
let current = starting
|
|
1723
|
+
for (let index = 0; index < trace.initial.plan.microsteps.length; index++) {
|
|
1724
|
+
const location: VerificationLocation = { eventIndex: undefined, microstepIndex: index }
|
|
1725
|
+
const microstep = trace.initial.plan.microsteps[index]!
|
|
1726
|
+
const next = inspectSnapshot(microstep.next, location, `initial microstep ${index} next state`)
|
|
1727
|
+
validateSnapshotMetadata(next, location, `initial microstep ${index} next state`)
|
|
1728
|
+
validateMicrostep(microstep, current, next, location)
|
|
1729
|
+
current = next
|
|
1730
|
+
}
|
|
1731
|
+
const initialState = inspectSnapshot(trace.initial.plan.state, initialLocation, "initial plan state")
|
|
1732
|
+
const initialCompletions = validateSnapshotMetadata(initialState, initialLocation, "initial plan state", true)
|
|
1733
|
+
if (selected.has("microsteps") && !sameControl(current, initialState)) {
|
|
1734
|
+
add("microsteps.continuity", initialLocation, "initial plan state does not continue from its final microstep")
|
|
1735
|
+
}
|
|
1736
|
+
validatePlanCompletion(trace.initial.plan, initialState, initialCompletions, initialLocation, "initial plan")
|
|
1737
|
+
validateTraceConfiguration(
|
|
1738
|
+
initialState,
|
|
1739
|
+
trace.initial.configuration as ReadonlyArray<string>,
|
|
1740
|
+
initialLocation,
|
|
1741
|
+
"initial"
|
|
1742
|
+
)
|
|
1743
|
+
if (selected.has("microsteps") && !sameValue(trace.initial.startingState, trace.initial.plan.startingState)) {
|
|
1744
|
+
add("microsteps.continuity", initialLocation, "initial trace starting state differs from its plan")
|
|
1745
|
+
}
|
|
1746
|
+
if (
|
|
1747
|
+
selected.has("microsteps") &&
|
|
1748
|
+
!samePaths(trace.initial.initialEntryPaths as ReadonlyArray<string>, trace.initial.plan.initialEntryPaths)
|
|
1749
|
+
) {
|
|
1750
|
+
add("microsteps.continuity", initialLocation, "initial trace entry paths differ from its plan")
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
let previous = initialState
|
|
1754
|
+
for (let eventIndex = 0; eventIndex < trace.steps.length; eventIndex++) {
|
|
1755
|
+
const step = trace.steps[eventIndex]!
|
|
1756
|
+
const location: VerificationLocation = { eventIndex }
|
|
1757
|
+
const before = inspectSnapshot(step.before, location, `event ${eventIndex} before state`)
|
|
1758
|
+
validateSnapshotMetadata(before, location, `event ${eventIndex} before state`, true)
|
|
1759
|
+
validateTraceConfiguration(
|
|
1760
|
+
before,
|
|
1761
|
+
step.beforeConfiguration as ReadonlyArray<string>,
|
|
1762
|
+
location,
|
|
1763
|
+
`event ${eventIndex} before`
|
|
1764
|
+
)
|
|
1765
|
+
if (selected.has("microsteps")) {
|
|
1766
|
+
if (step.index !== eventIndex) {
|
|
1767
|
+
add("microsteps.continuity", location, `trace step index ${step.index} does not equal ${eventIndex}`)
|
|
1768
|
+
}
|
|
1769
|
+
if (!sameValue(previous.root, before.root)) {
|
|
1770
|
+
add("microsteps.continuity", location, `event ${eventIndex} before state does not equal the previous state`)
|
|
1771
|
+
}
|
|
1772
|
+
if (!sameValue(step.event, trace.scenario.events[eventIndex])) {
|
|
1773
|
+
add("microsteps.continuity", location, `event ${eventIndex} differs from its scenario event`)
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
current = before
|
|
1778
|
+
for (let microstepIndex = 0; microstepIndex < step.plan.microsteps.length; microstepIndex++) {
|
|
1779
|
+
const microstepLocation: VerificationLocation = { eventIndex, microstepIndex }
|
|
1780
|
+
const microstep = step.plan.microsteps[microstepIndex]!
|
|
1781
|
+
const next = inspectSnapshot(
|
|
1782
|
+
microstep.next,
|
|
1783
|
+
microstepLocation,
|
|
1784
|
+
`event ${eventIndex} microstep ${microstepIndex} next state`
|
|
1785
|
+
)
|
|
1786
|
+
validateSnapshotMetadata(next, microstepLocation, `event ${eventIndex} microstep ${microstepIndex} next state`)
|
|
1787
|
+
validateMicrostep(microstep, current, next, microstepLocation)
|
|
1788
|
+
current = next
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
const plannedNext = inspectSnapshot(step.plan.next, location, `event ${eventIndex} plan next state`)
|
|
1792
|
+
const completions = validateSnapshotMetadata(plannedNext, location, `event ${eventIndex} plan next state`, true)
|
|
1793
|
+
if (selected.has("microsteps") && !sameControl(current, plannedNext)) {
|
|
1794
|
+
add(
|
|
1795
|
+
"microsteps.continuity",
|
|
1796
|
+
location,
|
|
1797
|
+
`event ${eventIndex} plan next state does not continue its final microstep`
|
|
1798
|
+
)
|
|
1799
|
+
}
|
|
1800
|
+
validatePlanCompletion(step.plan, plannedNext, completions, location, `event ${eventIndex} plan`)
|
|
1801
|
+
|
|
1802
|
+
const after = inspectSnapshot(step.after, location, `event ${eventIndex} after state`)
|
|
1803
|
+
validateSnapshotMetadata(after, location, `event ${eventIndex} after state`, true)
|
|
1804
|
+
validateTraceConfiguration(
|
|
1805
|
+
after,
|
|
1806
|
+
step.afterConfiguration as ReadonlyArray<string>,
|
|
1807
|
+
location,
|
|
1808
|
+
`event ${eventIndex} after`
|
|
1809
|
+
)
|
|
1810
|
+
if (selected.has("microsteps") && !sameValue(step.plan.next, step.after)) {
|
|
1811
|
+
add("microsteps.continuity", location, `event ${eventIndex} after state differs from its plan next state`)
|
|
1812
|
+
}
|
|
1813
|
+
previous = after
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const finalEventIndex = trace.steps.length === 0 ? undefined : trace.steps.length - 1
|
|
1817
|
+
const finalLocation: VerificationLocation = { eventIndex: finalEventIndex }
|
|
1818
|
+
const final = inspectSnapshot(trace.final, finalLocation, "trace final state")
|
|
1819
|
+
validateSnapshotMetadata(final, finalLocation, "trace final state", true)
|
|
1820
|
+
validateTraceConfiguration(final, trace.finalConfiguration as ReadonlyArray<string>, finalLocation, "final")
|
|
1821
|
+
if (selected.has("microsteps") && !sameValue(previous.root, final.root)) {
|
|
1822
|
+
add("microsteps.continuity", finalLocation, "trace final state differs from its final planned state")
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return violations.length === 0 ? Effect.void : Effect.fail(new VerificationError({ violations }))
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const formatConfiguration = (paths: ReadonlyArray<string>): string => `[${paths.join(", ")}]`
|
|
1829
|
+
|
|
1830
|
+
const formatMicrosteps = <M extends AnyMachine>(microsteps: ReadonlyArray<Microstep<M, any>>): Array<string> =>
|
|
1831
|
+
microsteps.map((microstep, index) => {
|
|
1832
|
+
const transitions = microstep.transitions.map((transition) => ({
|
|
1833
|
+
source: transition.source,
|
|
1834
|
+
trigger: transition.trigger,
|
|
1835
|
+
reenter: transition.reenter,
|
|
1836
|
+
target: transition.target,
|
|
1837
|
+
resolvedTarget: transition.resolvedTarget
|
|
1838
|
+
}))
|
|
1839
|
+
return ` microstep ${index}: event=${formatValue(microstep.event)} changed=${String(microstep.changed)} ` +
|
|
1840
|
+
`transitions=${formatValue(transitions)} exit=${formatConfiguration(microstep.exitPaths)} ` +
|
|
1841
|
+
`entry=${formatConfiguration(microstep.entryPaths)} commands=${microstep.commands.length} ` +
|
|
1842
|
+
`raised=${formatValue(microstep.raisedEvents)} emitted=${formatValue(microstep.emittedEvents)} ` +
|
|
1843
|
+
`next=${formatValue(microstep.next)}`
|
|
1844
|
+
})
|
|
1845
|
+
|
|
1846
|
+
const formatInitial = <M extends AnyMachine>(initial: InitialTrace<M>): Array<string> => [
|
|
1847
|
+
`initial: startingConfiguration=${formatConfiguration(initial.startingConfiguration)} ` +
|
|
1848
|
+
`startingState=${formatValue(initial.startingState)} initialEntry=${
|
|
1849
|
+
formatConfiguration(initial.initialEntryPaths)
|
|
1850
|
+
} ` +
|
|
1851
|
+
`configuration=${formatConfiguration(initial.configuration)} state=${formatValue(initial.plan.state)} ` +
|
|
1852
|
+
`done=${String(initial.plan.done)} output=${formatValue(initial.plan.output)} ` +
|
|
1853
|
+
`commands=${initial.plan.commands.length} emitted=${formatValue(initial.plan.emittedEvents)}`,
|
|
1854
|
+
...formatMicrosteps(initial.plan.microsteps)
|
|
1855
|
+
]
|
|
1856
|
+
|
|
1857
|
+
const formatStep = <M extends AnyMachine>(step: TraceStep<M>): Array<string> => [
|
|
1858
|
+
`step ${step.index}: event=${formatValue(step.event)} before=${formatConfiguration(step.beforeConfiguration)} ` +
|
|
1859
|
+
`after=${formatConfiguration(step.afterConfiguration)} state=${formatValue(step.after)} ` +
|
|
1860
|
+
`done=${String(step.plan.done)} output=${formatValue(step.plan.output)} ` +
|
|
1861
|
+
`commands=${step.plan.commands.length} emitted=${formatValue(step.plan.emittedEvents)}`,
|
|
1862
|
+
...formatMicrosteps(step.plan.microsteps)
|
|
1863
|
+
]
|
|
1864
|
+
|
|
1865
|
+
const isRunFailure = <M extends AnyMachine, Cause>(
|
|
1866
|
+
trace: Trace<M> | RunFailure<Cause, M>
|
|
1867
|
+
): trace is RunFailure<Cause, M> => "_tag" in trace && trace._tag === "MachineTestRunFailure"
|
|
1868
|
+
|
|
1869
|
+
export const formatTrace = <M extends AnyMachine, Cause>(trace: Trace<M> | RunFailure<Cause, M>): string => {
|
|
1870
|
+
const lines = [`scenario: ${formatValue(trace.scenario)}`]
|
|
1871
|
+
if (isRunFailure(trace)) {
|
|
1872
|
+
if (trace.initial !== undefined) {
|
|
1873
|
+
lines.push(...formatInitial(trace.initial))
|
|
1874
|
+
for (const step of trace.steps) {
|
|
1875
|
+
lines.push(...formatStep(step))
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
lines.push(
|
|
1879
|
+
`failure: phase=${trace.phase} eventIndex=${formatValue(trace.eventIndex)} ` +
|
|
1880
|
+
`event=${formatValue(trace.event)} cause=${formatValue(trace.cause)}`
|
|
1881
|
+
)
|
|
1882
|
+
return lines.join("\n")
|
|
1883
|
+
}
|
|
1884
|
+
lines.push(...formatInitial(trace.initial))
|
|
1885
|
+
for (const step of trace.steps) {
|
|
1886
|
+
lines.push(...formatStep(step))
|
|
1887
|
+
}
|
|
1888
|
+
lines.push(`final: configuration=${formatConfiguration(trace.finalConfiguration)} state=${formatValue(trace.final)}`)
|
|
1889
|
+
return lines.join("\n")
|
|
1890
|
+
}
|