@mmapp/react 0.1.0-alpha.1 → 0.1.0-alpha.4

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.
Files changed (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.mts +1378 -94
  3. package/dist/index.d.ts +1378 -94
  4. package/dist/index.js +1094 -1309
  5. package/dist/index.mjs +1038 -1296
  6. package/package.json +4 -3
  7. package/package.json.backup +0 -41
  8. package/src/Blueprint.ts +0 -216
  9. package/src/__tests__/Blueprint.test.ts +0 -106
  10. package/src/__tests__/action-context.test.ts +0 -166
  11. package/src/__tests__/actionCreators.test.ts +0 -179
  12. package/src/__tests__/builders.test.ts +0 -336
  13. package/src/__tests__/defineBlueprint-composition.test.ts +0 -106
  14. package/src/__tests__/factories.test.ts +0 -229
  15. package/src/__tests__/loader.test.ts +0 -159
  16. package/src/__tests__/logger.test.ts +0 -70
  17. package/src/__tests__/type-inference.test.ts +0 -160
  18. package/src/__tests__/typed-transitions.test.ts +0 -126
  19. package/src/__tests__/useModuleConfig.test.ts +0 -61
  20. package/src/actionCreators.ts +0 -132
  21. package/src/actions.ts +0 -547
  22. package/src/atoms/index.ts +0 -600
  23. package/src/authoring.ts +0 -92
  24. package/src/browser-player.ts +0 -783
  25. package/src/builders.ts +0 -1342
  26. package/src/components/ExperienceWorkflowBridge.tsx +0 -123
  27. package/src/components/PlayerProvider.tsx +0 -43
  28. package/src/components/atoms/index.tsx +0 -269
  29. package/src/components/index.ts +0 -36
  30. package/src/conditions.ts +0 -692
  31. package/src/config/defineBlueprint.ts +0 -329
  32. package/src/config/defineModel.ts +0 -753
  33. package/src/config/defineWorkspace.ts +0 -24
  34. package/src/core/WorkflowRuntime.ts +0 -153
  35. package/src/factories.ts +0 -425
  36. package/src/grammar/index.ts +0 -173
  37. package/src/hooks/index.ts +0 -106
  38. package/src/hooks/useAuth.ts +0 -288
  39. package/src/hooks/useChannel.ts +0 -304
  40. package/src/hooks/useComputed.ts +0 -154
  41. package/src/hooks/useDomainSubscription.ts +0 -110
  42. package/src/hooks/useDuringAction.ts +0 -99
  43. package/src/hooks/useExperienceState.ts +0 -59
  44. package/src/hooks/useExpressionLibrary.ts +0 -129
  45. package/src/hooks/useForm.ts +0 -352
  46. package/src/hooks/useGeolocation.ts +0 -207
  47. package/src/hooks/useMapView.ts +0 -259
  48. package/src/hooks/useMiddleware.ts +0 -291
  49. package/src/hooks/useModel.ts +0 -363
  50. package/src/hooks/useModule.ts +0 -59
  51. package/src/hooks/useModuleConfig.ts +0 -61
  52. package/src/hooks/useMutation.ts +0 -237
  53. package/src/hooks/useNotification.ts +0 -151
  54. package/src/hooks/useOnChange.ts +0 -30
  55. package/src/hooks/useOnEnter.ts +0 -59
  56. package/src/hooks/useOnEvent.ts +0 -37
  57. package/src/hooks/useOnExit.ts +0 -27
  58. package/src/hooks/useOnTransition.ts +0 -30
  59. package/src/hooks/usePackage.ts +0 -128
  60. package/src/hooks/useParams.ts +0 -33
  61. package/src/hooks/usePlayer.ts +0 -308
  62. package/src/hooks/useQuery.ts +0 -184
  63. package/src/hooks/useRealtimeQuery.ts +0 -222
  64. package/src/hooks/useRole.ts +0 -191
  65. package/src/hooks/useRouteParams.ts +0 -100
  66. package/src/hooks/useRouter.ts +0 -347
  67. package/src/hooks/useServerAction.ts +0 -178
  68. package/src/hooks/useServerState.ts +0 -284
  69. package/src/hooks/useToast.ts +0 -164
  70. package/src/hooks/useTransition.ts +0 -39
  71. package/src/hooks/useView.ts +0 -102
  72. package/src/hooks/useWhileIn.ts +0 -48
  73. package/src/hooks/useWorkflow.ts +0 -63
  74. package/src/index.ts +0 -465
  75. package/src/loader/experience-workflow-loader.ts +0 -192
  76. package/src/loader/index.ts +0 -6
  77. package/src/local/LocalEngine.ts +0 -388
  78. package/src/local/LocalEngineAdapter.ts +0 -175
  79. package/src/local/LocalEngineContext.ts +0 -30
  80. package/src/logger.ts +0 -37
  81. package/src/mixins.ts +0 -1160
  82. package/src/providers/RuntimeContext.ts +0 -20
  83. package/src/providers/WorkflowProvider.tsx +0 -28
  84. package/src/routing/instance-key.ts +0 -107
  85. package/src/server/transition-context.ts +0 -172
  86. package/src/testing/index.ts +0 -9
  87. package/src/testing/useBlueprintTestRunner.ts +0 -91
  88. package/src/testing/useGraphAnalysis.ts +0 -18
  89. package/src/testing/useTestRunner.ts +0 -77
  90. package/src/testing.ts +0 -995
  91. package/src/types/workflow-inference.ts +0 -158
  92. package/src/types.ts +0 -114
  93. package/tsconfig.json +0 -27
  94. package/vitest.config.ts +0 -8
package/src/testing.ts DELETED
@@ -1,995 +0,0 @@
1
- /**
2
- * @mindmatrix/react/testing — Testing utilities for workflow models.
3
- *
4
- * Provides static analysis, BDD-style test harnesses, and pretty-printing
5
- * for models defined with `defineModel()`.
6
- *
7
- * @example
8
- * ```typescript
9
- * import { validateModel, testModel, describeModel, assertModelValid } from '@mindmatrix/react/testing';
10
- * import authModel from '../models/authentication';
11
- *
12
- * // Static analysis
13
- * const issues = validateModel(authModel);
14
- *
15
- * // Throws if any errors
16
- * assertModelValid(authModel);
17
- *
18
- * // BDD-style test chain
19
- * testModel(authModel)
20
- * .given()
21
- * .when('login', { email: 'a@b.com', password: 'secret' })
22
- * .thenState('authenticating')
23
- * .when('login_success')
24
- * .thenState('authenticated');
25
- *
26
- * // Human-readable summary
27
- * console.log(describeModel(authModel));
28
- * ```
29
- */
30
-
31
- import type {
32
- ModelDefinition,
33
- TransitionDescriptor,
34
- TransitionCondition,
35
- ActionDefinition,
36
- } from './config/defineModel';
37
-
38
- // =============================================================================
39
- // Validation
40
- // =============================================================================
41
-
42
- /**
43
- * A single issue found by `validateModel()`.
44
- *
45
- * @example
46
- * ```typescript
47
- * const issues = validateModel(myModel);
48
- * const errors = issues.filter(i => i.severity === 'error');
49
- * if (errors.length > 0) {
50
- * throw new Error(`Model has ${errors.length} error(s)`);
51
- * }
52
- * ```
53
- */
54
- export interface ValidationIssue {
55
- /** How severe the issue is. `'error'` blocks usage, `'warning'` is advisory, `'info'` is informational. */
56
- severity: 'error' | 'warning' | 'info';
57
- /** Dot-separated path to the problematic definition element, e.g. `'transitions.login.from'`. */
58
- path: string;
59
- /** Human-readable description of the problem. */
60
- message: string;
61
- /** Machine-readable issue code, e.g. `'UNREACHABLE_STATE'`. */
62
- code: string;
63
- }
64
-
65
- /**
66
- * Statically analyse a `ModelDefinition` for common errors.
67
- *
68
- * Performs 12 categories of checks including state-machine integrity,
69
- * reachability, transition validity, required-field existence, and role
70
- * consistency.
71
- *
72
- * @param def - The model definition to validate.
73
- * @returns An array of `ValidationIssue` objects (empty if the model is clean).
74
- *
75
- * @example
76
- * ```typescript
77
- * import { defineModel } from '@mindmatrix/react';
78
- * import { validateModel } from '@mindmatrix/react/testing';
79
- *
80
- * const model = defineModel({
81
- * slug: 'order',
82
- * fields: { total: { type: 'number', default: 0 } },
83
- * states: {
84
- * draft: { type: 'initial' },
85
- * placed: {},
86
- * },
87
- * transitions: {
88
- * place: { from: 'draft', to: 'placed' },
89
- * },
90
- * });
91
- *
92
- * const issues = validateModel(model);
93
- * // issues[0].code === 'NO_TERMINAL_STATE'
94
- * ```
95
- */
96
- export function validateModel(def: ModelDefinition): ValidationIssue[] {
97
- const issues: ValidationIssue[] = [];
98
- const stateNames = Object.keys(def.states);
99
- const transitionNames = Object.keys(def.transitions);
100
-
101
- // --- Helper: normalise `from` to an array ---
102
- const fromArray = (t: TransitionDescriptor): string[] =>
103
- Array.isArray(t.from) ? [...t.from] : [t.from as string];
104
-
105
- // -------------------------------------------------------------------------
106
- // 1. Exactly one initial state
107
- // -------------------------------------------------------------------------
108
- const initialStates = stateNames.filter(s => def.states[s].type === 'initial');
109
- if (initialStates.length === 0) {
110
- issues.push({
111
- severity: 'error',
112
- path: 'states',
113
- message: 'No state with type "initial" found. Exactly one initial state is required.',
114
- code: 'NO_INITIAL_STATE',
115
- });
116
- } else if (initialStates.length > 1) {
117
- issues.push({
118
- severity: 'error',
119
- path: 'states',
120
- message: `Multiple initial states found: ${initialStates.join(', ')}. Exactly one is required.`,
121
- code: 'MULTIPLE_INITIAL_STATES',
122
- });
123
- }
124
-
125
- // -------------------------------------------------------------------------
126
- // 2. At least one terminal state (end or cancelled)
127
- // -------------------------------------------------------------------------
128
- const terminalStates = stateNames.filter(
129
- s => def.states[s].type === 'end' || def.states[s].type === 'cancelled',
130
- );
131
- if (terminalStates.length === 0) {
132
- issues.push({
133
- severity: 'warning',
134
- path: 'states',
135
- message: 'No terminal state (type "end" or "cancelled") found. The model may run indefinitely.',
136
- code: 'NO_TERMINAL_STATE',
137
- });
138
- }
139
-
140
- // -------------------------------------------------------------------------
141
- // 3 & 4. All transition from/to states exist
142
- // -------------------------------------------------------------------------
143
- for (const tName of transitionNames) {
144
- const t = def.transitions[tName];
145
- const froms = fromArray(t);
146
-
147
- for (const f of froms) {
148
- if (!stateNames.includes(f)) {
149
- issues.push({
150
- severity: 'error',
151
- path: `transitions.${tName}.from`,
152
- message: `Transition "${tName}" references non-existent source state "${f}".`,
153
- code: 'INVALID_FROM_STATE',
154
- });
155
- }
156
- }
157
-
158
- if (!stateNames.includes(t.to)) {
159
- issues.push({
160
- severity: 'error',
161
- path: `transitions.${tName}.to`,
162
- message: `Transition "${tName}" references non-existent target state "${t.to}".`,
163
- code: 'INVALID_TO_STATE',
164
- });
165
- }
166
- }
167
-
168
- // -------------------------------------------------------------------------
169
- // 5. No transitions FROM end/cancelled states
170
- // -------------------------------------------------------------------------
171
- const terminalSet = new Set(terminalStates);
172
- for (const tName of transitionNames) {
173
- const froms = fromArray(def.transitions[tName]);
174
- for (const f of froms) {
175
- if (terminalSet.has(f)) {
176
- issues.push({
177
- severity: 'error',
178
- path: `transitions.${tName}.from`,
179
- message: `Transition "${tName}" originates from terminal state "${f}" (type "${def.states[f]?.type}"). Terminal states must not have outgoing transitions.`,
180
- code: 'TRANSITION_FROM_TERMINAL',
181
- });
182
- }
183
- }
184
- }
185
-
186
- // -------------------------------------------------------------------------
187
- // 6. All states reachable from initial state (graph walk)
188
- // -------------------------------------------------------------------------
189
- if (initialStates.length === 1) {
190
- const reachable = new Set<string>();
191
- const queue: string[] = [initialStates[0]];
192
- while (queue.length > 0) {
193
- const current = queue.pop()!;
194
- if (reachable.has(current)) continue;
195
- reachable.add(current);
196
- // Find all states reachable via transitions from `current`
197
- for (const tName of transitionNames) {
198
- const t = def.transitions[tName];
199
- const froms = fromArray(t);
200
- if (froms.includes(current) && !reachable.has(t.to)) {
201
- queue.push(t.to);
202
- }
203
- }
204
- }
205
-
206
- for (const s of stateNames) {
207
- if (!reachable.has(s)) {
208
- issues.push({
209
- severity: 'warning',
210
- path: `states.${s}`,
211
- message: `State "${s}" is not reachable from the initial state "${initialStates[0]}".`,
212
- code: 'UNREACHABLE_STATE',
213
- });
214
- }
215
- }
216
- }
217
-
218
- // -------------------------------------------------------------------------
219
- // 7. All non-terminal states have at least one outgoing transition
220
- // -------------------------------------------------------------------------
221
- const statesWithOutgoing = new Set<string>();
222
- for (const tName of transitionNames) {
223
- const froms = fromArray(def.transitions[tName]);
224
- for (const f of froms) {
225
- statesWithOutgoing.add(f);
226
- }
227
- }
228
- for (const s of stateNames) {
229
- if (!terminalSet.has(s) && !statesWithOutgoing.has(s)) {
230
- issues.push({
231
- severity: 'warning',
232
- path: `states.${s}`,
233
- message: `Non-terminal state "${s}" has no outgoing transitions and will be a dead end.`,
234
- code: 'DEAD_END_STATE',
235
- });
236
- }
237
- }
238
-
239
- // -------------------------------------------------------------------------
240
- // 8. Auto transitions should have at least one condition
241
- // -------------------------------------------------------------------------
242
- for (const tName of transitionNames) {
243
- const t = def.transitions[tName];
244
- if (t.auto && (!t.conditions || t.conditions.length === 0)) {
245
- issues.push({
246
- severity: 'warning',
247
- path: `transitions.${tName}`,
248
- message: `Auto transition "${tName}" has no conditions. It will fire unconditionally on state entry, which may cause an infinite loop.`,
249
- code: 'UNCONDITIONAL_AUTO_TRANSITION',
250
- });
251
- }
252
- }
253
-
254
- // -------------------------------------------------------------------------
255
- // 9. Required fields referenced in requiredFields exist in fields
256
- // -------------------------------------------------------------------------
257
- const fieldNames = Object.keys(def.fields);
258
- for (const tName of transitionNames) {
259
- const t = def.transitions[tName];
260
- const reqFields = t.requiredFields ?? t.required_fields;
261
- if (reqFields) {
262
- for (const rf of reqFields) {
263
- if (!fieldNames.includes(rf)) {
264
- issues.push({
265
- severity: 'error',
266
- path: `transitions.${tName}.requiredFields`,
267
- message: `Transition "${tName}" requires field "${rf}" which does not exist in the model's fields.`,
268
- code: 'MISSING_REQUIRED_FIELD',
269
- });
270
- }
271
- }
272
- }
273
- }
274
-
275
- // -------------------------------------------------------------------------
276
- // 10. Roles referenced in transition roles exist in model roles
277
- // -------------------------------------------------------------------------
278
- if (def.roles) {
279
- const roleNames = Object.keys(def.roles);
280
- for (const tName of transitionNames) {
281
- const t = def.transitions[tName];
282
- if (t.roles) {
283
- for (const r of t.roles) {
284
- if (!roleNames.includes(r)) {
285
- issues.push({
286
- severity: 'error',
287
- path: `transitions.${tName}.roles`,
288
- message: `Transition "${tName}" references role "${r}" which is not defined in the model's roles.`,
289
- code: 'UNDEFINED_ROLE',
290
- });
291
- }
292
- }
293
- }
294
- }
295
- }
296
-
297
- // -------------------------------------------------------------------------
298
- // 11. Duplicate transition names
299
- // -------------------------------------------------------------------------
300
- const seen = new Set<string>();
301
- for (const tName of transitionNames) {
302
- if (seen.has(tName)) {
303
- issues.push({
304
- severity: 'error',
305
- path: `transitions.${tName}`,
306
- message: `Duplicate transition name "${tName}".`,
307
- code: 'DUPLICATE_TRANSITION',
308
- });
309
- }
310
- seen.add(tName);
311
- }
312
-
313
- // -------------------------------------------------------------------------
314
- // 12. Self-transitions (from === to) — informational
315
- // -------------------------------------------------------------------------
316
- for (const tName of transitionNames) {
317
- const t = def.transitions[tName];
318
- const froms = fromArray(t);
319
- for (const f of froms) {
320
- if (f === t.to) {
321
- issues.push({
322
- severity: 'info',
323
- path: `transitions.${tName}`,
324
- message: `Transition "${tName}" is a self-transition on state "${f}" (from and to are the same).`,
325
- code: 'SELF_TRANSITION',
326
- });
327
- }
328
- }
329
- }
330
-
331
- return issues;
332
- }
333
-
334
- /**
335
- * Assert that a model definition has no validation errors.
336
- *
337
- * Calls `validateModel()` internally and throws an `Error` if any issue
338
- * with severity `'error'` is found. Warnings and info issues are ignored.
339
- *
340
- * Useful in test setup or CI pipelines where you want a hard failure on
341
- * invalid models.
342
- *
343
- * @param def - The model definition to assert.
344
- * @throws {Error} If any validation error is found, with a message listing all errors.
345
- *
346
- * @example
347
- * ```typescript
348
- * import { assertModelValid } from '@mindmatrix/react/testing';
349
- * import orderModel from '../models/order';
350
- *
351
- * // In a test file
352
- * describe('order model', () => {
353
- * it('is structurally valid', () => {
354
- * assertModelValid(orderModel); // throws if invalid
355
- * });
356
- * });
357
- * ```
358
- */
359
- export function assertModelValid(def: ModelDefinition): void {
360
- const issues = validateModel(def);
361
- const errors = issues.filter(i => i.severity === 'error');
362
- if (errors.length > 0) {
363
- const lines = errors.map(e => ` [${e.code}] ${e.path}: ${e.message}`);
364
- throw new Error(
365
- `Model "${def.slug}" has ${errors.length} validation error(s):\n${lines.join('\n')}`,
366
- );
367
- }
368
- }
369
-
370
- // =============================================================================
371
- // BDD Test Harness
372
- // =============================================================================
373
-
374
- /**
375
- * Snapshot of the model's runtime state during a test chain.
376
- *
377
- * @example
378
- * ```typescript
379
- * testModel(model)
380
- * .given({ fields: { total: 100 } })
381
- * .when('approve')
382
- * .then(ctx => {
383
- * expect(ctx.state).toBe('approved');
384
- * expect(ctx.fields.total).toBe(100);
385
- * expect(ctx.history).toHaveLength(1);
386
- * });
387
- * ```
388
- */
389
- export interface ModelTestContext {
390
- /** Current field values. */
391
- fields: Record<string, unknown>;
392
- /** Current state name. */
393
- state: string;
394
- /** History of transitions that have been executed. */
395
- history: Array<{
396
- from: string;
397
- to: string;
398
- transition: string;
399
- input?: Record<string, unknown>;
400
- }>;
401
- }
402
-
403
- /**
404
- * A single step in a BDD test chain. Provides assertions after a `when()` call.
405
- */
406
- export interface TestStep {
407
- /**
408
- * Assert arbitrary properties of the test context.
409
- *
410
- * @param assertion - A function that receives the current `ModelTestContext`.
411
- * Throw inside the function to signal a failure.
412
- * @returns The test chain for further chaining.
413
- *
414
- * @example
415
- * ```typescript
416
- * .then(ctx => {
417
- * expect(ctx.state).toBe('sent');
418
- * expect(ctx.fields.timestamp).toBeGreaterThan(0);
419
- * })
420
- * ```
421
- */
422
- then(assertion: (ctx: ModelTestContext) => void): TestChain;
423
-
424
- /**
425
- * Assert that the current state matches the expected state name.
426
- *
427
- * @param expectedState - The expected state name after the transition.
428
- * @returns The test chain for further chaining.
429
- *
430
- * @example
431
- * ```typescript
432
- * .when('login', { email: 'a@b.com', password: 'pw' })
433
- * .thenState('authenticating')
434
- * ```
435
- */
436
- thenState(expectedState: string): TestChain;
437
-
438
- /**
439
- * Assert that a specific field has the expected value.
440
- *
441
- * @param field - The field name to check.
442
- * @param expected - The expected value (compared with strict equality).
443
- * @returns The test chain for further chaining.
444
- *
445
- * @example
446
- * ```typescript
447
- * .when('send', { content: 'hello' })
448
- * .thenField('content', 'hello')
449
- * ```
450
- */
451
- thenField(field: string, expected: unknown): TestChain;
452
-
453
- /**
454
- * Assert that the transition would be rejected (wrong source state,
455
- * failed condition, or missing required fields).
456
- *
457
- * @returns The test chain for further chaining.
458
- *
459
- * @example
460
- * ```typescript
461
- * testModel(model)
462
- * .given({ state: 'authenticated' })
463
- * .when('login')
464
- * .thenFails()
465
- * ```
466
- */
467
- thenFails(): TestChain;
468
- }
469
-
470
- /**
471
- * A chainable test sequence combining `when()` triggers with assertions.
472
- */
473
- export interface TestChain extends TestStep {
474
- /**
475
- * Simulate firing a transition with optional input data.
476
- *
477
- * @param transition - The transition name to fire.
478
- * @param input - Optional input data passed to the transition (available as `input.*` in expressions).
479
- * @returns A `TestStep` for asserting the outcome.
480
- *
481
- * @example
482
- * ```typescript
483
- * testModel(authModel)
484
- * .given()
485
- * .when('login', { email: 'a@b.com', password: 'secret' })
486
- * .thenState('authenticating')
487
- * .when('login_success')
488
- * .thenState('authenticated')
489
- * ```
490
- */
491
- when(transition: string, input?: Record<string, unknown>): TestStep;
492
- }
493
-
494
- /**
495
- * Create a BDD-style test harness for a model definition.
496
- *
497
- * The harness simulates state-machine transitions in-memory. It verifies
498
- * source-state guards, evaluates simple field conditions, applies
499
- * `set_field`/`set_fields` actions, and tracks transition history.
500
- *
501
- * Complex expression conditions (those referencing `context.*`, `input.*`,
502
- * or calling functions like `LEN()`) are skipped during evaluation --
503
- * only simple field comparisons are evaluated.
504
- *
505
- * @param def - The model definition to test.
506
- * @returns An object with a `given()` method to start the test chain.
507
- *
508
- * @example
509
- * ```typescript
510
- * import { testModel } from '@mindmatrix/react/testing';
511
- * import messageModel from '../models/message';
512
- *
513
- * testModel(messageModel)
514
- * .given()
515
- * .when('send', { content: 'hello', channelId: 'ch-1' })
516
- * .thenState('sent')
517
- * .when('deliver')
518
- * .thenState('delivered')
519
- * .when('mark_read')
520
- * .thenState('read')
521
- * .when('delete')
522
- * .thenState('deleted');
523
- * ```
524
- */
525
- export function testModel(def: ModelDefinition): {
526
- /**
527
- * Set the initial context for the test chain.
528
- *
529
- * @param overrides - Optional state and field overrides.
530
- * If `state` is omitted, the initial state is used.
531
- * If `fields` is omitted, default values from field descriptors are used.
532
- * @returns A `TestChain` for chaining `when()` and assertions.
533
- *
534
- * @example
535
- * ```typescript
536
- * testModel(model)
537
- * .given({ state: 'review', fields: { amount: 500 } })
538
- * .when('approve')
539
- * .thenState('approved');
540
- * ```
541
- */
542
- given(overrides?: { state?: string; fields?: Record<string, unknown> }): TestChain;
543
- } {
544
- return {
545
- given(overrides?: { state?: string; fields?: Record<string, unknown> }): TestChain {
546
- // Resolve initial state
547
- const initialState = Object.keys(def.states).find(s => def.states[s].type === 'initial');
548
- const startState = overrides?.state ?? initialState ?? Object.keys(def.states)[0];
549
-
550
- // Build default field values
551
- const defaultFields: Record<string, unknown> = {};
552
- for (const [name, fd] of Object.entries(def.fields)) {
553
- if (fd.default !== undefined) {
554
- // Deep-clone arrays/objects to avoid shared references
555
- defaultFields[name] =
556
- typeof fd.default === 'object' && fd.default !== null
557
- ? JSON.parse(JSON.stringify(fd.default))
558
- : fd.default;
559
- }
560
- }
561
-
562
- const ctx: ModelTestContext = {
563
- state: startState,
564
- fields: { ...defaultFields, ...overrides?.fields },
565
- history: [],
566
- };
567
-
568
- return createChain(def, ctx);
569
- },
570
- };
571
- }
572
-
573
- // --- Internal helpers for the test harness ---
574
-
575
- /** Try to evaluate a simple field-based condition. Returns `null` if it cannot evaluate. */
576
- function evaluateCondition(
577
- cond: TransitionCondition | string,
578
- ctx: ModelTestContext,
579
- _input?: Record<string, unknown>,
580
- ): boolean | null {
581
- // String shorthand — we can't evaluate arbitrary expressions
582
- if (typeof cond === 'string') {
583
- return null;
584
- }
585
-
586
- // Field-based condition
587
- if (cond.type === 'field' && cond.field && cond.operator !== undefined) {
588
- const fieldValue = ctx.fields[cond.field];
589
- switch (cond.operator) {
590
- case 'eq':
591
- return fieldValue === cond.value;
592
- case 'ne':
593
- return fieldValue !== cond.value;
594
- case 'gt':
595
- return (fieldValue as number) > (cond.value as number);
596
- case 'gte':
597
- return (fieldValue as number) >= (cond.value as number);
598
- case 'lt':
599
- return (fieldValue as number) < (cond.value as number);
600
- case 'lte':
601
- return (fieldValue as number) <= (cond.value as number);
602
- case 'is_set':
603
- return fieldValue != null && fieldValue !== '';
604
- case 'is_empty':
605
- return fieldValue == null || fieldValue === '';
606
- case 'in':
607
- return Array.isArray(cond.value) && (cond.value as unknown[]).includes(fieldValue);
608
- case 'not_in':
609
- return Array.isArray(cond.value) && !(cond.value as unknown[]).includes(fieldValue);
610
- case 'contains':
611
- return typeof fieldValue === 'string' && fieldValue.includes(cond.value as string);
612
- default:
613
- return null;
614
- }
615
- }
616
-
617
- // Role-based condition — we skip these (no runtime role info)
618
- if (cond.type === 'role') {
619
- return null;
620
- }
621
-
622
- // Expression condition — we skip complex expressions
623
- if (cond.type === 'expression') {
624
- return null;
625
- }
626
-
627
- // OR combinator
628
- if (cond.OR) {
629
- let anyTrue = false;
630
- let allSkipped = true;
631
- for (const sub of cond.OR) {
632
- const result = evaluateCondition(sub, ctx, _input);
633
- if (result !== null) {
634
- allSkipped = false;
635
- if (result) {
636
- anyTrue = true;
637
- break;
638
- }
639
- }
640
- }
641
- return allSkipped ? null : anyTrue;
642
- }
643
-
644
- // AND combinator
645
- if (cond.AND) {
646
- let allTrue = true;
647
- let allSkipped = true;
648
- for (const sub of cond.AND) {
649
- const result = evaluateCondition(sub, ctx, _input);
650
- if (result !== null) {
651
- allSkipped = false;
652
- if (!result) {
653
- allTrue = false;
654
- break;
655
- }
656
- }
657
- }
658
- return allSkipped ? null : allTrue;
659
- }
660
-
661
- return null;
662
- }
663
-
664
- /** Apply set_field / set_fields actions (only simple literal values from input). */
665
- function applyActions(
666
- actions: readonly ActionDefinition[] | undefined,
667
- ctx: ModelTestContext,
668
- input?: Record<string, unknown>,
669
- ): void {
670
- if (!actions) return;
671
-
672
- for (const action of actions) {
673
- if (action.type === 'set_field' && action.config) {
674
- const field = action.config.field as string | undefined;
675
- const expression = action.config.expression as string | undefined;
676
- if (field && expression) {
677
- // Try to resolve simple input references: 'input.foo'
678
- const inputMatch = expression.match(/^input\.(\w+)$/);
679
- if (inputMatch && input && inputMatch[1] in input) {
680
- ctx.fields[field] = input[inputMatch[1]];
681
- }
682
- // Simple string literal: '"some string"'
683
- const strMatch = expression.match(/^"(.*)"$/);
684
- if (strMatch) {
685
- ctx.fields[field] = strMatch[1];
686
- }
687
- // Simple number literal
688
- const numMatch = expression.match(/^(\d+(?:\.\d+)?)$/);
689
- if (numMatch) {
690
- ctx.fields[field] = Number(numMatch[1]);
691
- }
692
- // Boolean literals
693
- if (expression === 'true') ctx.fields[field] = true;
694
- if (expression === 'false') ctx.fields[field] = false;
695
- if (expression === 'null') ctx.fields[field] = null;
696
- }
697
- }
698
-
699
- if (action.type === 'set_fields' && action.config?.fields) {
700
- const fieldMap = action.config.fields as Record<string, { expression: string }>;
701
- for (const [field, spec] of Object.entries(fieldMap)) {
702
- const expression = spec.expression;
703
- if (!expression) continue;
704
-
705
- const inputMatch = expression.match(/^input\.(\w+)$/);
706
- if (inputMatch && input && inputMatch[1] in input) {
707
- ctx.fields[field] = input[inputMatch[1]];
708
- continue;
709
- }
710
- const strMatch = expression.match(/^"(.*)"$/);
711
- if (strMatch) {
712
- ctx.fields[field] = strMatch[1];
713
- continue;
714
- }
715
- const numMatch = expression.match(/^(\d+(?:\.\d+)?)$/);
716
- if (numMatch) {
717
- ctx.fields[field] = Number(numMatch[1]);
718
- continue;
719
- }
720
- if (expression === 'true') ctx.fields[field] = true;
721
- else if (expression === 'false') ctx.fields[field] = false;
722
- else if (expression === 'null') ctx.fields[field] = null;
723
- }
724
- }
725
- }
726
- }
727
-
728
- /** Try to execute a transition. Returns `{ ok: true }` or `{ ok: false, reason: string }`. */
729
- function tryTransition(
730
- def: ModelDefinition,
731
- ctx: ModelTestContext,
732
- transitionName: string,
733
- input?: Record<string, unknown>,
734
- ): { ok: boolean; reason?: string } {
735
- const t = def.transitions[transitionName];
736
- if (!t) {
737
- return { ok: false, reason: `Transition "${transitionName}" does not exist.` };
738
- }
739
-
740
- // Check source state
741
- const froms = Array.isArray(t.from) ? [...t.from] : [t.from as string];
742
- if (!froms.includes(ctx.state)) {
743
- return {
744
- ok: false,
745
- reason: `Current state "${ctx.state}" is not a valid source for transition "${transitionName}" (valid: ${froms.join(', ')}).`,
746
- };
747
- }
748
-
749
- // Check required fields
750
- const reqFields = t.requiredFields ?? t.required_fields;
751
- if (reqFields) {
752
- for (const rf of reqFields) {
753
- const val = ctx.fields[rf];
754
- if (val === undefined || val === null || val === '') {
755
- return {
756
- ok: false,
757
- reason: `Required field "${rf}" is empty for transition "${transitionName}".`,
758
- };
759
- }
760
- }
761
- }
762
-
763
- // Evaluate conditions (only simple ones — skip complex expressions)
764
- if (t.conditions) {
765
- for (const cond of t.conditions) {
766
- const result = evaluateCondition(cond, ctx, input);
767
- // Only reject if a condition explicitly evaluates to false
768
- if (result === false) {
769
- return {
770
- ok: false,
771
- reason: `Condition failed for transition "${transitionName}".`,
772
- };
773
- }
774
- }
775
- }
776
-
777
- // Transition succeeds — apply actions and move state
778
- const prevState = ctx.state;
779
- applyActions(t.actions, ctx, input);
780
- ctx.state = t.to;
781
- ctx.history.push({ from: prevState, to: t.to, transition: transitionName, input });
782
-
783
- return { ok: true };
784
- }
785
-
786
- /** Create a TestChain backed by a mutable context. */
787
- function createChain(def: ModelDefinition, ctx: ModelTestContext): TestChain {
788
- // Track the result of the last `when()` call for `thenFails()`
789
- let lastResult: { ok: boolean; reason?: string } | null = null;
790
-
791
- const chain: TestChain = {
792
- when(transition: string, input?: Record<string, unknown>): TestStep {
793
- // Save a snapshot so thenFails() works correctly
794
- const snapshot = {
795
- state: ctx.state,
796
- fields: { ...ctx.fields },
797
- historyLen: ctx.history.length,
798
- };
799
-
800
- lastResult = tryTransition(def, ctx, transition, input);
801
-
802
- // If the transition failed, revert context for thenFails()
803
- if (!lastResult.ok) {
804
- ctx.state = snapshot.state;
805
- ctx.fields = snapshot.fields;
806
- // Remove any history entries added (shouldn't be any, but safety)
807
- ctx.history.length = snapshot.historyLen;
808
- }
809
-
810
- return chain;
811
- },
812
-
813
- then(assertion: (c: ModelTestContext) => void): TestChain {
814
- if (lastResult && !lastResult.ok) {
815
- throw new Error(
816
- `Cannot assert with then() — the last transition failed: ${lastResult.reason}`,
817
- );
818
- }
819
- assertion(ctx);
820
- return chain;
821
- },
822
-
823
- thenState(expectedState: string): TestChain {
824
- if (lastResult && !lastResult.ok) {
825
- throw new Error(
826
- `Expected state "${expectedState}" but the last transition failed: ${lastResult.reason}`,
827
- );
828
- }
829
- if (ctx.state !== expectedState) {
830
- throw new Error(
831
- `Expected state "${expectedState}" but got "${ctx.state}".`,
832
- );
833
- }
834
- return chain;
835
- },
836
-
837
- thenField(field: string, expected: unknown): TestChain {
838
- if (lastResult && !lastResult.ok) {
839
- throw new Error(
840
- `Cannot assert field "${field}" — the last transition failed: ${lastResult.reason}`,
841
- );
842
- }
843
- const actual = ctx.fields[field];
844
- if (actual !== expected) {
845
- throw new Error(
846
- `Expected field "${field}" to be ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}.`,
847
- );
848
- }
849
- return chain;
850
- },
851
-
852
- thenFails(): TestChain {
853
- if (!lastResult) {
854
- throw new Error('thenFails() called without a preceding when() call.');
855
- }
856
- if (lastResult.ok) {
857
- throw new Error(
858
- `Expected the transition to fail, but it succeeded. State is now "${ctx.state}".`,
859
- );
860
- }
861
- // Reset lastResult so the chain can continue
862
- lastResult = null;
863
- return chain;
864
- },
865
- };
866
-
867
- return chain;
868
- }
869
-
870
- // =============================================================================
871
- // Describe
872
- // =============================================================================
873
-
874
- /**
875
- * Generate a human-readable summary of a model definition.
876
- *
877
- * Useful for documentation, debugging, and quick inspection of a model's
878
- * structure without reading the raw definition object.
879
- *
880
- * @param def - The model definition to describe.
881
- * @returns A multi-line string summarising the model's fields, states,
882
- * transitions, and roles.
883
- *
884
- * @example
885
- * ```typescript
886
- * import { describeModel } from '@mindmatrix/react/testing';
887
- * import authModel from '../models/authentication';
888
- *
889
- * console.log(describeModel(authModel));
890
- * // Model: mod-authentication (v2.0.0, module)
891
- * //
892
- * // Fields (6):
893
- * // appName: string = ''
894
- * // layout: string = 'card' [card, split, minimal]
895
- * // ...
896
- * //
897
- * // States (4):
898
- * // * unauthenticated (initial)
899
- * // - authenticating
900
- * // - authenticated
901
- * // - error
902
- * //
903
- * // Transitions (8):
904
- * // login: unauthenticated -> authenticating [conditions: 2, actions: 1]
905
- * // ...
906
- * //
907
- * // Roles (0): none defined
908
- * ```
909
- */
910
- export function describeModel(def: ModelDefinition): string {
911
- const lines: string[] = [];
912
-
913
- // --- Header ---
914
- const versionStr = def.version ? `v${def.version}` : 'unversioned';
915
- const categoryStr = Array.isArray(def.category)
916
- ? def.category.join(', ')
917
- : def.category ?? 'model';
918
- lines.push(`Model: ${def.slug} (${versionStr}, ${categoryStr})`);
919
-
920
- // --- Fields ---
921
- const fieldEntries = Object.entries(def.fields);
922
- lines.push('');
923
- lines.push(`Fields (${fieldEntries.length}):`);
924
- if (fieldEntries.length === 0) {
925
- lines.push(' (none)');
926
- } else {
927
- for (const [name, fd] of fieldEntries) {
928
- let line = ` ${name}: ${fd.type}`;
929
- if (fd.default !== undefined) {
930
- line += ` = ${JSON.stringify(fd.default)}`;
931
- }
932
- if (fd.required) {
933
- line += ' (required)';
934
- }
935
- if (fd.enum && fd.enum.length > 0) {
936
- line += ` [${fd.enum.join(', ')}]`;
937
- }
938
- if (fd.computed) {
939
- line += ' (computed)';
940
- }
941
- lines.push(line);
942
- }
943
- }
944
-
945
- // --- States ---
946
- const stateEntries = Object.entries(def.states);
947
- lines.push('');
948
- lines.push(`States (${stateEntries.length}):`);
949
- for (const [name, sd] of stateEntries) {
950
- const typeLabel = sd.type ? ` (${sd.type})` : '';
951
- const marker = sd.type === 'initial' ? '*' : sd.type === 'end' || sd.type === 'cancelled' ? 'x' : '-';
952
- lines.push(` ${marker} ${name}${typeLabel}`);
953
- }
954
-
955
- // --- Transitions ---
956
- const transitionEntries = Object.entries(def.transitions);
957
- lines.push('');
958
- lines.push(`Transitions (${transitionEntries.length}):`);
959
- for (const [name, td] of transitionEntries) {
960
- const fromStr = Array.isArray(td.from) ? (td.from as readonly string[]).join(' | ') : td.from;
961
- const parts: string[] = [];
962
- if (td.conditions && td.conditions.length > 0) {
963
- parts.push(`conditions: ${td.conditions.length}`);
964
- }
965
- if (td.actions && td.actions.length > 0) {
966
- parts.push(`actions: ${td.actions.length}`);
967
- }
968
- if (td.roles && td.roles.length > 0) {
969
- parts.push(`roles: ${td.roles.join(', ')}`);
970
- }
971
- if (td.auto) {
972
- parts.push('auto');
973
- }
974
- const suffix = parts.length > 0 ? ` [${parts.join(', ')}]` : '';
975
- lines.push(` ${name}: ${fromStr} -> ${td.to}${suffix}`);
976
- }
977
-
978
- // --- Roles ---
979
- const roleEntries = def.roles ? Object.entries(def.roles) : [];
980
- lines.push('');
981
- if (roleEntries.length === 0) {
982
- lines.push('Roles (0): none defined');
983
- } else {
984
- lines.push(`Roles (${roleEntries.length}):`);
985
- for (const [name, rd] of roleEntries) {
986
- const desc = rd.description ? ` - ${rd.description}` : '';
987
- const perms = rd.permissions && rd.permissions.length > 0
988
- ? ` [${rd.permissions.join(', ')}]`
989
- : '';
990
- lines.push(` ${name}${desc}${perms}`);
991
- }
992
- }
993
-
994
- return lines.join('\n');
995
- }