@mmapp/react 0.1.0-alpha.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/dist/atoms/index.d.mts +424 -0
- package/dist/atoms/index.d.ts +424 -0
- package/dist/atoms/index.js +195 -0
- package/dist/atoms/index.mjs +110 -0
- package/dist/chunk-2VJQJM7S.mjs +119 -0
- package/dist/index.d.mts +6999 -0
- package/dist/index.d.ts +6999 -0
- package/dist/index.js +8247 -0
- package/dist/index.mjs +8016 -0
- package/package.json +37 -0
- package/package.json.backup +41 -0
- package/src/Blueprint.ts +216 -0
- package/src/__tests__/Blueprint.test.ts +106 -0
- package/src/__tests__/action-context.test.ts +166 -0
- package/src/__tests__/actionCreators.test.ts +179 -0
- package/src/__tests__/builders.test.ts +336 -0
- package/src/__tests__/defineBlueprint-composition.test.ts +106 -0
- package/src/__tests__/factories.test.ts +229 -0
- package/src/__tests__/loader.test.ts +159 -0
- package/src/__tests__/logger.test.ts +70 -0
- package/src/__tests__/type-inference.test.ts +160 -0
- package/src/__tests__/typed-transitions.test.ts +126 -0
- package/src/__tests__/useModuleConfig.test.ts +61 -0
- package/src/actionCreators.ts +132 -0
- package/src/actions.ts +547 -0
- package/src/atoms/index.ts +600 -0
- package/src/authoring.ts +92 -0
- package/src/browser-player.ts +783 -0
- package/src/builders.ts +1342 -0
- package/src/components/ExperienceWorkflowBridge.tsx +123 -0
- package/src/components/PlayerProvider.tsx +43 -0
- package/src/components/atoms/index.tsx +269 -0
- package/src/components/index.ts +36 -0
- package/src/conditions.ts +692 -0
- package/src/config/defineBlueprint.ts +329 -0
- package/src/config/defineModel.ts +753 -0
- package/src/config/defineWorkspace.ts +24 -0
- package/src/core/WorkflowRuntime.ts +153 -0
- package/src/factories.ts +425 -0
- package/src/grammar/index.ts +173 -0
- package/src/hooks/index.ts +106 -0
- package/src/hooks/useAuth.ts +288 -0
- package/src/hooks/useChannel.ts +304 -0
- package/src/hooks/useComputed.ts +154 -0
- package/src/hooks/useDomainSubscription.ts +110 -0
- package/src/hooks/useDuringAction.ts +99 -0
- package/src/hooks/useExperienceState.ts +59 -0
- package/src/hooks/useExpressionLibrary.ts +129 -0
- package/src/hooks/useForm.ts +352 -0
- package/src/hooks/useGeolocation.ts +207 -0
- package/src/hooks/useMapView.ts +259 -0
- package/src/hooks/useMiddleware.ts +291 -0
- package/src/hooks/useModel.ts +363 -0
- package/src/hooks/useModule.ts +59 -0
- package/src/hooks/useModuleConfig.ts +61 -0
- package/src/hooks/useMutation.ts +237 -0
- package/src/hooks/useNotification.ts +151 -0
- package/src/hooks/useOnChange.ts +30 -0
- package/src/hooks/useOnEnter.ts +59 -0
- package/src/hooks/useOnEvent.ts +37 -0
- package/src/hooks/useOnExit.ts +27 -0
- package/src/hooks/useOnTransition.ts +30 -0
- package/src/hooks/usePackage.ts +128 -0
- package/src/hooks/useParams.ts +33 -0
- package/src/hooks/usePlayer.ts +308 -0
- package/src/hooks/useQuery.ts +184 -0
- package/src/hooks/useRealtimeQuery.ts +222 -0
- package/src/hooks/useRole.ts +191 -0
- package/src/hooks/useRouteParams.ts +100 -0
- package/src/hooks/useRouter.ts +347 -0
- package/src/hooks/useServerAction.ts +178 -0
- package/src/hooks/useServerState.ts +284 -0
- package/src/hooks/useToast.ts +164 -0
- package/src/hooks/useTransition.ts +39 -0
- package/src/hooks/useView.ts +102 -0
- package/src/hooks/useWhileIn.ts +48 -0
- package/src/hooks/useWorkflow.ts +63 -0
- package/src/index.ts +465 -0
- package/src/loader/experience-workflow-loader.ts +192 -0
- package/src/loader/index.ts +6 -0
- package/src/local/LocalEngine.ts +388 -0
- package/src/local/LocalEngineAdapter.ts +175 -0
- package/src/local/LocalEngineContext.ts +30 -0
- package/src/logger.ts +37 -0
- package/src/mixins.ts +1160 -0
- package/src/providers/RuntimeContext.ts +20 -0
- package/src/providers/WorkflowProvider.tsx +28 -0
- package/src/routing/instance-key.ts +107 -0
- package/src/server/transition-context.ts +172 -0
- package/src/testing/index.ts +9 -0
- package/src/testing/useBlueprintTestRunner.ts +91 -0
- package/src/testing/useGraphAnalysis.ts +18 -0
- package/src/testing/useTestRunner.ts +77 -0
- package/src/testing.ts +995 -0
- package/src/types/workflow-inference.ts +158 -0
- package/src/types.ts +114 -0
- package/tsconfig.json +27 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { model, field, state } from '../builders';
|
|
3
|
+
import { approval, escalation, review, crud } from '../factories';
|
|
4
|
+
|
|
5
|
+
describe('approval factory', () => {
|
|
6
|
+
it('adds approve/reject transitions with defaults', () => {
|
|
7
|
+
const def = model('invoice')
|
|
8
|
+
.field('amount', field.currency())
|
|
9
|
+
.state('draft', state.initial())
|
|
10
|
+
.state('pending_approval')
|
|
11
|
+
.mixin(approval())
|
|
12
|
+
.build();
|
|
13
|
+
|
|
14
|
+
expect(def.transitions.approve).toBeDefined();
|
|
15
|
+
expect(def.transitions.approve.from).toBe('pending_approval');
|
|
16
|
+
expect(def.transitions.approve.to).toBe('approved');
|
|
17
|
+
expect(def.transitions.approve.roles).toContain('approver');
|
|
18
|
+
|
|
19
|
+
expect(def.transitions.reject).toBeDefined();
|
|
20
|
+
expect(def.transitions.reject.from).toBe('pending_approval');
|
|
21
|
+
expect(def.transitions.reject.to).toBe('rejected');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('adds approval-related fields', () => {
|
|
25
|
+
const def = model('invoice')
|
|
26
|
+
.state('draft', state.initial())
|
|
27
|
+
.mixin(approval())
|
|
28
|
+
.build();
|
|
29
|
+
|
|
30
|
+
expect(def.fields.approvalStatus).toBeDefined();
|
|
31
|
+
expect(def.fields.approvedBy).toBeDefined();
|
|
32
|
+
expect(def.fields.approvedAt).toBeDefined();
|
|
33
|
+
expect(def.fields.rejectionReason).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('respects custom config', () => {
|
|
37
|
+
const def = model('invoice')
|
|
38
|
+
.state('draft', state.initial())
|
|
39
|
+
.state('review')
|
|
40
|
+
.mixin(approval({
|
|
41
|
+
approverRole: 'manager',
|
|
42
|
+
field: 'status',
|
|
43
|
+
fromState: 'review',
|
|
44
|
+
approvedState: 'done',
|
|
45
|
+
rejectedState: 'cancelled',
|
|
46
|
+
}))
|
|
47
|
+
.build();
|
|
48
|
+
|
|
49
|
+
expect(def.transitions.approve.from).toBe('review');
|
|
50
|
+
expect(def.transitions.approve.to).toBe('done');
|
|
51
|
+
expect(def.transitions.approve.roles).toContain('manager');
|
|
52
|
+
expect(def.transitions.reject.to).toBe('cancelled');
|
|
53
|
+
expect(def.fields.status).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('adds approver role', () => {
|
|
57
|
+
const def = model('invoice')
|
|
58
|
+
.state('draft', state.initial())
|
|
59
|
+
.mixin(approval())
|
|
60
|
+
.build();
|
|
61
|
+
|
|
62
|
+
expect(def.roles!.approver).toBeDefined();
|
|
63
|
+
expect(def.roles!.approver.permissions).toContain('approve');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('escalation factory', () => {
|
|
68
|
+
it('adds escalate transition with timeout', () => {
|
|
69
|
+
const def = model('ticket')
|
|
70
|
+
.state('pending_approval', state.initial())
|
|
71
|
+
.mixin(escalation({ timeout: '24h' }))
|
|
72
|
+
.build();
|
|
73
|
+
|
|
74
|
+
expect(def.transitions.escalate).toBeDefined();
|
|
75
|
+
expect(def.transitions.escalate.from).toBe('pending_approval');
|
|
76
|
+
expect(def.transitions.escalate.to).toBe('escalated');
|
|
77
|
+
expect(def.transitions.escalate.auto).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('sets timeout on the source state', () => {
|
|
81
|
+
const def = model('ticket')
|
|
82
|
+
.state('pending_approval', state.initial())
|
|
83
|
+
.mixin(escalation({ timeout: '2d', fromState: 'pending_approval' }))
|
|
84
|
+
.build();
|
|
85
|
+
|
|
86
|
+
expect(def.states.pending_approval.timeout).toBeDefined();
|
|
87
|
+
expect(def.states.pending_approval.timeout!.duration).toBe('2d');
|
|
88
|
+
expect(def.states.pending_approval.timeout!.fallback?.transition).toBe('escalate');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('adds escalation fields', () => {
|
|
92
|
+
const def = model('ticket')
|
|
93
|
+
.state('open', state.initial())
|
|
94
|
+
.mixin(escalation({ timeout: '1h', fromState: 'open' }))
|
|
95
|
+
.build();
|
|
96
|
+
|
|
97
|
+
expect(def.fields.escalatedAt).toBeDefined();
|
|
98
|
+
expect(def.fields.escalationLevel).toBeDefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('review factory', () => {
|
|
103
|
+
it('adds submit/approve/revise cycle', () => {
|
|
104
|
+
const def = model('article')
|
|
105
|
+
.field('content', field.string())
|
|
106
|
+
.state('draft', state.initial())
|
|
107
|
+
.mixin(review())
|
|
108
|
+
.build();
|
|
109
|
+
|
|
110
|
+
expect(def.transitions.submit_for_review).toBeDefined();
|
|
111
|
+
expect(def.transitions.submit_for_review.from).toBe('draft');
|
|
112
|
+
expect(def.transitions.submit_for_review.to).toBe('in_review');
|
|
113
|
+
|
|
114
|
+
expect(def.transitions.approve_review).toBeDefined();
|
|
115
|
+
expect(def.transitions.approve_review.from).toBe('in_review');
|
|
116
|
+
expect(def.transitions.approve_review.to).toBe('approved');
|
|
117
|
+
|
|
118
|
+
expect(def.transitions.request_revisions).toBeDefined();
|
|
119
|
+
expect(def.transitions.request_revisions.from).toBe('in_review');
|
|
120
|
+
expect(def.transitions.request_revisions.to).toBe('revisions_requested');
|
|
121
|
+
|
|
122
|
+
expect(def.transitions.resubmit).toBeDefined();
|
|
123
|
+
expect(def.transitions.resubmit.from).toBe('revisions_requested');
|
|
124
|
+
expect(def.transitions.resubmit.to).toBe('in_review');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('adds review fields', () => {
|
|
128
|
+
const def = model('article')
|
|
129
|
+
.state('draft', state.initial())
|
|
130
|
+
.mixin(review())
|
|
131
|
+
.build();
|
|
132
|
+
|
|
133
|
+
expect(def.fields.reviewNotes).toBeDefined();
|
|
134
|
+
expect(def.fields.reviewedBy).toBeDefined();
|
|
135
|
+
expect(def.fields.reviewedAt).toBeDefined();
|
|
136
|
+
expect(def.fields.revisionCount).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('adds author and reviewer roles', () => {
|
|
140
|
+
const def = model('article')
|
|
141
|
+
.state('draft', state.initial())
|
|
142
|
+
.mixin(review())
|
|
143
|
+
.build();
|
|
144
|
+
|
|
145
|
+
expect(def.roles!.author).toBeDefined();
|
|
146
|
+
expect(def.roles!.reviewer).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('respects custom role names', () => {
|
|
150
|
+
const def = model('article')
|
|
151
|
+
.state('draft', state.initial())
|
|
152
|
+
.mixin(review({ authorRole: 'writer', reviewerRole: 'editor' }))
|
|
153
|
+
.build();
|
|
154
|
+
|
|
155
|
+
expect(def.roles!.writer).toBeDefined();
|
|
156
|
+
expect(def.roles!.editor).toBeDefined();
|
|
157
|
+
expect(def.transitions.submit_for_review.roles).toContain('writer');
|
|
158
|
+
expect(def.transitions.approve_review.roles).toContain('editor');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('crud factory', () => {
|
|
163
|
+
it('adds draft/active/archived lifecycle (soft delete)', () => {
|
|
164
|
+
const def = model('product')
|
|
165
|
+
.field('name', field.string().required())
|
|
166
|
+
.mixin(crud())
|
|
167
|
+
.build();
|
|
168
|
+
|
|
169
|
+
expect(def.states.draft).toBeDefined();
|
|
170
|
+
expect(def.states.draft.type).toBe('initial');
|
|
171
|
+
expect(def.states.active).toBeDefined();
|
|
172
|
+
expect(def.states.archived).toBeDefined();
|
|
173
|
+
|
|
174
|
+
expect(def.transitions.publish).toBeDefined();
|
|
175
|
+
expect(def.transitions.update).toBeDefined();
|
|
176
|
+
expect(def.transitions.archive).toBeDefined();
|
|
177
|
+
expect(def.transitions.restore).toBeDefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('adds hard delete when softDelete=false', () => {
|
|
181
|
+
const def = model('temp')
|
|
182
|
+
.field('data', field.string())
|
|
183
|
+
.mixin(crud({ softDelete: false }))
|
|
184
|
+
.build();
|
|
185
|
+
|
|
186
|
+
expect(def.states.deleted).toBeDefined();
|
|
187
|
+
expect(def.states.deleted.type).toBe('end');
|
|
188
|
+
expect(def.transitions.delete_record).toBeDefined();
|
|
189
|
+
expect(def.transitions.archive).toBeUndefined();
|
|
190
|
+
expect(def.transitions.restore).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('adds CRUD fields', () => {
|
|
194
|
+
const def = model('product')
|
|
195
|
+
.mixin(crud())
|
|
196
|
+
.build();
|
|
197
|
+
|
|
198
|
+
expect(def.fields.createdBy).toBeDefined();
|
|
199
|
+
expect(def.fields.createdAt).toBeDefined();
|
|
200
|
+
expect(def.fields.updatedAt).toBeDefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('does not overwrite existing fields', () => {
|
|
204
|
+
const def = model('product')
|
|
205
|
+
.field('createdBy', field.string('system'))
|
|
206
|
+
.mixin(crud())
|
|
207
|
+
.build();
|
|
208
|
+
|
|
209
|
+
// Our manually set field should be preserved
|
|
210
|
+
expect(def.fields.createdBy.default).toBe('system');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('composable: approval + escalation', () => {
|
|
214
|
+
const def = model('po')
|
|
215
|
+
.field('amount', field.currency())
|
|
216
|
+
.state('draft', state.initial())
|
|
217
|
+
.state('pending_approval')
|
|
218
|
+
.mixin(approval({ approverRole: 'manager' }))
|
|
219
|
+
.mixin(escalation({ timeout: '48h' }))
|
|
220
|
+
.build();
|
|
221
|
+
|
|
222
|
+
// Both patterns should coexist
|
|
223
|
+
expect(def.transitions.approve).toBeDefined();
|
|
224
|
+
expect(def.transitions.reject).toBeDefined();
|
|
225
|
+
expect(def.transitions.escalate).toBeDefined();
|
|
226
|
+
expect(def.states.escalated).toBeDefined();
|
|
227
|
+
expect(def.states.approved).toBeDefined();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateExperienceWorkflow, normalizeDefinition } from '../loader';
|
|
3
|
+
|
|
4
|
+
const validDef = {
|
|
5
|
+
id: 'test-1',
|
|
6
|
+
slug: 'test-workflow',
|
|
7
|
+
states: [
|
|
8
|
+
{ name: 'start', type: 'START' },
|
|
9
|
+
{ name: 'active', type: 'REGULAR' },
|
|
10
|
+
{ name: 'done', type: 'END' },
|
|
11
|
+
],
|
|
12
|
+
transitions: [
|
|
13
|
+
{ name: 'begin', from: ['start'], to: 'active' },
|
|
14
|
+
{ name: 'finish', from: ['active'], to: 'done' },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('validateExperienceWorkflow', () => {
|
|
19
|
+
it('validates a correct definition', () => {
|
|
20
|
+
const result = validateExperienceWorkflow(validDef);
|
|
21
|
+
expect(result.valid).toBe(true);
|
|
22
|
+
expect(result.errors).toHaveLength(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rejects non-object input', () => {
|
|
26
|
+
expect(validateExperienceWorkflow(null).valid).toBe(false);
|
|
27
|
+
expect(validateExperienceWorkflow('string').valid).toBe(false);
|
|
28
|
+
expect(validateExperienceWorkflow(42).valid).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('requires id and slug', () => {
|
|
32
|
+
const result = validateExperienceWorkflow({ states: [{ name: 'x', type: 'START' }], transitions: [] });
|
|
33
|
+
expect(result.valid).toBe(false);
|
|
34
|
+
expect(result.errors.some(e => e.includes('id'))).toBe(true);
|
|
35
|
+
expect(result.errors.some(e => e.includes('slug'))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('requires at least one state', () => {
|
|
39
|
+
const result = validateExperienceWorkflow({ id: '1', slug: 's', states: [], transitions: [] });
|
|
40
|
+
expect(result.valid).toBe(false);
|
|
41
|
+
expect(result.errors.some(e => e.includes('at least one state'))).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('requires a START state', () => {
|
|
45
|
+
const result = validateExperienceWorkflow({
|
|
46
|
+
id: '1', slug: 's',
|
|
47
|
+
states: [{ name: 'x', type: 'REGULAR' }],
|
|
48
|
+
transitions: [],
|
|
49
|
+
});
|
|
50
|
+
expect(result.valid).toBe(false);
|
|
51
|
+
expect(result.errors.some(e => e.includes('START'))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('detects duplicate state names', () => {
|
|
55
|
+
const result = validateExperienceWorkflow({
|
|
56
|
+
id: '1', slug: 's',
|
|
57
|
+
states: [{ name: 'x', type: 'START' }, { name: 'x', type: 'REGULAR' }],
|
|
58
|
+
transitions: [],
|
|
59
|
+
});
|
|
60
|
+
expect(result.valid).toBe(false);
|
|
61
|
+
expect(result.errors.some(e => e.includes('Duplicate'))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('detects unknown transition target states', () => {
|
|
65
|
+
const result = validateExperienceWorkflow({
|
|
66
|
+
id: '1', slug: 's',
|
|
67
|
+
states: [{ name: 'start', type: 'START' }],
|
|
68
|
+
transitions: [{ name: 't', from: ['start'], to: 'nonexistent' }],
|
|
69
|
+
});
|
|
70
|
+
expect(result.valid).toBe(false);
|
|
71
|
+
expect(result.errors.some(e => e.includes('unknown state'))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('detects unknown transition from states', () => {
|
|
75
|
+
const result = validateExperienceWorkflow({
|
|
76
|
+
id: '1', slug: 's',
|
|
77
|
+
states: [{ name: 'start', type: 'START' }, { name: 'end', type: 'END' }],
|
|
78
|
+
transitions: [{ name: 't', from: ['ghost'], to: 'end' }],
|
|
79
|
+
});
|
|
80
|
+
expect(result.valid).toBe(false);
|
|
81
|
+
expect(result.errors.some(e => e.includes('unknown "from" state'))).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('validates on_event subscriptions', () => {
|
|
85
|
+
const result = validateExperienceWorkflow({
|
|
86
|
+
id: '1', slug: 's',
|
|
87
|
+
states: [{
|
|
88
|
+
name: 'start', type: 'START',
|
|
89
|
+
on_event: [{ actions: [{ type: 'log', config: {} }] }], // missing match
|
|
90
|
+
}],
|
|
91
|
+
transitions: [],
|
|
92
|
+
});
|
|
93
|
+
expect(result.valid).toBe(false);
|
|
94
|
+
expect(result.errors.some(e => e.includes('match'))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('warns on on_event with empty actions', () => {
|
|
98
|
+
const result = validateExperienceWorkflow({
|
|
99
|
+
id: '1', slug: 's',
|
|
100
|
+
states: [{
|
|
101
|
+
name: 'start', type: 'START',
|
|
102
|
+
on_event: [{ match: 'test.*', actions: [] }],
|
|
103
|
+
}],
|
|
104
|
+
transitions: [],
|
|
105
|
+
});
|
|
106
|
+
expect(result.valid).toBe(true); // warning, not error
|
|
107
|
+
expect(result.warnings.some(w => w.includes('no actions'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('normalizeDefinition', () => {
|
|
112
|
+
it('normalizes a valid definition', () => {
|
|
113
|
+
const def = normalizeDefinition(validDef);
|
|
114
|
+
expect(def.id).toBe('test-1');
|
|
115
|
+
expect(def.slug).toBe('test-workflow');
|
|
116
|
+
expect(def.states).toHaveLength(3);
|
|
117
|
+
expect(def.transitions).toHaveLength(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('defaults state type to REGULAR', () => {
|
|
121
|
+
const def = normalizeDefinition({
|
|
122
|
+
id: '1', slug: 's',
|
|
123
|
+
states: [{ name: 'x' }],
|
|
124
|
+
transitions: [],
|
|
125
|
+
});
|
|
126
|
+
expect(def.states[0].type).toBe('REGULAR');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('coerces string "from" to array', () => {
|
|
130
|
+
const def = normalizeDefinition({
|
|
131
|
+
id: '1', slug: 's',
|
|
132
|
+
states: [{ name: 'a', type: 'START' }, { name: 'b', type: 'END' }],
|
|
133
|
+
transitions: [{ name: 't', from: 'a', to: 'b' }],
|
|
134
|
+
});
|
|
135
|
+
expect(def.transitions[0].from).toEqual(['a']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('normalizes on_event with "pattern" field to "match"', () => {
|
|
139
|
+
const def = normalizeDefinition({
|
|
140
|
+
id: '1', slug: 's',
|
|
141
|
+
states: [{
|
|
142
|
+
name: 'x', type: 'START',
|
|
143
|
+
on_event: [{ pattern: 'test.*', actions: [{ type: 'log', config: {} }] }],
|
|
144
|
+
}],
|
|
145
|
+
transitions: [],
|
|
146
|
+
});
|
|
147
|
+
expect(def.states[0].on_event?.[0].match).toBe('test.*');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('preserves state_views and experience metadata', () => {
|
|
151
|
+
const def = normalizeDefinition({
|
|
152
|
+
...validDef,
|
|
153
|
+
state_views: { active: { title: 'Active View' } },
|
|
154
|
+
experience: { default_layout: 'grid' },
|
|
155
|
+
});
|
|
156
|
+
expect(def.state_views?.active.title).toBe('Active View');
|
|
157
|
+
expect(def.experience?.default_layout).toBe('grid');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { setPlayerDebug, isPlayerDebug, playerLog } from '../logger';
|
|
3
|
+
|
|
4
|
+
describe('Player Logger', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
setPlayerDebug(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('toggles debug mode', () => {
|
|
10
|
+
expect(isPlayerDebug()).toBe(false);
|
|
11
|
+
setPlayerDebug(true);
|
|
12
|
+
expect(isPlayerDebug()).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('suppresses debug logs when debug is off', () => {
|
|
16
|
+
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
17
|
+
playerLog({ level: 'debug', category: 'event_match', message: 'test' });
|
|
18
|
+
expect(spy).not.toHaveBeenCalled();
|
|
19
|
+
spy.mockRestore();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('emits debug logs when debug is on', () => {
|
|
23
|
+
setPlayerDebug(true);
|
|
24
|
+
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
25
|
+
playerLog({ level: 'debug', category: 'event_match', message: 'test' });
|
|
26
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
27
|
+
spy.mockRestore();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('emits warn logs regardless of debug setting', () => {
|
|
31
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
32
|
+
playerLog({ level: 'warn', category: 'action_dispatch', message: 'warning' });
|
|
33
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
34
|
+
spy.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('emits error logs', () => {
|
|
38
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
39
|
+
playerLog({ level: 'error', category: 'transition', message: 'error msg' });
|
|
40
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
41
|
+
spy.mockRestore();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('emits info logs', () => {
|
|
45
|
+
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
46
|
+
playerLog({ level: 'info', category: 'lifecycle', message: 'info msg' });
|
|
47
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
48
|
+
spy.mockRestore();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('includes category prefix in log message', () => {
|
|
52
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
53
|
+
playerLog({ level: 'warn', category: 'event_match', message: 'matched' });
|
|
54
|
+
expect(spy).toHaveBeenCalledWith(
|
|
55
|
+
expect.stringContaining('[player-web:event_match]'),
|
|
56
|
+
expect.anything(),
|
|
57
|
+
);
|
|
58
|
+
spy.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('passes data object to console', () => {
|
|
62
|
+
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
63
|
+
playerLog({ level: 'info', category: 'lifecycle', message: 'test', data: { key: 'val' } });
|
|
64
|
+
expect(spy).toHaveBeenCalledWith(
|
|
65
|
+
expect.any(String),
|
|
66
|
+
{ key: 'val' },
|
|
67
|
+
);
|
|
68
|
+
spy.mockRestore();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from 'vitest';
|
|
2
|
+
import { defineModel } from '../config/defineModel';
|
|
3
|
+
import type {
|
|
4
|
+
InferFields,
|
|
5
|
+
InferTransitions,
|
|
6
|
+
InferStates,
|
|
7
|
+
InferSlug,
|
|
8
|
+
InferFieldNames,
|
|
9
|
+
} from '../types/workflow-inference';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Test model definition
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const employeeModel = defineModel({
|
|
16
|
+
slug: 'employee',
|
|
17
|
+
version: '1.0.0',
|
|
18
|
+
category: 'data',
|
|
19
|
+
fields: {
|
|
20
|
+
name: { type: 'string', required: true },
|
|
21
|
+
email: { type: 'email', required: true },
|
|
22
|
+
salary: { type: 'currency', default: 0 },
|
|
23
|
+
isActive: { type: 'boolean', default: true },
|
|
24
|
+
startDate: { type: 'date' },
|
|
25
|
+
tags: { type: 'array' },
|
|
26
|
+
metadata: { type: 'object' },
|
|
27
|
+
department: { type: 'string', enum: ['engineering', 'sales', 'hr'] as const },
|
|
28
|
+
rating: { type: 'rating' },
|
|
29
|
+
},
|
|
30
|
+
states: {
|
|
31
|
+
onboarding: { type: 'initial' },
|
|
32
|
+
active: {},
|
|
33
|
+
on_leave: {},
|
|
34
|
+
terminated: { type: 'end' },
|
|
35
|
+
},
|
|
36
|
+
transitions: {
|
|
37
|
+
activate: { from: 'onboarding', to: 'active' },
|
|
38
|
+
request_leave: { from: 'active', to: 'on_leave' },
|
|
39
|
+
return_from_leave: { from: 'on_leave', to: 'active' },
|
|
40
|
+
terminate: { from: ['active', 'on_leave'], to: 'terminated' },
|
|
41
|
+
},
|
|
42
|
+
} as const);
|
|
43
|
+
|
|
44
|
+
type EmployeeFields = InferFields<typeof employeeModel>;
|
|
45
|
+
type EmployeeTransitions = InferTransitions<typeof employeeModel>;
|
|
46
|
+
type EmployeeStates = InferStates<typeof employeeModel>;
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Type-level tests
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
describe('InferFields', () => {
|
|
53
|
+
it('maps string-family types to string', () => {
|
|
54
|
+
expectTypeOf<EmployeeFields['name']>().toEqualTypeOf<string>();
|
|
55
|
+
expectTypeOf<EmployeeFields['email']>().toEqualTypeOf<string>();
|
|
56
|
+
expectTypeOf<EmployeeFields['department']>().toEqualTypeOf<string>();
|
|
57
|
+
expectTypeOf<EmployeeFields['startDate']>().toEqualTypeOf<string>();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('maps numeric types to number', () => {
|
|
61
|
+
expectTypeOf<EmployeeFields['salary']>().toEqualTypeOf<number>();
|
|
62
|
+
expectTypeOf<EmployeeFields['rating']>().toEqualTypeOf<number>();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('maps boolean type to boolean', () => {
|
|
66
|
+
expectTypeOf<EmployeeFields['isActive']>().toEqualTypeOf<boolean>();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('maps complex types correctly', () => {
|
|
70
|
+
expectTypeOf<EmployeeFields['tags']>().toEqualTypeOf<unknown[]>();
|
|
71
|
+
expectTypeOf<EmployeeFields['metadata']>().toEqualTypeOf<Record<string, unknown>>();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('produces an object with all field keys', () => {
|
|
75
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('name');
|
|
76
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('email');
|
|
77
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('salary');
|
|
78
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('isActive');
|
|
79
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('startDate');
|
|
80
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('tags');
|
|
81
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('metadata');
|
|
82
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('department');
|
|
83
|
+
expectTypeOf<EmployeeFields>().toHaveProperty('rating');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to Record<string, unknown> for non-model types', () => {
|
|
87
|
+
type NoFields = InferFields<{ slug: 'x' }>;
|
|
88
|
+
expectTypeOf<NoFields>().toEqualTypeOf<Record<string, unknown>>();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('InferTransitions', () => {
|
|
93
|
+
it('extracts transition names as a union', () => {
|
|
94
|
+
expectTypeOf<EmployeeTransitions>().toEqualTypeOf<
|
|
95
|
+
'activate' | 'request_leave' | 'return_from_leave' | 'terminate'
|
|
96
|
+
>();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('falls back to string for non-model types', () => {
|
|
100
|
+
type NoTrans = InferTransitions<{ slug: 'x' }>;
|
|
101
|
+
expectTypeOf<NoTrans>().toEqualTypeOf<string>();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('InferStates', () => {
|
|
106
|
+
it('extracts state names as a union', () => {
|
|
107
|
+
expectTypeOf<EmployeeStates>().toEqualTypeOf<
|
|
108
|
+
'onboarding' | 'active' | 'on_leave' | 'terminated'
|
|
109
|
+
>();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('falls back to string for non-model types', () => {
|
|
113
|
+
type NoStates = InferStates<{ slug: 'x' }>;
|
|
114
|
+
expectTypeOf<NoStates>().toEqualTypeOf<string>();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('InferSlug', () => {
|
|
119
|
+
it('extracts the literal slug type', () => {
|
|
120
|
+
expectTypeOf<InferSlug<typeof employeeModel>>().toEqualTypeOf<'employee'>();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('InferFieldNames', () => {
|
|
125
|
+
it('extracts field name union', () => {
|
|
126
|
+
expectTypeOf<InferFieldNames<typeof employeeModel>>().toEqualTypeOf<
|
|
127
|
+
'name' | 'email' | 'salary' | 'isActive' | 'startDate' | 'tags' | 'metadata' | 'department' | 'rating'
|
|
128
|
+
>();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('defineModel', () => {
|
|
133
|
+
it('returns the definition unchanged at runtime', () => {
|
|
134
|
+
expect(employeeModel.slug).toBe('employee');
|
|
135
|
+
expect(Object.keys(employeeModel.fields)).toHaveLength(9);
|
|
136
|
+
expect(Object.keys(employeeModel.states)).toHaveLength(4);
|
|
137
|
+
expect(Object.keys(employeeModel.transitions)).toHaveLength(4);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('preserves const literal types', () => {
|
|
141
|
+
// The slug should be the literal 'employee', not string
|
|
142
|
+
expectTypeOf(employeeModel.slug).toEqualTypeOf<'employee'>();
|
|
143
|
+
// Field keys should be literal union
|
|
144
|
+
expectTypeOf<keyof typeof employeeModel.fields>().toEqualTypeOf<
|
|
145
|
+
'name' | 'email' | 'salary' | 'isActive' | 'startDate' | 'tags' | 'metadata' | 'department' | 'rating'
|
|
146
|
+
>();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws in dev mode when slug is missing', () => {
|
|
150
|
+
expect(() => defineModel({ slug: '', fields: {}, states: {}, transitions: {} })).toThrow(
|
|
151
|
+
'slug is required',
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('throws in dev mode when fields is missing', () => {
|
|
156
|
+
expect(() => defineModel({ slug: 'x', fields: null as any, states: {}, transitions: {} })).toThrow(
|
|
157
|
+
'fields object is required',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|