@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.
- package/README.md +112 -0
- package/dist/index.d.mts +1378 -94
- package/dist/index.d.ts +1378 -94
- package/dist/index.js +1094 -1309
- package/dist/index.mjs +1038 -1296
- package/package.json +4 -3
- package/package.json.backup +0 -41
- package/src/Blueprint.ts +0 -216
- package/src/__tests__/Blueprint.test.ts +0 -106
- package/src/__tests__/action-context.test.ts +0 -166
- package/src/__tests__/actionCreators.test.ts +0 -179
- package/src/__tests__/builders.test.ts +0 -336
- package/src/__tests__/defineBlueprint-composition.test.ts +0 -106
- package/src/__tests__/factories.test.ts +0 -229
- package/src/__tests__/loader.test.ts +0 -159
- package/src/__tests__/logger.test.ts +0 -70
- package/src/__tests__/type-inference.test.ts +0 -160
- package/src/__tests__/typed-transitions.test.ts +0 -126
- package/src/__tests__/useModuleConfig.test.ts +0 -61
- package/src/actionCreators.ts +0 -132
- package/src/actions.ts +0 -547
- package/src/atoms/index.ts +0 -600
- package/src/authoring.ts +0 -92
- package/src/browser-player.ts +0 -783
- package/src/builders.ts +0 -1342
- package/src/components/ExperienceWorkflowBridge.tsx +0 -123
- package/src/components/PlayerProvider.tsx +0 -43
- package/src/components/atoms/index.tsx +0 -269
- package/src/components/index.ts +0 -36
- package/src/conditions.ts +0 -692
- package/src/config/defineBlueprint.ts +0 -329
- package/src/config/defineModel.ts +0 -753
- package/src/config/defineWorkspace.ts +0 -24
- package/src/core/WorkflowRuntime.ts +0 -153
- package/src/factories.ts +0 -425
- package/src/grammar/index.ts +0 -173
- package/src/hooks/index.ts +0 -106
- package/src/hooks/useAuth.ts +0 -288
- package/src/hooks/useChannel.ts +0 -304
- package/src/hooks/useComputed.ts +0 -154
- package/src/hooks/useDomainSubscription.ts +0 -110
- package/src/hooks/useDuringAction.ts +0 -99
- package/src/hooks/useExperienceState.ts +0 -59
- package/src/hooks/useExpressionLibrary.ts +0 -129
- package/src/hooks/useForm.ts +0 -352
- package/src/hooks/useGeolocation.ts +0 -207
- package/src/hooks/useMapView.ts +0 -259
- package/src/hooks/useMiddleware.ts +0 -291
- package/src/hooks/useModel.ts +0 -363
- package/src/hooks/useModule.ts +0 -59
- package/src/hooks/useModuleConfig.ts +0 -61
- package/src/hooks/useMutation.ts +0 -237
- package/src/hooks/useNotification.ts +0 -151
- package/src/hooks/useOnChange.ts +0 -30
- package/src/hooks/useOnEnter.ts +0 -59
- package/src/hooks/useOnEvent.ts +0 -37
- package/src/hooks/useOnExit.ts +0 -27
- package/src/hooks/useOnTransition.ts +0 -30
- package/src/hooks/usePackage.ts +0 -128
- package/src/hooks/useParams.ts +0 -33
- package/src/hooks/usePlayer.ts +0 -308
- package/src/hooks/useQuery.ts +0 -184
- package/src/hooks/useRealtimeQuery.ts +0 -222
- package/src/hooks/useRole.ts +0 -191
- package/src/hooks/useRouteParams.ts +0 -100
- package/src/hooks/useRouter.ts +0 -347
- package/src/hooks/useServerAction.ts +0 -178
- package/src/hooks/useServerState.ts +0 -284
- package/src/hooks/useToast.ts +0 -164
- package/src/hooks/useTransition.ts +0 -39
- package/src/hooks/useView.ts +0 -102
- package/src/hooks/useWhileIn.ts +0 -48
- package/src/hooks/useWorkflow.ts +0 -63
- package/src/index.ts +0 -465
- package/src/loader/experience-workflow-loader.ts +0 -192
- package/src/loader/index.ts +0 -6
- package/src/local/LocalEngine.ts +0 -388
- package/src/local/LocalEngineAdapter.ts +0 -175
- package/src/local/LocalEngineContext.ts +0 -30
- package/src/logger.ts +0 -37
- package/src/mixins.ts +0 -1160
- package/src/providers/RuntimeContext.ts +0 -20
- package/src/providers/WorkflowProvider.tsx +0 -28
- package/src/routing/instance-key.ts +0 -107
- package/src/server/transition-context.ts +0 -172
- package/src/testing/index.ts +0 -9
- package/src/testing/useBlueprintTestRunner.ts +0 -91
- package/src/testing/useGraphAnalysis.ts +0 -18
- package/src/testing/useTestRunner.ts +0 -77
- package/src/testing.ts +0 -995
- package/src/types/workflow-inference.ts +0 -158
- package/src/types.ts +0 -114
- package/tsconfig.json +0 -27
- 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
|
-
}
|