@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,1498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finite hierarchical, parallel, history, choice, and automatic-transition
|
|
3
|
+
* statechart models used by the public testing module.
|
|
4
|
+
*
|
|
5
|
+
* This module intentionally compiles through the public Machine API. It must
|
|
6
|
+
* not share planner helpers with the implementation that later reference
|
|
7
|
+
* models are expected to check.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as Schema from "effect/Schema"
|
|
13
|
+
import { FastCheck } from "effect/testing"
|
|
14
|
+
import * as Machine from "../../../Machine.js"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An atomic state in a finite generated model.
|
|
18
|
+
*
|
|
19
|
+
* @category models
|
|
20
|
+
* @since 0.4.0
|
|
21
|
+
*/
|
|
22
|
+
export interface FiniteAtomicState {
|
|
23
|
+
readonly _tag: "Atomic"
|
|
24
|
+
readonly key: string
|
|
25
|
+
/** Deterministic payload accepted by this state's generated schema. */
|
|
26
|
+
readonly value: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A final state in a finite generated model.
|
|
31
|
+
*
|
|
32
|
+
* @category models
|
|
33
|
+
* @since 0.4.0
|
|
34
|
+
*/
|
|
35
|
+
export interface FiniteFinalState {
|
|
36
|
+
readonly _tag: "Final"
|
|
37
|
+
readonly key: string
|
|
38
|
+
/** Deterministic payload accepted by this state's generated schema. */
|
|
39
|
+
readonly value: number
|
|
40
|
+
/** Deterministic value returned by this state's output handler. */
|
|
41
|
+
readonly output: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A compound state in a finite generated model.
|
|
46
|
+
*
|
|
47
|
+
* @category models
|
|
48
|
+
* @since 0.4.0
|
|
49
|
+
*/
|
|
50
|
+
export interface FiniteCompoundState {
|
|
51
|
+
readonly _tag: "Compound"
|
|
52
|
+
readonly key: string
|
|
53
|
+
/** Deterministic payload accepted by this state's generated schema. */
|
|
54
|
+
readonly value: number
|
|
55
|
+
/** Key of the direct child entered by default. */
|
|
56
|
+
readonly initial: string
|
|
57
|
+
readonly states: ReadonlyArray<FiniteState>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A parallel state in a finite generated model.
|
|
62
|
+
*
|
|
63
|
+
* Every direct child is an orthogonal region and is active whenever the
|
|
64
|
+
* parallel state is active. The output is deterministic so completion can be
|
|
65
|
+
* compared without sharing executable callbacks with the reference model.
|
|
66
|
+
*
|
|
67
|
+
* @category models
|
|
68
|
+
* @since 0.4.0
|
|
69
|
+
*/
|
|
70
|
+
export interface FiniteParallelState {
|
|
71
|
+
readonly _tag: "Parallel"
|
|
72
|
+
readonly key: string
|
|
73
|
+
/** Deterministic payload accepted by this state's generated schema. */
|
|
74
|
+
readonly value: number
|
|
75
|
+
/** Deterministic value returned after every region completes. */
|
|
76
|
+
readonly output: string
|
|
77
|
+
/** Between two and three orthogonal region nodes. */
|
|
78
|
+
readonly states: ReadonlyArray<FiniteState>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A shallow or deep history pseudo-state in a finite generated model.
|
|
83
|
+
*
|
|
84
|
+
* History states never carry values, become active, or act as transition
|
|
85
|
+
* sources. `fallback` is a concrete descendant of the direct compound or
|
|
86
|
+
* parallel owner and is used only before that history register is captured.
|
|
87
|
+
*
|
|
88
|
+
* @category models
|
|
89
|
+
* @since 0.4.0
|
|
90
|
+
*/
|
|
91
|
+
export interface FiniteHistoryState {
|
|
92
|
+
readonly _tag: "History"
|
|
93
|
+
readonly key: string
|
|
94
|
+
readonly history: "shallow" | "deep"
|
|
95
|
+
readonly fallback: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A deterministic transient choice pseudo-state in a finite model. */
|
|
99
|
+
export interface FiniteChoiceState {
|
|
100
|
+
readonly _tag: "Choice"
|
|
101
|
+
readonly key: string
|
|
102
|
+
readonly targets: ReadonlyArray<string>
|
|
103
|
+
readonly selected: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A finite model state.
|
|
108
|
+
*
|
|
109
|
+
* @category models
|
|
110
|
+
* @since 0.4.0
|
|
111
|
+
*/
|
|
112
|
+
export type FiniteState =
|
|
113
|
+
| FiniteAtomicState
|
|
114
|
+
| FiniteFinalState
|
|
115
|
+
| FiniteCompoundState
|
|
116
|
+
| FiniteParallelState
|
|
117
|
+
| FiniteHistoryState
|
|
118
|
+
| FiniteChoiceState
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The single trigger representation used by generated and hand-authored finite
|
|
122
|
+
* transitions. Event, always, and completion registrations therefore share the
|
|
123
|
+
* same validation, compilation, and reference interpretation path.
|
|
124
|
+
*
|
|
125
|
+
* @category models
|
|
126
|
+
* @since 0.4.0
|
|
127
|
+
*/
|
|
128
|
+
export type FiniteTransitionTrigger =
|
|
129
|
+
| { readonly type: "event"; readonly event: string }
|
|
130
|
+
| { readonly type: "always" }
|
|
131
|
+
| { readonly type: "done" }
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One deterministic transition in a finite generated model.
|
|
135
|
+
*
|
|
136
|
+
* @category models
|
|
137
|
+
* @since 0.4.0
|
|
138
|
+
*/
|
|
139
|
+
interface FiniteTransitionBase {
|
|
140
|
+
readonly source: string
|
|
141
|
+
/** Omission represents a targetless transition. */
|
|
142
|
+
readonly target?: string
|
|
143
|
+
/** Optional schema-valid value supplied for the declared target state. */
|
|
144
|
+
readonly targetValue?: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A public event transition may explicitly request source reentry.
|
|
149
|
+
*
|
|
150
|
+
* @category models
|
|
151
|
+
* @since 0.4.0
|
|
152
|
+
*/
|
|
153
|
+
export type FiniteEventTransition = FiniteTransitionBase & {
|
|
154
|
+
readonly trigger: Extract<FiniteTransitionTrigger, { readonly type: "event" }>
|
|
155
|
+
readonly reenter: boolean
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* An always or completion transition. Automatic transitions deliberately omit
|
|
160
|
+
* the event-only reentry option.
|
|
161
|
+
*
|
|
162
|
+
* @category models
|
|
163
|
+
* @since 0.4.0
|
|
164
|
+
*/
|
|
165
|
+
export type FiniteAutomaticTransition = FiniteTransitionBase & {
|
|
166
|
+
readonly trigger: Exclude<FiniteTransitionTrigger, { readonly type: "event" }>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One deterministic event or automatic transition in a finite model.
|
|
171
|
+
*
|
|
172
|
+
* @category models
|
|
173
|
+
* @since 0.4.0
|
|
174
|
+
*/
|
|
175
|
+
export type FiniteTransition = FiniteEventTransition | FiniteAutomaticTransition
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The exact generated transition that changes a value before history capture.
|
|
179
|
+
*
|
|
180
|
+
* The source and target are the same active atomic state. `value` is distinct
|
|
181
|
+
* from that state's generated default value.
|
|
182
|
+
*
|
|
183
|
+
* @category models
|
|
184
|
+
* @since 0.4.0
|
|
185
|
+
*/
|
|
186
|
+
export interface FiniteHistoryMutation {
|
|
187
|
+
readonly source: string
|
|
188
|
+
readonly event: string
|
|
189
|
+
readonly target: string
|
|
190
|
+
readonly value: number
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One exact transition in a generated history witness.
|
|
195
|
+
*
|
|
196
|
+
* @category models
|
|
197
|
+
* @since 0.4.0
|
|
198
|
+
*/
|
|
199
|
+
export interface FiniteHistoryTransfer {
|
|
200
|
+
readonly source: string
|
|
201
|
+
readonly event: string
|
|
202
|
+
readonly target: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A replayable value-mutation, capture, and restoration witness generated for
|
|
207
|
+
* one history pseudo-state.
|
|
208
|
+
*
|
|
209
|
+
* `events` is exactly `[mutation.event, leave.event, resume.event]`. Replaying
|
|
210
|
+
* it changes a schema-valid atomic value, exits the history owner through its
|
|
211
|
+
* root, and restores the remembered non-default value through `history`.
|
|
212
|
+
*
|
|
213
|
+
* @category models
|
|
214
|
+
* @since 0.4.0
|
|
215
|
+
*/
|
|
216
|
+
export interface FiniteHistoryScenario {
|
|
217
|
+
readonly history: string
|
|
218
|
+
readonly owner: string
|
|
219
|
+
readonly historyType: "shallow" | "deep"
|
|
220
|
+
readonly mutation: FiniteHistoryMutation
|
|
221
|
+
readonly leave: FiniteHistoryTransfer
|
|
222
|
+
readonly resume: FiniteHistoryTransfer
|
|
223
|
+
readonly events: readonly [mutation: string, leave: string, resume: string]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A small immutable statechart model suitable for generation and shrinking.
|
|
228
|
+
*
|
|
229
|
+
* State paths use dot-separated state keys. Transitions are unique by
|
|
230
|
+
* source/event pair. A transition may target any state under its source root,
|
|
231
|
+
* or a different root as a complete configuration.
|
|
232
|
+
*
|
|
233
|
+
* @category models
|
|
234
|
+
* @since 0.4.0
|
|
235
|
+
*/
|
|
236
|
+
export interface FiniteModel {
|
|
237
|
+
readonly roots: ReadonlyArray<FiniteState>
|
|
238
|
+
/** Key of the root entered during startup. */
|
|
239
|
+
readonly initial: string
|
|
240
|
+
readonly events: ReadonlyArray<string>
|
|
241
|
+
readonly transitions: ReadonlyArray<FiniteTransition>
|
|
242
|
+
/** Exact value-sensitive history witnesses attached by `finiteModels`. */
|
|
243
|
+
readonly historyScenarios?: ReadonlyArray<FiniteHistoryScenario>
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Limits controlling finite model generation.
|
|
248
|
+
*
|
|
249
|
+
* @category models
|
|
250
|
+
* @since 0.4.0
|
|
251
|
+
*/
|
|
252
|
+
export interface FiniteModelOptions {
|
|
253
|
+
/** Maximum number of root states. Always between one and three. */
|
|
254
|
+
readonly maxRoots?: 1 | 2 | 3
|
|
255
|
+
/** Maximum state-tree depth, counting a root as depth one. */
|
|
256
|
+
readonly maxDepth?: number
|
|
257
|
+
/** Maximum number of direct children in a compound state. */
|
|
258
|
+
readonly maxChildren?: number
|
|
259
|
+
/** Maximum number of regions in a parallel state. Always two or three. */
|
|
260
|
+
readonly maxParallelRegions?: 2 | 3
|
|
261
|
+
/** Maximum number of distinct public event tags. */
|
|
262
|
+
readonly maxEvents?: number
|
|
263
|
+
/** Maximum number of source/event transition registrations. */
|
|
264
|
+
readonly maxTransitions?: number
|
|
265
|
+
/** Maximum number of history pseudo-states added to one generated model. */
|
|
266
|
+
readonly maxHistoryStates?: number
|
|
267
|
+
/** Maximum number of deterministic choice witnesses added to a generated model. */
|
|
268
|
+
readonly maxChoiceStates?: number
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolved limits and structural guarantees for a finite model arbitrary.
|
|
273
|
+
*
|
|
274
|
+
* @category models
|
|
275
|
+
* @since 0.4.0
|
|
276
|
+
*/
|
|
277
|
+
export interface FiniteModelDiagnostics {
|
|
278
|
+
readonly limits: {
|
|
279
|
+
readonly maxRoots: 1 | 2 | 3
|
|
280
|
+
readonly maxDepth: number
|
|
281
|
+
readonly maxChildren: number
|
|
282
|
+
readonly maxParallelRegions: 2 | 3
|
|
283
|
+
readonly maxEvents: number
|
|
284
|
+
readonly maxTransitions: number
|
|
285
|
+
readonly maxHistoryStates: number
|
|
286
|
+
readonly maxChoiceStates: number
|
|
287
|
+
}
|
|
288
|
+
readonly guarantees: {
|
|
289
|
+
readonly compoundOnly: false
|
|
290
|
+
readonly parallelStates: true
|
|
291
|
+
readonly historyStates: true
|
|
292
|
+
readonly historyLeaveResumeSequences: true
|
|
293
|
+
readonly historyValueScenarios: true
|
|
294
|
+
readonly choiceStates: true
|
|
295
|
+
readonly choiceInitialWitnesses: true
|
|
296
|
+
readonly structurallyValid: true
|
|
297
|
+
readonly shrinkPreservesValidity: true
|
|
298
|
+
readonly eventlessTransitions: true
|
|
299
|
+
readonly acyclicAutomaticTransitions: true
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A finite model arbitrary and the exact limits used to construct it.
|
|
305
|
+
*
|
|
306
|
+
* @category models
|
|
307
|
+
* @since 0.4.0
|
|
308
|
+
*/
|
|
309
|
+
export interface FiniteModels {
|
|
310
|
+
readonly arbitrary: FastCheck.Arbitrary<FiniteModel>
|
|
311
|
+
readonly diagnostics: FiniteModelDiagnostics
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
interface RawAtomicState {
|
|
315
|
+
readonly _tag: "Atomic"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface RawFinalState {
|
|
319
|
+
readonly _tag: "Final"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface RawCompoundState {
|
|
323
|
+
readonly _tag: "Compound"
|
|
324
|
+
readonly initialIndex: number
|
|
325
|
+
readonly states: ReadonlyArray<RawState>
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
interface RawParallelState {
|
|
329
|
+
readonly _tag: "Parallel"
|
|
330
|
+
readonly states: ReadonlyArray<RawState>
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type RawState = RawAtomicState | RawFinalState | RawCompoundState | RawParallelState
|
|
334
|
+
|
|
335
|
+
interface FlatFiniteState {
|
|
336
|
+
readonly node: FiniteState
|
|
337
|
+
readonly path: string
|
|
338
|
+
readonly parent: string | undefined
|
|
339
|
+
readonly root: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
interface TransitionCandidate {
|
|
343
|
+
readonly source: string
|
|
344
|
+
readonly trigger: FiniteTransitionTrigger
|
|
345
|
+
readonly targets: ReadonlyArray<string>
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const triggerKey = (trigger: FiniteTransitionTrigger): string =>
|
|
349
|
+
trigger.type === "event" ? `event:${trigger.event}` : trigger.type
|
|
350
|
+
|
|
351
|
+
const defaults = {
|
|
352
|
+
maxRoots: 3 as const,
|
|
353
|
+
maxDepth: 3,
|
|
354
|
+
maxChildren: 3,
|
|
355
|
+
maxParallelRegions: 3 as const,
|
|
356
|
+
maxEvents: 3,
|
|
357
|
+
maxTransitions: 12,
|
|
358
|
+
maxHistoryStates: 2,
|
|
359
|
+
maxChoiceStates: 1
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const validateLimit = (name: string, value: number, maximum: number): void => {
|
|
363
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
364
|
+
throw new Error(`MachineTest.finiteModels expected ${name} to be an integer between 1 and ${maximum}`)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const resolveOptions = (options: FiniteModelOptions): FiniteModelDiagnostics["limits"] => {
|
|
369
|
+
const limits = {
|
|
370
|
+
maxRoots: options.maxRoots ?? defaults.maxRoots,
|
|
371
|
+
maxDepth: options.maxDepth ?? defaults.maxDepth,
|
|
372
|
+
maxChildren: options.maxChildren ?? defaults.maxChildren,
|
|
373
|
+
maxParallelRegions: options.maxParallelRegions ?? defaults.maxParallelRegions,
|
|
374
|
+
maxEvents: options.maxEvents ?? defaults.maxEvents,
|
|
375
|
+
maxTransitions: options.maxTransitions ?? defaults.maxTransitions,
|
|
376
|
+
maxHistoryStates: options.maxHistoryStates ?? defaults.maxHistoryStates,
|
|
377
|
+
maxChoiceStates: options.maxChoiceStates ?? defaults.maxChoiceStates
|
|
378
|
+
}
|
|
379
|
+
validateLimit("maxRoots", limits.maxRoots, 3)
|
|
380
|
+
validateLimit("maxDepth", limits.maxDepth, 6)
|
|
381
|
+
validateLimit("maxChildren", limits.maxChildren, 4)
|
|
382
|
+
validateLimit("maxParallelRegions", limits.maxParallelRegions, 3)
|
|
383
|
+
if (limits.maxParallelRegions < 2) {
|
|
384
|
+
throw new Error("MachineTest.finiteModels expected maxParallelRegions to be two or three")
|
|
385
|
+
}
|
|
386
|
+
validateLimit("maxEvents", limits.maxEvents, 8)
|
|
387
|
+
validateLimit("maxTransitions", limits.maxTransitions, 256)
|
|
388
|
+
if (!Number.isSafeInteger(limits.maxHistoryStates) || limits.maxHistoryStates < 0 || limits.maxHistoryStates > 8) {
|
|
389
|
+
throw new Error("MachineTest.finiteModels expected maxHistoryStates to be an integer between 0 and 8")
|
|
390
|
+
}
|
|
391
|
+
if (!Number.isSafeInteger(limits.maxChoiceStates) || limits.maxChoiceStates < 0 || limits.maxChoiceStates > 8) {
|
|
392
|
+
throw new Error("MachineTest.finiteModels expected maxChoiceStates to be an integer between 0 and 8")
|
|
393
|
+
}
|
|
394
|
+
return limits
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const rawStateArbitrary = (
|
|
398
|
+
depth: number,
|
|
399
|
+
limits: FiniteModelDiagnostics["limits"]
|
|
400
|
+
): FastCheck.Arbitrary<RawState> => {
|
|
401
|
+
const leaf = FastCheck.boolean().map((final): RawState => final ? { _tag: "Final" } : { _tag: "Atomic" })
|
|
402
|
+
if (depth >= limits.maxDepth) return leaf
|
|
403
|
+
|
|
404
|
+
const nested = rawStateArbitrary(depth + 1, limits)
|
|
405
|
+
const compound = FastCheck.array(nested, {
|
|
406
|
+
minLength: 1,
|
|
407
|
+
maxLength: limits.maxChildren
|
|
408
|
+
}).chain((states) =>
|
|
409
|
+
FastCheck.integer({ min: 0, max: states.length - 1 }).map((initialIndex): RawCompoundState => ({
|
|
410
|
+
_tag: "Compound",
|
|
411
|
+
initialIndex,
|
|
412
|
+
states
|
|
413
|
+
}))
|
|
414
|
+
)
|
|
415
|
+
const parallel = FastCheck.array(nested, {
|
|
416
|
+
minLength: 2,
|
|
417
|
+
maxLength: limits.maxParallelRegions
|
|
418
|
+
}).map((states): RawParallelState => ({ _tag: "Parallel", states }))
|
|
419
|
+
return FastCheck.oneof(leaf, compound, parallel)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const normalizeStates = (raw: ReadonlyArray<RawState>): ReadonlyArray<FiniteState> => {
|
|
423
|
+
let value = 0
|
|
424
|
+
const visit = (states: ReadonlyArray<RawState>, parentPath: string): ReadonlyArray<FiniteState> =>
|
|
425
|
+
states.map((state, index) => {
|
|
426
|
+
const key = `state${index}`
|
|
427
|
+
const path = parentPath === "" ? key : `${parentPath}.${key}`
|
|
428
|
+
const currentValue = value++
|
|
429
|
+
if (state._tag === "Atomic") {
|
|
430
|
+
return Object.freeze({ _tag: "Atomic", key, value: currentValue })
|
|
431
|
+
}
|
|
432
|
+
if (state._tag === "Final") {
|
|
433
|
+
return Object.freeze({
|
|
434
|
+
_tag: "Final",
|
|
435
|
+
key,
|
|
436
|
+
value: currentValue,
|
|
437
|
+
output: `output:${path}`
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
const children = visit(state.states, path)
|
|
441
|
+
if (state._tag === "Parallel") {
|
|
442
|
+
return Object.freeze({
|
|
443
|
+
_tag: "Parallel",
|
|
444
|
+
key,
|
|
445
|
+
value: currentValue,
|
|
446
|
+
output: `output:${path}`,
|
|
447
|
+
states: Object.freeze(children.slice())
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
return Object.freeze({
|
|
451
|
+
_tag: "Compound",
|
|
452
|
+
key,
|
|
453
|
+
value: currentValue,
|
|
454
|
+
initial: children[state.initialIndex]!.key,
|
|
455
|
+
states: Object.freeze(children.slice())
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
return Object.freeze(visit(raw, "").slice())
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const flattenStates = (roots: ReadonlyArray<FiniteState>): ReadonlyArray<FlatFiniteState> => {
|
|
462
|
+
const flattened: Array<FlatFiniteState> = []
|
|
463
|
+
const visit = (states: ReadonlyArray<FiniteState>, parent: string | undefined, root: string | undefined): void => {
|
|
464
|
+
for (const node of states) {
|
|
465
|
+
const path = parent === undefined ? node.key : `${parent}.${node.key}`
|
|
466
|
+
const nodeRoot = root ?? path
|
|
467
|
+
flattened.push({ node, path, parent, root: nodeRoot })
|
|
468
|
+
if (node._tag === "Compound" || node._tag === "Parallel") visit(node.states, path, nodeRoot)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
visit(roots, undefined, undefined)
|
|
472
|
+
return flattened
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const isPathInSubtree = (path: string, root: string): boolean => path === root || path.startsWith(`${root}.`)
|
|
476
|
+
|
|
477
|
+
const initialLeaves = (
|
|
478
|
+
states: ReadonlyMap<string, FlatFiniteState>,
|
|
479
|
+
path: string
|
|
480
|
+
): ReadonlyArray<string> => {
|
|
481
|
+
const state = states.get(path)!
|
|
482
|
+
if (state.node._tag === "Choice") return initialLeaves(states, state.node.selected)
|
|
483
|
+
if (state.node._tag === "Compound") {
|
|
484
|
+
return initialLeaves(states, `${path}.${state.node.initial}`)
|
|
485
|
+
}
|
|
486
|
+
if (state.node._tag === "Parallel") {
|
|
487
|
+
return state.node.states
|
|
488
|
+
.filter((child) => child._tag !== "History" && child._tag !== "Choice")
|
|
489
|
+
.flatMap((child) => initialLeaves(states, `${path}.${child.key}`))
|
|
490
|
+
}
|
|
491
|
+
return state.node._tag === "Atomic" ? [path] : []
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const addGeneratedHistory = (
|
|
495
|
+
roots: ReadonlyArray<FiniteState>,
|
|
496
|
+
initial: string,
|
|
497
|
+
maxHistoryStates: number,
|
|
498
|
+
maxTransitions: number,
|
|
499
|
+
events: ReadonlyArray<string>,
|
|
500
|
+
decisions: ReadonlyArray<"shallow" | "deep">
|
|
501
|
+
): {
|
|
502
|
+
readonly roots: ReadonlyArray<FiniteState>
|
|
503
|
+
readonly scenarios: ReadonlyArray<FiniteHistoryScenario>
|
|
504
|
+
} => {
|
|
505
|
+
if (maxHistoryStates === 0 || maxTransitions < 2 || decisions.length === 0) {
|
|
506
|
+
return { roots, scenarios: [] }
|
|
507
|
+
}
|
|
508
|
+
const flattened = flattenStates(roots)
|
|
509
|
+
const byPath = new Map(flattened.map((state) => [state.path, state]))
|
|
510
|
+
const initialActiveLeaves = initialLeaves(byPath, initial)
|
|
511
|
+
const initialActive = new Set(initialActiveLeaves.flatMap((leaf) => {
|
|
512
|
+
const parts = leaf.split(".")
|
|
513
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("."))
|
|
514
|
+
}))
|
|
515
|
+
const outside = roots
|
|
516
|
+
.filter((root) => root.key !== initial)
|
|
517
|
+
.map((root) => ({ root, leaves: initialLeaves(byPath, root.key) }))
|
|
518
|
+
.find(({ leaves }) => leaves.length > 0)
|
|
519
|
+
if (outside === undefined) return { roots, scenarios: [] }
|
|
520
|
+
|
|
521
|
+
const eligible = flattened.filter((state) =>
|
|
522
|
+
initialActive.has(state.path) &&
|
|
523
|
+
(state.node._tag === "Compound" || state.node._tag === "Parallel") &&
|
|
524
|
+
initialLeaves(byPath, state.path).length > 0
|
|
525
|
+
)
|
|
526
|
+
const count = Math.min(
|
|
527
|
+
maxHistoryStates,
|
|
528
|
+
decisions.length,
|
|
529
|
+
Math.floor(events.length / 2),
|
|
530
|
+
Math.floor(maxTransitions / 3),
|
|
531
|
+
eligible.length
|
|
532
|
+
)
|
|
533
|
+
const selected = eligible.slice(0, count).map((owner, index) => {
|
|
534
|
+
const composite = owner.node as FiniteCompoundState | FiniteParallelState
|
|
535
|
+
const directAtomic = composite._tag === "Compound"
|
|
536
|
+
? composite.states.find((child) => child.key === composite.initial && child._tag === "Atomic")
|
|
537
|
+
: composite.states.find((child) => child._tag === "Atomic")
|
|
538
|
+
const historyType = decisions[index] === "shallow" && directAtomic !== undefined ? "shallow" : "deep"
|
|
539
|
+
const mutationSource = historyType === "shallow"
|
|
540
|
+
? `${owner.path}.${directAtomic!.key}`
|
|
541
|
+
: initialLeaves(byPath, owner.path)[0]!
|
|
542
|
+
const fallbackChild = composite._tag === "Compound"
|
|
543
|
+
? composite.states.find((child) => child.key === composite.initial)!
|
|
544
|
+
: composite.states.find((child) => child._tag !== "History")!
|
|
545
|
+
const history: FiniteHistoryState = Object.freeze({
|
|
546
|
+
_tag: "History",
|
|
547
|
+
key: `history${index}`,
|
|
548
|
+
history: historyType,
|
|
549
|
+
fallback: `${owner.path}.${fallbackChild.key}`
|
|
550
|
+
})
|
|
551
|
+
return { owner, history, mutationSource }
|
|
552
|
+
})
|
|
553
|
+
const byOwner = new Map(selected.map(({ history, owner }) => [owner.path, history]))
|
|
554
|
+
const decorate = (states: ReadonlyArray<FiniteState>, parent: string | undefined): ReadonlyArray<FiniteState> =>
|
|
555
|
+
Object.freeze(states.map((state): FiniteState => {
|
|
556
|
+
const path = parent === undefined ? state.key : `${parent}.${state.key}`
|
|
557
|
+
if (state._tag !== "Compound" && state._tag !== "Parallel") return state
|
|
558
|
+
const children = decorate(state.states, path)
|
|
559
|
+
const history = byOwner.get(path)
|
|
560
|
+
return Object.freeze({
|
|
561
|
+
...state,
|
|
562
|
+
states: Object.freeze(history === undefined ? children.slice() : [...children, history])
|
|
563
|
+
})
|
|
564
|
+
}))
|
|
565
|
+
const decorated = decorate(roots, undefined)
|
|
566
|
+
return {
|
|
567
|
+
roots: decorated,
|
|
568
|
+
scenarios: selected.map(({ history, mutationSource, owner }, index): FiniteHistoryScenario => {
|
|
569
|
+
const historyPath = `${owner.path}.${history.key}`
|
|
570
|
+
const leaveEvent = events[index]!
|
|
571
|
+
const mutationEvent = events[count + index]!
|
|
572
|
+
const mutationValue = (byPath.get(mutationSource)!.node as FiniteAtomicState).value + 10_000 + index
|
|
573
|
+
return Object.freeze({
|
|
574
|
+
history: historyPath,
|
|
575
|
+
owner: owner.path,
|
|
576
|
+
historyType: history.history,
|
|
577
|
+
mutation: Object.freeze({
|
|
578
|
+
source: mutationSource,
|
|
579
|
+
event: mutationEvent,
|
|
580
|
+
target: mutationSource,
|
|
581
|
+
value: mutationValue
|
|
582
|
+
}),
|
|
583
|
+
leave: Object.freeze({
|
|
584
|
+
source: initialLeaves(byPath, owner.path)[0]!,
|
|
585
|
+
event: leaveEvent,
|
|
586
|
+
target: outside.root.key
|
|
587
|
+
}),
|
|
588
|
+
resume: Object.freeze({
|
|
589
|
+
source: outside.leaves[0]!,
|
|
590
|
+
event: leaveEvent,
|
|
591
|
+
target: historyPath
|
|
592
|
+
}),
|
|
593
|
+
events: Object.freeze([mutationEvent, leaveEvent, leaveEvent]) as readonly [string, string, string]
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const addGeneratedChoices = (
|
|
600
|
+
roots: ReadonlyArray<FiniteState>,
|
|
601
|
+
maxChoiceStates: number
|
|
602
|
+
): ReadonlyArray<FiniteState> => {
|
|
603
|
+
if (maxChoiceStates === 0) return roots
|
|
604
|
+
let remaining = maxChoiceStates
|
|
605
|
+
const visit = (
|
|
606
|
+
states: ReadonlyArray<FiniteState>,
|
|
607
|
+
parent: string | undefined,
|
|
608
|
+
insideParallel: boolean
|
|
609
|
+
): ReadonlyArray<FiniteState> =>
|
|
610
|
+
Object.freeze(states.map((state): FiniteState => {
|
|
611
|
+
const path = parent === undefined ? state.key : `${parent}.${state.key}`
|
|
612
|
+
if (state._tag === "Compound") {
|
|
613
|
+
const children = visit(state.states, path, insideParallel)
|
|
614
|
+
if (remaining > 0 && !insideParallel) {
|
|
615
|
+
const concrete = children.filter((child) =>
|
|
616
|
+
child._tag !== "History" && child._tag !== "Choice" && child._tag !== "Parallel"
|
|
617
|
+
)
|
|
618
|
+
if (concrete.some((child) => child.key === state.initial)) {
|
|
619
|
+
remaining -= 1
|
|
620
|
+
const choice: FiniteChoiceState = Object.freeze({
|
|
621
|
+
_tag: "Choice",
|
|
622
|
+
key: `choice${remaining}`,
|
|
623
|
+
targets: Object.freeze(concrete.map((child) => `${path}.${child.key}`)),
|
|
624
|
+
selected: `${path}.${state.initial}`
|
|
625
|
+
})
|
|
626
|
+
return Object.freeze({
|
|
627
|
+
...state,
|
|
628
|
+
initial: choice.key,
|
|
629
|
+
states: Object.freeze([...children, choice])
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return Object.freeze({ ...state, states: children })
|
|
634
|
+
}
|
|
635
|
+
if (state._tag === "Parallel") return Object.freeze({ ...state, states: visit(state.states, path, true) })
|
|
636
|
+
return state
|
|
637
|
+
}))
|
|
638
|
+
return visit(roots, undefined, false)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const freezeModel = (
|
|
642
|
+
roots: ReadonlyArray<FiniteState>,
|
|
643
|
+
initial: string,
|
|
644
|
+
events: ReadonlyArray<string>,
|
|
645
|
+
transitions: ReadonlyArray<FiniteTransition>,
|
|
646
|
+
historyScenarios: ReadonlyArray<FiniteHistoryScenario> = []
|
|
647
|
+
): FiniteModel =>
|
|
648
|
+
Object.freeze({
|
|
649
|
+
roots,
|
|
650
|
+
initial,
|
|
651
|
+
events: Object.freeze(events.slice()),
|
|
652
|
+
transitions: Object.freeze(transitions.map((transition) => Object.freeze(transition))),
|
|
653
|
+
historyScenarios: Object.freeze(historyScenarios.slice())
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
const makeTransitionArbitrary = (
|
|
657
|
+
roots: ReadonlyArray<FiniteState>,
|
|
658
|
+
initial: string,
|
|
659
|
+
events: ReadonlyArray<string>,
|
|
660
|
+
maxTransitions: number,
|
|
661
|
+
historyScenarios: ReadonlyArray<FiniteHistoryScenario> = []
|
|
662
|
+
): FastCheck.Arbitrary<ReadonlyArray<FiniteTransition>> => {
|
|
663
|
+
const states = flattenStates(roots)
|
|
664
|
+
const sourceOrder = new Map(states.map((state, index) => [state.path, index]))
|
|
665
|
+
const stateByPath = new Map(states.map((state) => [state.path, state]))
|
|
666
|
+
const initiallyActive = new Set(
|
|
667
|
+
initialLeaves(stateByPath, initial).flatMap((leaf) => {
|
|
668
|
+
const parts = leaf.split(".")
|
|
669
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("."))
|
|
670
|
+
})
|
|
671
|
+
)
|
|
672
|
+
const triggerOrder = (trigger: FiniteTransitionTrigger): number =>
|
|
673
|
+
trigger.type === "event"
|
|
674
|
+
? events.indexOf(trigger.event)
|
|
675
|
+
: events.length + (trigger.type === "always" ? 0 : 1)
|
|
676
|
+
const orderTransitions = (transitions: ReadonlyArray<FiniteTransition>): ReadonlyArray<FiniteTransition> =>
|
|
677
|
+
transitions.slice().sort((left, right) =>
|
|
678
|
+
sourceOrder.get(left.source)! - sourceOrder.get(right.source)! ||
|
|
679
|
+
triggerOrder(left.trigger) - triggerOrder(right.trigger)
|
|
680
|
+
)
|
|
681
|
+
const active = states.filter(({ node }) => node._tag !== "Final" && node._tag !== "History" && node._tag !== "Choice")
|
|
682
|
+
const mandatory = historyScenarios.flatMap((scenario): ReadonlyArray<FiniteTransition> => [
|
|
683
|
+
{
|
|
684
|
+
source: scenario.mutation.source,
|
|
685
|
+
trigger: { type: "event", event: scenario.mutation.event },
|
|
686
|
+
target: scenario.mutation.target,
|
|
687
|
+
targetValue: scenario.mutation.value,
|
|
688
|
+
reenter: false
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
source: scenario.leave.source,
|
|
692
|
+
trigger: { type: "event", event: scenario.leave.event },
|
|
693
|
+
target: scenario.leave.target,
|
|
694
|
+
reenter: false
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
source: scenario.resume.source,
|
|
698
|
+
trigger: { type: "event", event: scenario.resume.event },
|
|
699
|
+
target: scenario.resume.target,
|
|
700
|
+
reenter: false
|
|
701
|
+
}
|
|
702
|
+
])
|
|
703
|
+
const mandatoryRegistrations = new Set(
|
|
704
|
+
mandatory.map(({ source, trigger }) => `${source}\u0000${triggerKey(trigger)}`)
|
|
705
|
+
)
|
|
706
|
+
const reservedEvents = new Set(
|
|
707
|
+
historyScenarios.flatMap(({ leave, mutation }) => [leave.event, mutation.event])
|
|
708
|
+
)
|
|
709
|
+
const eventCandidates = active.flatMap(({ path: source, root }) =>
|
|
710
|
+
events.map((event): TransitionCandidate => ({
|
|
711
|
+
source,
|
|
712
|
+
trigger: { type: "event", event },
|
|
713
|
+
// `branch` addresses the source root while `full` replaces another root.
|
|
714
|
+
// Same-root targets may select any state, including a parallel state or
|
|
715
|
+
// a compound state whose initial descent enters a parallel state. The
|
|
716
|
+
// compiler expands those targets into every required orthogonal region.
|
|
717
|
+
targets: states.filter((target) =>
|
|
718
|
+
target.node._tag !== "History" && (target.root === root || target.parent === undefined)
|
|
719
|
+
)
|
|
720
|
+
.map(({ path }) => path)
|
|
721
|
+
}))
|
|
722
|
+
).filter(({ source, trigger }) =>
|
|
723
|
+
!mandatoryRegistrations.has(`${source}\u0000${triggerKey(trigger)}`) &&
|
|
724
|
+
(trigger.type !== "event" || !reservedEvents.has(trigger.event))
|
|
725
|
+
)
|
|
726
|
+
const exitsSourceTargets = (source: FlatFiniteState): ReadonlyArray<string> =>
|
|
727
|
+
states.filter((target, targetIndex) => {
|
|
728
|
+
if (
|
|
729
|
+
targetIndex <= sourceOrder.get(source.path)! || target.node._tag === "History" ||
|
|
730
|
+
target.node._tag === "Choice"
|
|
731
|
+
) return false
|
|
732
|
+
// A generated automatic transition must make its source inactive. This
|
|
733
|
+
// gives the independent oracle a structurally acyclic witness instead of
|
|
734
|
+
// relying on runtime iteration bounds. A later sibling under a compound
|
|
735
|
+
// parent exits the source branch; a later root replaces the whole root.
|
|
736
|
+
if (source.parent === undefined) {
|
|
737
|
+
return target.parent === undefined && target.root !== source.root
|
|
738
|
+
}
|
|
739
|
+
return stateByPath.get(source.parent)?.node._tag === "Compound" && target.parent === source.parent
|
|
740
|
+
}).map(({ path }) => path)
|
|
741
|
+
const automaticCandidates: ReadonlyArray<TransitionCandidate> = historyScenarios.length === 0
|
|
742
|
+
? [
|
|
743
|
+
...active.flatMap((source): ReadonlyArray<TransitionCandidate> =>
|
|
744
|
+
source.node._tag !== "Atomic" ? [] : [{
|
|
745
|
+
source: source.path,
|
|
746
|
+
trigger: { type: "always" },
|
|
747
|
+
targets: exitsSourceTargets(source)
|
|
748
|
+
}]
|
|
749
|
+
),
|
|
750
|
+
...states.flatMap((source): ReadonlyArray<TransitionCandidate> =>
|
|
751
|
+
source.node._tag !== "Compound" && source.node._tag !== "Parallel" ? [] : [{
|
|
752
|
+
source: source.path,
|
|
753
|
+
trigger: { type: "done" },
|
|
754
|
+
targets: exitsSourceTargets(source)
|
|
755
|
+
}]
|
|
756
|
+
)
|
|
757
|
+
].filter(({ targets }) => targets.length > 0)
|
|
758
|
+
: []
|
|
759
|
+
const completesOnEntry = (path: string): boolean => {
|
|
760
|
+
const state = stateByPath.get(path)!
|
|
761
|
+
if (state.node._tag === "Final") return true
|
|
762
|
+
if (state.node._tag === "Compound") {
|
|
763
|
+
const node = state.node
|
|
764
|
+
return node.states.some(({ key, _tag }) => key === node.initial && _tag === "Final")
|
|
765
|
+
}
|
|
766
|
+
if (state.node._tag === "Parallel") {
|
|
767
|
+
return state.node.states
|
|
768
|
+
.filter(({ _tag }) => _tag !== "History" && _tag !== "Choice")
|
|
769
|
+
.every((child) => completesOnEntry(`${path}.${child.key}`))
|
|
770
|
+
}
|
|
771
|
+
return false
|
|
772
|
+
}
|
|
773
|
+
const materialize = (
|
|
774
|
+
selected: ReadonlyArray<TransitionCandidate>,
|
|
775
|
+
allowTargetlessEvents = true
|
|
776
|
+
): FastCheck.Arbitrary<ReadonlyArray<FiniteTransition>> => {
|
|
777
|
+
if (selected.length === 0) return FastCheck.constant([])
|
|
778
|
+
const decisions = selected.map((candidate) =>
|
|
779
|
+
FastCheck.record({
|
|
780
|
+
// Targetless event transitions remain useful witnesses. Generated
|
|
781
|
+
// automatic transitions always exit their source so stabilization is
|
|
782
|
+
// acyclic by construction; targetless automatic semantics are covered
|
|
783
|
+
// by focused examples rather than mixed into the finite-model oracle.
|
|
784
|
+
targetIndex: FastCheck.integer({
|
|
785
|
+
min: candidate.trigger.type === "event" && allowTargetlessEvents ? 0 : 1,
|
|
786
|
+
max: candidate.targets.length
|
|
787
|
+
}),
|
|
788
|
+
targetValueOffset: FastCheck.integer({ min: 0, max: 2 }),
|
|
789
|
+
reenter: FastCheck.boolean()
|
|
790
|
+
})
|
|
791
|
+
)
|
|
792
|
+
return FastCheck.tuple(...decisions).map((values) =>
|
|
793
|
+
selected.map((candidate, index): FiniteTransition => {
|
|
794
|
+
const decision = values[index]!
|
|
795
|
+
const target = decision.targetIndex === 0 ? undefined : candidate.targets[decision.targetIndex - 1]
|
|
796
|
+
const targetState = target === undefined ? undefined : states.find(({ path }) => path === target)
|
|
797
|
+
const targetValue = targetState?.node._tag === "Atomic" &&
|
|
798
|
+
decision.targetValueOffset !== 0
|
|
799
|
+
? targetState.node.value + decision.targetValueOffset
|
|
800
|
+
: undefined
|
|
801
|
+
const targetFields = target === undefined
|
|
802
|
+
? {}
|
|
803
|
+
: { target, ...(targetValue === undefined ? {} : { targetValue }) }
|
|
804
|
+
const trigger = candidate.trigger
|
|
805
|
+
return trigger.type === "event"
|
|
806
|
+
? { source: candidate.source, trigger, reenter: decision.reenter, ...targetFields }
|
|
807
|
+
: { source: candidate.source, trigger, ...targetFields }
|
|
808
|
+
})
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
const optionalBudget = maxTransitions - mandatory.length
|
|
812
|
+
const general = FastCheck.subarray([...eventCandidates, ...automaticCandidates], {
|
|
813
|
+
minLength: 0,
|
|
814
|
+
maxLength: Math.min(optionalBudget, eventCandidates.length + automaticCandidates.length)
|
|
815
|
+
}).chain((selected) => materialize(selected).map((transitions) => orderTransitions([...mandatory, ...transitions])))
|
|
816
|
+
|
|
817
|
+
if (mandatory.length > 0 || optionalBudget < 2 || automaticCandidates.length === 0) return general
|
|
818
|
+
|
|
819
|
+
// Bias one branch toward a reachable public-event -> automatic-transition
|
|
820
|
+
// chain. The automatic edge itself still moves forward in document order,
|
|
821
|
+
// so combining the two trigger kinds cannot introduce an automatic cycle.
|
|
822
|
+
const chainCandidates = eventCandidates.flatMap((eventCandidate) => {
|
|
823
|
+
const source = stateByPath.get(eventCandidate.source)!
|
|
824
|
+
if (source.node._tag !== "Atomic" || !initiallyActive.has(source.path)) return []
|
|
825
|
+
return automaticCandidates.flatMap((automaticCandidate) => {
|
|
826
|
+
if (
|
|
827
|
+
automaticCandidate.source === eventCandidate.source ||
|
|
828
|
+
!eventCandidate.targets.includes(automaticCandidate.source) ||
|
|
829
|
+
(automaticCandidate.trigger.type === "done" && !completesOnEntry(automaticCandidate.source))
|
|
830
|
+
) return []
|
|
831
|
+
return [{
|
|
832
|
+
event: { ...eventCandidate, targets: [automaticCandidate.source] },
|
|
833
|
+
automatic: automaticCandidate
|
|
834
|
+
}]
|
|
835
|
+
})
|
|
836
|
+
})
|
|
837
|
+
if (chainCandidates.length === 0) return general
|
|
838
|
+
|
|
839
|
+
const chain = FastCheck.constantFrom(...chainCandidates).chain(({ automatic, event }) =>
|
|
840
|
+
materialize([event, automatic], false).map((transitions) => orderTransitions(transitions))
|
|
841
|
+
)
|
|
842
|
+
return FastCheck.oneof(general, chain)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Generates bounded, structurally valid hierarchical statechart
|
|
847
|
+
* models.
|
|
848
|
+
*
|
|
849
|
+
* Generation is composed from shrinkable topology, initial-state, event, and
|
|
850
|
+
* transition decisions. Paths and transition candidates are rebuilt after a
|
|
851
|
+
* topology shrink, so a shrink can never retain a dangling source or target.
|
|
852
|
+
* Generated automatic transitions are acyclic by construction: `always`
|
|
853
|
+
* transitions leave atomic sources and completion transitions leave compound
|
|
854
|
+
* or parallel sources. Separate focused witnesses cover cyclic stabilization.
|
|
855
|
+
*
|
|
856
|
+
* @category constructors
|
|
857
|
+
* @since 0.4.0
|
|
858
|
+
*/
|
|
859
|
+
export const finiteModels = (options: FiniteModelOptions = {}): FiniteModels => {
|
|
860
|
+
const limits = resolveOptions(options)
|
|
861
|
+
const rawRoots = FastCheck.array(rawStateArbitrary(1, limits), {
|
|
862
|
+
minLength: 1,
|
|
863
|
+
maxLength: limits.maxRoots
|
|
864
|
+
})
|
|
865
|
+
const arbitrary = rawRoots.chain((raw) => {
|
|
866
|
+
const activeRoots = normalizeStates(raw)
|
|
867
|
+
return FastCheck.tuple(
|
|
868
|
+
FastCheck.integer({ min: 0, max: activeRoots.length - 1 }),
|
|
869
|
+
FastCheck.integer({ min: 1, max: limits.maxEvents }),
|
|
870
|
+
FastCheck.array(FastCheck.constantFrom("shallow" as const, "deep" as const), {
|
|
871
|
+
minLength: 0,
|
|
872
|
+
maxLength: limits.maxHistoryStates
|
|
873
|
+
})
|
|
874
|
+
).chain(([initialIndex, eventCount, historyDecisions]) => {
|
|
875
|
+
const events = Array.from({ length: eventCount }, (_, index) => `Event${index}`)
|
|
876
|
+
const initial = activeRoots[initialIndex]!.key
|
|
877
|
+
const generated = addGeneratedHistory(
|
|
878
|
+
activeRoots,
|
|
879
|
+
initial,
|
|
880
|
+
limits.maxHistoryStates,
|
|
881
|
+
limits.maxTransitions,
|
|
882
|
+
events,
|
|
883
|
+
historyDecisions
|
|
884
|
+
)
|
|
885
|
+
const roots = generated.scenarios.length === 0
|
|
886
|
+
? addGeneratedChoices(generated.roots, limits.maxChoiceStates)
|
|
887
|
+
: generated.roots
|
|
888
|
+
return makeTransitionArbitrary(roots, initial, events, limits.maxTransitions, generated.scenarios).map(
|
|
889
|
+
(transitions) => freezeModel(roots, initial, events, transitions, generated.scenarios)
|
|
890
|
+
)
|
|
891
|
+
})
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
arbitrary,
|
|
896
|
+
diagnostics: {
|
|
897
|
+
limits,
|
|
898
|
+
guarantees: {
|
|
899
|
+
compoundOnly: false,
|
|
900
|
+
parallelStates: true,
|
|
901
|
+
historyStates: true,
|
|
902
|
+
historyLeaveResumeSequences: true,
|
|
903
|
+
historyValueScenarios: true,
|
|
904
|
+
choiceStates: true,
|
|
905
|
+
choiceInitialWitnesses: true,
|
|
906
|
+
structurallyValid: true,
|
|
907
|
+
shrinkPreservesValidity: true,
|
|
908
|
+
eventlessTransitions: true,
|
|
909
|
+
acyclicAutomaticTransitions: true
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const isValidKey = (key: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
|
|
916
|
+
|
|
917
|
+
const validateModel = (model: FiniteModel): ReadonlyArray<FlatFiniteState> => {
|
|
918
|
+
if (!Array.isArray(model.roots) || model.roots.length < 1 || model.roots.length > 3) {
|
|
919
|
+
throw new Error("MachineTest.compileModel expected between one and three root states")
|
|
920
|
+
}
|
|
921
|
+
const visit = (states: ReadonlyArray<FiniteState>, parent: string | undefined): void => {
|
|
922
|
+
if (states.length === 0) {
|
|
923
|
+
throw new Error(`MachineTest.compileModel expected ${parent ?? "the model"} to contain a state`)
|
|
924
|
+
}
|
|
925
|
+
const siblingKeys = new Set<string>()
|
|
926
|
+
for (const node of states) {
|
|
927
|
+
if (!isValidKey(node.key)) {
|
|
928
|
+
throw new Error(`MachineTest.compileModel received invalid state key "${node.key}"`)
|
|
929
|
+
}
|
|
930
|
+
if (siblingKeys.has(node.key)) {
|
|
931
|
+
throw new Error(`MachineTest.compileModel received duplicate state key "${node.key}"`)
|
|
932
|
+
}
|
|
933
|
+
siblingKeys.add(node.key)
|
|
934
|
+
const path = parent === undefined ? node.key : `${parent}.${node.key}`
|
|
935
|
+
if (node._tag !== "History" && node._tag !== "Choice" && !Number.isSafeInteger(node.value)) {
|
|
936
|
+
throw new Error(`MachineTest.compileModel expected state "${path}" value to be a safe integer`)
|
|
937
|
+
}
|
|
938
|
+
if (node._tag === "History") {
|
|
939
|
+
if (parent === undefined) {
|
|
940
|
+
throw new Error(`MachineTest.compileModel received root history state "${path}"`)
|
|
941
|
+
}
|
|
942
|
+
if (node.history !== "shallow" && node.history !== "deep") {
|
|
943
|
+
throw new Error(`MachineTest.compileModel received invalid history mode at "${path}"`)
|
|
944
|
+
}
|
|
945
|
+
continue
|
|
946
|
+
}
|
|
947
|
+
if (node._tag === "Choice") {
|
|
948
|
+
if (parent === undefined || node.targets.length === 0 || !node.targets.includes(node.selected)) {
|
|
949
|
+
throw new Error(`MachineTest.compileModel received invalid choice state "${path}"`)
|
|
950
|
+
}
|
|
951
|
+
continue
|
|
952
|
+
}
|
|
953
|
+
if (node._tag === "Compound") {
|
|
954
|
+
if (!node.states.some((child) => child.key === node.initial && child._tag !== "History")) {
|
|
955
|
+
throw new Error(`MachineTest.compileModel received unknown initial child "${node.initial}" for "${path}"`)
|
|
956
|
+
}
|
|
957
|
+
visit(node.states, path)
|
|
958
|
+
} else if (node._tag === "Parallel") {
|
|
959
|
+
const regions = node.states.filter((child) => child._tag !== "History" && child._tag !== "Choice")
|
|
960
|
+
if (regions.length < 2 || regions.length > 3) {
|
|
961
|
+
throw new Error(`MachineTest.compileModel expected parallel state "${path}" to contain two or three regions`)
|
|
962
|
+
}
|
|
963
|
+
if (typeof node.output !== "string") {
|
|
964
|
+
throw new Error(`MachineTest.compileModel expected parallel state "${path}" output to be a string`)
|
|
965
|
+
}
|
|
966
|
+
visit(node.states, path)
|
|
967
|
+
} else if (node._tag === "Final" && typeof node.output !== "string") {
|
|
968
|
+
throw new Error(`MachineTest.compileModel expected final state "${path}" output to be a string`)
|
|
969
|
+
} else if (node._tag !== "Atomic" && node._tag !== "Final") {
|
|
970
|
+
throw new Error(`MachineTest.compileModel received unsupported state type at "${path}"`)
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
visit(model.roots, undefined)
|
|
975
|
+
|
|
976
|
+
if (!model.roots.some((root) => root.key === model.initial)) {
|
|
977
|
+
throw new Error(`MachineTest.compileModel received unknown initial root "${model.initial}"`)
|
|
978
|
+
}
|
|
979
|
+
const events = new Set<string>()
|
|
980
|
+
for (const event of model.events) {
|
|
981
|
+
if (typeof event !== "string" || event.length === 0 || events.has(event)) {
|
|
982
|
+
throw new Error(`MachineTest.compileModel received invalid or duplicate event tag "${String(event)}"`)
|
|
983
|
+
}
|
|
984
|
+
events.add(event)
|
|
985
|
+
}
|
|
986
|
+
if (events.size === 0) {
|
|
987
|
+
throw new Error("MachineTest.compileModel expected at least one event tag")
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const flattened = flattenStates(model.roots)
|
|
991
|
+
const byPath = new Map(flattened.map((state) => [state.path, state]))
|
|
992
|
+
for (const state of flattened) {
|
|
993
|
+
if (state.node._tag !== "Choice") continue
|
|
994
|
+
const owner = state.parent === undefined ? undefined : byPath.get(state.parent)
|
|
995
|
+
if (owner?.node._tag !== "Compound") {
|
|
996
|
+
throw new Error(`MachineTest.compileModel expected choice state "${state.path}" to belong to a compound state`)
|
|
997
|
+
}
|
|
998
|
+
for (const targetPath of state.node.targets) {
|
|
999
|
+
const target = byPath.get(targetPath)
|
|
1000
|
+
if (
|
|
1001
|
+
target === undefined || target.node._tag === "History" || target.node._tag === "Choice" ||
|
|
1002
|
+
target.parent !== owner.path
|
|
1003
|
+
) {
|
|
1004
|
+
throw new Error(`MachineTest.compileModel received invalid choice target "${targetPath}"`)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
for (const state of flattened) {
|
|
1009
|
+
if (state.node._tag !== "History") continue
|
|
1010
|
+
const owner = state.parent === undefined ? undefined : byPath.get(state.parent)
|
|
1011
|
+
const fallback = byPath.get(state.node.fallback)
|
|
1012
|
+
if (
|
|
1013
|
+
owner === undefined ||
|
|
1014
|
+
(owner.node._tag !== "Compound" && owner.node._tag !== "Parallel") ||
|
|
1015
|
+
fallback === undefined ||
|
|
1016
|
+
fallback.node._tag === "History" ||
|
|
1017
|
+
fallback.path === owner.path ||
|
|
1018
|
+
!isPathInSubtree(fallback.path, owner.path)
|
|
1019
|
+
) {
|
|
1020
|
+
throw new Error(
|
|
1021
|
+
`MachineTest.compileModel expected history state "${state.path}" fallback to be a concrete descendant of "${state.parent}"`
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const registrations = new Set<string>()
|
|
1026
|
+
const transitionsByRegistration = new Map<string, FiniteTransition>()
|
|
1027
|
+
for (const transition of model.transitions) {
|
|
1028
|
+
const source = byPath.get(transition.source)
|
|
1029
|
+
if (
|
|
1030
|
+
source === undefined || source.node._tag === "Final" || source.node._tag === "History" ||
|
|
1031
|
+
source.node._tag === "Choice"
|
|
1032
|
+
) {
|
|
1033
|
+
throw new Error(`MachineTest.compileModel received invalid transition source "${transition.source}"`)
|
|
1034
|
+
}
|
|
1035
|
+
if (transition.trigger.type === "event" && !events.has(transition.trigger.event)) {
|
|
1036
|
+
throw new Error(`MachineTest.compileModel received unknown transition event "${transition.trigger.event}"`)
|
|
1037
|
+
}
|
|
1038
|
+
if (
|
|
1039
|
+
transition.trigger.type === "done" && source.node._tag !== "Compound" && source.node._tag !== "Parallel"
|
|
1040
|
+
) {
|
|
1041
|
+
throw new Error(
|
|
1042
|
+
`MachineTest.compileModel received completion transition for non-composite "${transition.source}"`
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
if (transition.trigger.type !== "event" && "reenter" in transition) {
|
|
1046
|
+
throw new Error(`MachineTest.compileModel received event-only reenter option from "${transition.source}"`)
|
|
1047
|
+
}
|
|
1048
|
+
const registration = `${transition.source}\u0000${triggerKey(transition.trigger)}`
|
|
1049
|
+
if (registrations.has(registration)) {
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
`MachineTest.compileModel received duplicate transition for "${transition.source}" on ` +
|
|
1052
|
+
`"${triggerKey(transition.trigger)}"`
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
registrations.add(registration)
|
|
1056
|
+
transitionsByRegistration.set(registration, transition)
|
|
1057
|
+
if (transition.target !== undefined) {
|
|
1058
|
+
const target = byPath.get(transition.target)
|
|
1059
|
+
if (target === undefined) {
|
|
1060
|
+
throw new Error(`MachineTest.compileModel received unknown transition target "${transition.target}"`)
|
|
1061
|
+
}
|
|
1062
|
+
if (target.node._tag !== "History" && target.root !== source.root && target.parent !== undefined) {
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`MachineTest.compileModel expected cross-root target "${transition.target}" to select its root "${target.root}"`
|
|
1065
|
+
)
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (
|
|
1069
|
+
transition.targetValue !== undefined &&
|
|
1070
|
+
(!Number.isSafeInteger(transition.targetValue) || transition.target === undefined ||
|
|
1071
|
+
byPath.get(transition.target)?.node._tag === "History" ||
|
|
1072
|
+
byPath.get(transition.target)?.node._tag === "Choice")
|
|
1073
|
+
) {
|
|
1074
|
+
throw new Error(
|
|
1075
|
+
`MachineTest.compileModel received invalid target value for transition from "${transition.source}"`
|
|
1076
|
+
)
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const initiallyActiveLeaves = initialLeaves(byPath, model.initial)
|
|
1080
|
+
const initiallyActive = new Set(initiallyActiveLeaves.flatMap((leaf) => {
|
|
1081
|
+
const parts = leaf.split(".")
|
|
1082
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("."))
|
|
1083
|
+
}))
|
|
1084
|
+
const scenarioHistories = new Set<string>()
|
|
1085
|
+
const scenarioEvents = new Set<string>()
|
|
1086
|
+
const scenarioRegistrations = new Set<string>()
|
|
1087
|
+
for (const scenario of model.historyScenarios ?? []) {
|
|
1088
|
+
const history = byPath.get(scenario.history)
|
|
1089
|
+
const mutationState = byPath.get(scenario.mutation.source)
|
|
1090
|
+
if (
|
|
1091
|
+
history?.node._tag !== "History" || history.parent !== scenario.owner ||
|
|
1092
|
+
history.node.history !== scenario.historyType
|
|
1093
|
+
) {
|
|
1094
|
+
throw new Error(`MachineTest.compileModel received invalid history scenario for "${scenario.history}"`)
|
|
1095
|
+
}
|
|
1096
|
+
if (scenarioHistories.has(scenario.history)) {
|
|
1097
|
+
throw new Error(`MachineTest.compileModel received duplicate history scenario for "${scenario.history}"`)
|
|
1098
|
+
}
|
|
1099
|
+
scenarioHistories.add(scenario.history)
|
|
1100
|
+
const owner = byPath.get(scenario.owner)!
|
|
1101
|
+
const ownerNode = owner.node
|
|
1102
|
+
let expectedMutationSource: string | undefined
|
|
1103
|
+
if (scenario.historyType === "deep") {
|
|
1104
|
+
expectedMutationSource = initialLeaves(byPath, owner.path)[0]
|
|
1105
|
+
} else if (ownerNode._tag === "Compound") {
|
|
1106
|
+
if (ownerNode.states.some((child) => child.key === ownerNode.initial && child._tag === "Atomic")) {
|
|
1107
|
+
expectedMutationSource = `${owner.path}.${ownerNode.initial}`
|
|
1108
|
+
}
|
|
1109
|
+
} else if (ownerNode._tag === "Parallel") {
|
|
1110
|
+
const directAtomic = ownerNode.states.find((child) => child._tag === "Atomic")
|
|
1111
|
+
if (directAtomic !== undefined) expectedMutationSource = `${owner.path}.${directAtomic.key}`
|
|
1112
|
+
}
|
|
1113
|
+
if (
|
|
1114
|
+
mutationState?.node._tag !== "Atomic" || scenario.mutation.target !== scenario.mutation.source ||
|
|
1115
|
+
scenario.mutation.value === mutationState.node.value || !initiallyActive.has(mutationState.path) ||
|
|
1116
|
+
scenario.mutation.source !== expectedMutationSource
|
|
1117
|
+
) {
|
|
1118
|
+
throw new Error(`MachineTest.compileModel received invalid history mutation for "${scenario.history}"`)
|
|
1119
|
+
}
|
|
1120
|
+
if (
|
|
1121
|
+
!Array.isArray(scenario.events) || scenario.events.length !== 3 ||
|
|
1122
|
+
scenario.resume.target !== scenario.history || scenario.leave.event !== scenario.resume.event ||
|
|
1123
|
+
scenario.events[0] !== scenario.mutation.event || scenario.events[1] !== scenario.leave.event ||
|
|
1124
|
+
scenario.events[2] !== scenario.resume.event
|
|
1125
|
+
) {
|
|
1126
|
+
throw new Error(`MachineTest.compileModel received inconsistent history events for "${scenario.history}"`)
|
|
1127
|
+
}
|
|
1128
|
+
const expectedOutside = model.roots
|
|
1129
|
+
.filter((root) => root.key !== model.initial)
|
|
1130
|
+
.find((root) => initialLeaves(byPath, root.key).length > 0)
|
|
1131
|
+
if (
|
|
1132
|
+
scenario.leave.source !== initialLeaves(byPath, owner.path)[0] ||
|
|
1133
|
+
scenario.leave.target !== expectedOutside?.key ||
|
|
1134
|
+
scenario.resume.source !==
|
|
1135
|
+
(expectedOutside === undefined ? undefined : initialLeaves(byPath, expectedOutside.key)[0])
|
|
1136
|
+
) {
|
|
1137
|
+
throw new Error(`MachineTest.compileModel received invalid history transfer for "${scenario.history}"`)
|
|
1138
|
+
}
|
|
1139
|
+
const expectedTransitions: ReadonlyArray<readonly [FiniteHistoryTransfer | FiniteHistoryMutation, number?]> = [
|
|
1140
|
+
[scenario.mutation, scenario.mutation.value],
|
|
1141
|
+
[scenario.leave],
|
|
1142
|
+
[scenario.resume]
|
|
1143
|
+
]
|
|
1144
|
+
if (scenarioEvents.has(scenario.mutation.event) || scenarioEvents.has(scenario.leave.event)) {
|
|
1145
|
+
throw new Error(`MachineTest.compileModel received duplicate history scenario event for "${scenario.history}"`)
|
|
1146
|
+
}
|
|
1147
|
+
scenarioEvents.add(scenario.mutation.event)
|
|
1148
|
+
scenarioEvents.add(scenario.leave.event)
|
|
1149
|
+
for (const [expected, targetValue] of expectedTransitions) {
|
|
1150
|
+
const registration = `${expected.source}\u0000${triggerKey({ type: "event", event: expected.event })}`
|
|
1151
|
+
if (scenarioRegistrations.has(registration)) {
|
|
1152
|
+
throw new Error(`MachineTest.compileModel received duplicate history witness for "${scenario.history}"`)
|
|
1153
|
+
}
|
|
1154
|
+
scenarioRegistrations.add(registration)
|
|
1155
|
+
const transition = transitionsByRegistration.get(registration)
|
|
1156
|
+
if (
|
|
1157
|
+
transition?.trigger.type !== "event" || transition.trigger.event !== expected.event ||
|
|
1158
|
+
transition.target !== expected.target || !("reenter" in transition) || transition.reenter ||
|
|
1159
|
+
transition.targetValue !== targetValue
|
|
1160
|
+
) {
|
|
1161
|
+
throw new Error(`MachineTest.compileModel could not replay history scenario for "${scenario.history}"`)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return flattened
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const stateTag = (path: string): string => `State_${path.replaceAll(".", "_")}`
|
|
1169
|
+
|
|
1170
|
+
const stateValue = (
|
|
1171
|
+
state: FlatFiniteState,
|
|
1172
|
+
value?: number
|
|
1173
|
+
): { readonly _tag: string; readonly value: number } => {
|
|
1174
|
+
if (state.node._tag === "History" || state.node._tag === "Choice") {
|
|
1175
|
+
throw new Error(`MachineTest.compileModel cannot construct a value for pseudo-state "${state.path}"`)
|
|
1176
|
+
}
|
|
1177
|
+
return { _tag: stateTag(state.path), value: value ?? state.node.value }
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const runtimeTargetPath = (
|
|
1181
|
+
byPath: ReadonlyMap<string, FlatFiniteState>,
|
|
1182
|
+
sourcePath: string,
|
|
1183
|
+
requestedPath: string
|
|
1184
|
+
): string => {
|
|
1185
|
+
const source = byPath.get(sourcePath)!
|
|
1186
|
+
const requested = byPath.get(requestedPath)!
|
|
1187
|
+
if (requested.node._tag === "History") return requested.parent!
|
|
1188
|
+
if (requested.node._tag === "Choice") return runtimeTargetPath(byPath, sourcePath, requested.node.selected)
|
|
1189
|
+
if (source.root !== requested.root) return requested.path
|
|
1190
|
+
|
|
1191
|
+
const initial = (path: string): string => {
|
|
1192
|
+
const current = byPath.get(path)!
|
|
1193
|
+
if (current.node._tag === "Parallel") {
|
|
1194
|
+
if (sourcePath !== current.path && !sourcePath.startsWith(`${current.path}.`)) {
|
|
1195
|
+
return current.path
|
|
1196
|
+
}
|
|
1197
|
+
const child = sourcePath === current.path
|
|
1198
|
+
? current.node.states[0]!.key
|
|
1199
|
+
: sourcePath.slice(current.path.length + 1).split(".")[0]!
|
|
1200
|
+
return initial(`${current.path}.${child}`)
|
|
1201
|
+
}
|
|
1202
|
+
return current.node._tag === "Compound" ? initial(`${current.path}.${current.node.initial}`) : current.path
|
|
1203
|
+
}
|
|
1204
|
+
const inspect = (path: string): string => {
|
|
1205
|
+
const current = byPath.get(path)!
|
|
1206
|
+
if (
|
|
1207
|
+
current.node._tag === "Parallel" && sourcePath !== current.path && !sourcePath.startsWith(`${current.path}.`)
|
|
1208
|
+
) {
|
|
1209
|
+
return current.path
|
|
1210
|
+
}
|
|
1211
|
+
if (current.path === requestedPath) return initial(current.path)
|
|
1212
|
+
const next = requestedPath.slice(current.path.length + 1).split(".")[0]!
|
|
1213
|
+
return inspect(`${current.path}.${next}`)
|
|
1214
|
+
}
|
|
1215
|
+
return inspect(source.root)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const makeStateTree = (
|
|
1219
|
+
states: ReadonlyArray<FiniteState>,
|
|
1220
|
+
parent: string | undefined
|
|
1221
|
+
): Record<string, Machine.Machine.TaggedSchema | Machine.Machine.StateNodeConfig> => {
|
|
1222
|
+
const tree: Record<string, Machine.Machine.TaggedSchema | Machine.Machine.StateNodeConfig> = Object.create(null)
|
|
1223
|
+
for (const node of states) {
|
|
1224
|
+
const path = parent === undefined ? node.key : `${parent}.${node.key}`
|
|
1225
|
+
if (node._tag === "History") {
|
|
1226
|
+
tree[node.key] = {
|
|
1227
|
+
type: "history",
|
|
1228
|
+
...(node.history === "deep" ? { history: "deep" } : {})
|
|
1229
|
+
}
|
|
1230
|
+
continue
|
|
1231
|
+
}
|
|
1232
|
+
if (node._tag === "Choice") {
|
|
1233
|
+
tree[node.key] = { type: "choice" }
|
|
1234
|
+
continue
|
|
1235
|
+
}
|
|
1236
|
+
const schema = Schema.TaggedStruct(stateTag(path), { value: Schema.Number })
|
|
1237
|
+
if (node._tag === "Atomic") {
|
|
1238
|
+
tree[node.key] = schema
|
|
1239
|
+
} else if (node._tag === "Final") {
|
|
1240
|
+
tree[node.key] = { schema, type: "final", output: Schema.Literal(node.output) }
|
|
1241
|
+
} else if (node._tag === "Compound") {
|
|
1242
|
+
tree[node.key] = {
|
|
1243
|
+
schema,
|
|
1244
|
+
initial: node.initial,
|
|
1245
|
+
states: makeStateTree(node.states, path)
|
|
1246
|
+
}
|
|
1247
|
+
} else {
|
|
1248
|
+
tree[node.key] = {
|
|
1249
|
+
schema,
|
|
1250
|
+
type: "parallel",
|
|
1251
|
+
output: Schema.Literal(node.output),
|
|
1252
|
+
states: makeStateTree(node.states, path)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return tree
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const selectSnapshot = (
|
|
1260
|
+
builder: Record<string, any>,
|
|
1261
|
+
path: string,
|
|
1262
|
+
byPath: ReadonlyMap<string, FlatFiniteState>,
|
|
1263
|
+
requestedParts: ReadonlyArray<string> | undefined,
|
|
1264
|
+
index: number,
|
|
1265
|
+
sourcePath?: string,
|
|
1266
|
+
requestedValue?: number
|
|
1267
|
+
): unknown => {
|
|
1268
|
+
const state = byPath.get(path)!
|
|
1269
|
+
if (state.node._tag === "History") {
|
|
1270
|
+
throw new Error(`MachineTest.compileModel cannot construct active history state "${path}"`)
|
|
1271
|
+
}
|
|
1272
|
+
if (state.node._tag === "Choice") {
|
|
1273
|
+
return (builder[state.node.key] as () => unknown)()
|
|
1274
|
+
}
|
|
1275
|
+
const method = builder[state.node.key] as (value: unknown, selector?: (builder: any) => unknown) => unknown
|
|
1276
|
+
const value = stateValue(state, path === requestedParts?.join(".") ? requestedValue : undefined)
|
|
1277
|
+
if (state.node._tag === "Atomic" || state.node._tag === "Final") return method(value)
|
|
1278
|
+
|
|
1279
|
+
const requestedChild = requestedParts?.[index + 1]
|
|
1280
|
+
if (state.node._tag === "Parallel") {
|
|
1281
|
+
const parallel = state.node
|
|
1282
|
+
const sourceInside = sourcePath === path || sourcePath?.startsWith(`${path}.`) === true
|
|
1283
|
+
if (sourceInside) {
|
|
1284
|
+
const childKey = requestedChild ?? (sourcePath === path
|
|
1285
|
+
? parallel.states.find((child) => child._tag !== "History" && child._tag !== "Choice")!.key
|
|
1286
|
+
: sourcePath!.slice(path.length + 1).split(".")[0]!)
|
|
1287
|
+
return method(
|
|
1288
|
+
value,
|
|
1289
|
+
(children: Record<string, any>) =>
|
|
1290
|
+
selectSnapshot(
|
|
1291
|
+
children,
|
|
1292
|
+
`${path}.${childKey}`,
|
|
1293
|
+
byPath,
|
|
1294
|
+
requestedChild === undefined ? undefined : requestedParts,
|
|
1295
|
+
index + 1,
|
|
1296
|
+
sourcePath,
|
|
1297
|
+
requestedValue
|
|
1298
|
+
)
|
|
1299
|
+
)
|
|
1300
|
+
}
|
|
1301
|
+
return method(value, (children: Record<string, any>) => {
|
|
1302
|
+
let selected: unknown = children
|
|
1303
|
+
for (const child of parallel.states.filter((child) => child._tag !== "History" && child._tag !== "Choice")) {
|
|
1304
|
+
const isRequestedRegion = requestedChild === child.key
|
|
1305
|
+
selected = selectSnapshot(
|
|
1306
|
+
selected as Record<string, any>,
|
|
1307
|
+
`${path}.${child.key}`,
|
|
1308
|
+
byPath,
|
|
1309
|
+
isRequestedRegion ? requestedParts : undefined,
|
|
1310
|
+
index + 1,
|
|
1311
|
+
sourcePath,
|
|
1312
|
+
requestedValue
|
|
1313
|
+
)
|
|
1314
|
+
}
|
|
1315
|
+
return selected
|
|
1316
|
+
})
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const childKey = requestedChild ?? state.node.initial
|
|
1320
|
+
const childPath = `${path}.${childKey}`
|
|
1321
|
+
return method(
|
|
1322
|
+
value,
|
|
1323
|
+
(children: Record<string, any>) =>
|
|
1324
|
+
selectSnapshot(
|
|
1325
|
+
children,
|
|
1326
|
+
childPath,
|
|
1327
|
+
byPath,
|
|
1328
|
+
requestedChild === undefined ? undefined : requestedParts,
|
|
1329
|
+
index + 1,
|
|
1330
|
+
sourcePath,
|
|
1331
|
+
requestedValue
|
|
1332
|
+
)
|
|
1333
|
+
)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const findSnapshot = (snapshot: unknown, path: string): unknown => {
|
|
1337
|
+
if (typeof snapshot !== "object" || snapshot === null) return undefined
|
|
1338
|
+
const current = snapshot as Record<string, unknown>
|
|
1339
|
+
if (current.path === path) return snapshot
|
|
1340
|
+
if (current.state !== undefined) {
|
|
1341
|
+
const found = findSnapshot(current.state, path)
|
|
1342
|
+
if (found !== undefined) return found
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof current.states === "object" && current.states !== null) {
|
|
1345
|
+
for (const child of Object.values(current.states)) {
|
|
1346
|
+
const found = findSnapshot(child, path)
|
|
1347
|
+
if (found !== undefined) return found
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return undefined
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const selectHistoryTarget = (builder: Record<string, any>, path: string): unknown => {
|
|
1354
|
+
const parts = path.split(".")
|
|
1355
|
+
let current: any = builder
|
|
1356
|
+
for (let index = 0; index < parts.length - 1; index++) current = current[parts[index]!]
|
|
1357
|
+
return current[parts[parts.length - 1]!]()
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
type TargetBuilder = {
|
|
1361
|
+
readonly branch: Record<string, any>
|
|
1362
|
+
readonly full: Record<string, any>
|
|
1363
|
+
readonly history: Record<string, any>
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const makeHandlers = (
|
|
1367
|
+
states: ReadonlyArray<FiniteState>,
|
|
1368
|
+
parent: string | undefined,
|
|
1369
|
+
byPath: ReadonlyMap<string, FlatFiniteState>,
|
|
1370
|
+
transitions: ReadonlyMap<string, FiniteTransition>
|
|
1371
|
+
): Record<string, unknown> => {
|
|
1372
|
+
const handlers: Record<string, unknown> = Object.create(null)
|
|
1373
|
+
for (const node of states) {
|
|
1374
|
+
const path = parent === undefined ? node.key : `${parent}.${node.key}`
|
|
1375
|
+
if (node._tag === "History") continue
|
|
1376
|
+
if (node._tag === "Choice") {
|
|
1377
|
+
handlers[node.key] = {
|
|
1378
|
+
choice: {
|
|
1379
|
+
targets: node.targets,
|
|
1380
|
+
transition: ({ target }: { readonly target: TargetBuilder }) => {
|
|
1381
|
+
const selected = byPath.get(node.selected)!
|
|
1382
|
+
const parts = selected.path.split(".")
|
|
1383
|
+
const builder = selected.root === byPath.get(path)!.root ? target.branch : target.full
|
|
1384
|
+
return selectSnapshot(builder, parts[0]!, byPath, parts, 0, path)
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
continue
|
|
1389
|
+
}
|
|
1390
|
+
if (node._tag === "Final") {
|
|
1391
|
+
handlers[node.key] = { output: () => node.output }
|
|
1392
|
+
continue
|
|
1393
|
+
}
|
|
1394
|
+
const on: Record<string, unknown> = Object.create(null)
|
|
1395
|
+
let always: unknown
|
|
1396
|
+
let onDone: unknown
|
|
1397
|
+
for (const transition of transitions.values()) {
|
|
1398
|
+
if (transition.source !== path) continue
|
|
1399
|
+
const config = {
|
|
1400
|
+
...("reenter" in transition ? { reenter: transition.reenter } : {}),
|
|
1401
|
+
...(transition.target === undefined
|
|
1402
|
+
? {}
|
|
1403
|
+
: {
|
|
1404
|
+
targets: [
|
|
1405
|
+
byPath.get(transition.target)!.node._tag === "History" ||
|
|
1406
|
+
byPath.get(transition.target)!.node._tag === "Choice"
|
|
1407
|
+
? transition.target
|
|
1408
|
+
: runtimeTargetPath(byPath, path, transition.target)
|
|
1409
|
+
]
|
|
1410
|
+
}),
|
|
1411
|
+
transition: ({ target }: { readonly target: TargetBuilder }) => {
|
|
1412
|
+
if (transition.target === undefined) return undefined
|
|
1413
|
+
const targetState = byPath.get(transition.target)!
|
|
1414
|
+
if (targetState.node._tag === "History") return selectHistoryTarget(target.history, targetState.path)
|
|
1415
|
+
const parts = transition.target.split(".")
|
|
1416
|
+
const builder = targetState.root === byPath.get(path)!.root ? target.branch : target.full
|
|
1417
|
+
return selectSnapshot(builder, parts[0]!, byPath, parts, 0, path, transition.targetValue)
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (transition.trigger.type === "event") on[transition.trigger.event] = config
|
|
1421
|
+
else if (transition.trigger.type === "always") always = config
|
|
1422
|
+
else onDone = config
|
|
1423
|
+
}
|
|
1424
|
+
const history = node._tag === "Compound" || node._tag === "Parallel"
|
|
1425
|
+
? Object.fromEntries(node.states.flatMap((child) => {
|
|
1426
|
+
if (child._tag !== "History") return []
|
|
1427
|
+
return [[child.key, {
|
|
1428
|
+
default: ({ target }: { readonly target: Record<string, any> }) => {
|
|
1429
|
+
const fallback = byPath.get(child.fallback)!
|
|
1430
|
+
const parts = child.fallback.split(".")
|
|
1431
|
+
const completeRoot = selectSnapshot(target, fallback.root, byPath, parts, 0)
|
|
1432
|
+
if (findSnapshot(completeRoot, path) === undefined) {
|
|
1433
|
+
throw new Error(
|
|
1434
|
+
`MachineTest.compileModel could not construct history fallback for "${path}.${child.key}"`
|
|
1435
|
+
)
|
|
1436
|
+
}
|
|
1437
|
+
return completeRoot
|
|
1438
|
+
}
|
|
1439
|
+
}]]
|
|
1440
|
+
}))
|
|
1441
|
+
: {}
|
|
1442
|
+
handlers[node.key] = {
|
|
1443
|
+
...(Object.keys(on).length === 0 ? {} : { on }),
|
|
1444
|
+
...(always === undefined ? {} : { always }),
|
|
1445
|
+
...(onDone === undefined ? {} : { onDone }),
|
|
1446
|
+
...(node._tag === "Compound" || node._tag === "Parallel"
|
|
1447
|
+
? {
|
|
1448
|
+
...(node._tag === "Parallel" ? { output: () => node.output } : {}),
|
|
1449
|
+
...(Object.keys(history).length === 0 ? {} : { history }),
|
|
1450
|
+
...(node._tag === "Compound"
|
|
1451
|
+
? {
|
|
1452
|
+
initial: () => stateValue(byPath.get(`${path}.${node.initial}`)!)
|
|
1453
|
+
}
|
|
1454
|
+
: {
|
|
1455
|
+
initial: () =>
|
|
1456
|
+
Object.fromEntries(
|
|
1457
|
+
node.states
|
|
1458
|
+
.filter((child) => child._tag !== "History" && child._tag !== "Choice")
|
|
1459
|
+
.map((child) => [child.key, stateValue(byPath.get(`${path}.${child.key}`)!)])
|
|
1460
|
+
)
|
|
1461
|
+
}),
|
|
1462
|
+
states: makeHandlers(node.states, path, byPath, transitions)
|
|
1463
|
+
}
|
|
1464
|
+
: {})
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return handlers
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Compiles a finite model into a real machine using only public definition,
|
|
1472
|
+
* construction, and handler APIs.
|
|
1473
|
+
*
|
|
1474
|
+
* Hand-authored models are validated before compilation. This compiler is a
|
|
1475
|
+
* testing adapter, not the independent reference interpreter used by later
|
|
1476
|
+
* conformance stages.
|
|
1477
|
+
*
|
|
1478
|
+
* @category constructors
|
|
1479
|
+
* @since 0.4.0
|
|
1480
|
+
*/
|
|
1481
|
+
export const compileModel = (model: FiniteModel): Machine.Machine.Any => {
|
|
1482
|
+
const flattened = validateModel(model)
|
|
1483
|
+
const byPath = new Map(flattened.map((state) => [state.path, state]))
|
|
1484
|
+
const stateTree = makeStateTree(model.roots, undefined)
|
|
1485
|
+
const defined = Machine.defineStates(stateTree as any)
|
|
1486
|
+
const eventSchemas = model.events.map((event) => Schema.TaggedStruct(event, {}))
|
|
1487
|
+
const initial = byPath.get(model.initial)!
|
|
1488
|
+
const machine = Machine.make({
|
|
1489
|
+
states: defined.states as any,
|
|
1490
|
+
events: eventSchemas as any,
|
|
1491
|
+
initial: () => selectSnapshot(defined.initial as any, initial.path, byPath, [initial.path], 0) as any
|
|
1492
|
+
})
|
|
1493
|
+
const transitions = new Map(model.transitions.map((transition) => [
|
|
1494
|
+
`${transition.source}\u0000${triggerKey(transition.trigger)}`,
|
|
1495
|
+
transition
|
|
1496
|
+
]))
|
|
1497
|
+
return machine.handle(makeHandlers(model.roots, undefined, byPath, transitions) as any) as Machine.Machine.Any
|
|
1498
|
+
}
|