@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.
Files changed (98) hide show
  1. package/dist/atoms/index.d.mts +424 -0
  2. package/dist/atoms/index.d.ts +424 -0
  3. package/dist/atoms/index.js +195 -0
  4. package/dist/atoms/index.mjs +110 -0
  5. package/dist/chunk-2VJQJM7S.mjs +119 -0
  6. package/dist/index.d.mts +6999 -0
  7. package/dist/index.d.ts +6999 -0
  8. package/dist/index.js +8247 -0
  9. package/dist/index.mjs +8016 -0
  10. package/package.json +37 -0
  11. package/package.json.backup +41 -0
  12. package/src/Blueprint.ts +216 -0
  13. package/src/__tests__/Blueprint.test.ts +106 -0
  14. package/src/__tests__/action-context.test.ts +166 -0
  15. package/src/__tests__/actionCreators.test.ts +179 -0
  16. package/src/__tests__/builders.test.ts +336 -0
  17. package/src/__tests__/defineBlueprint-composition.test.ts +106 -0
  18. package/src/__tests__/factories.test.ts +229 -0
  19. package/src/__tests__/loader.test.ts +159 -0
  20. package/src/__tests__/logger.test.ts +70 -0
  21. package/src/__tests__/type-inference.test.ts +160 -0
  22. package/src/__tests__/typed-transitions.test.ts +126 -0
  23. package/src/__tests__/useModuleConfig.test.ts +61 -0
  24. package/src/actionCreators.ts +132 -0
  25. package/src/actions.ts +547 -0
  26. package/src/atoms/index.ts +600 -0
  27. package/src/authoring.ts +92 -0
  28. package/src/browser-player.ts +783 -0
  29. package/src/builders.ts +1342 -0
  30. package/src/components/ExperienceWorkflowBridge.tsx +123 -0
  31. package/src/components/PlayerProvider.tsx +43 -0
  32. package/src/components/atoms/index.tsx +269 -0
  33. package/src/components/index.ts +36 -0
  34. package/src/conditions.ts +692 -0
  35. package/src/config/defineBlueprint.ts +329 -0
  36. package/src/config/defineModel.ts +753 -0
  37. package/src/config/defineWorkspace.ts +24 -0
  38. package/src/core/WorkflowRuntime.ts +153 -0
  39. package/src/factories.ts +425 -0
  40. package/src/grammar/index.ts +173 -0
  41. package/src/hooks/index.ts +106 -0
  42. package/src/hooks/useAuth.ts +288 -0
  43. package/src/hooks/useChannel.ts +304 -0
  44. package/src/hooks/useComputed.ts +154 -0
  45. package/src/hooks/useDomainSubscription.ts +110 -0
  46. package/src/hooks/useDuringAction.ts +99 -0
  47. package/src/hooks/useExperienceState.ts +59 -0
  48. package/src/hooks/useExpressionLibrary.ts +129 -0
  49. package/src/hooks/useForm.ts +352 -0
  50. package/src/hooks/useGeolocation.ts +207 -0
  51. package/src/hooks/useMapView.ts +259 -0
  52. package/src/hooks/useMiddleware.ts +291 -0
  53. package/src/hooks/useModel.ts +363 -0
  54. package/src/hooks/useModule.ts +59 -0
  55. package/src/hooks/useModuleConfig.ts +61 -0
  56. package/src/hooks/useMutation.ts +237 -0
  57. package/src/hooks/useNotification.ts +151 -0
  58. package/src/hooks/useOnChange.ts +30 -0
  59. package/src/hooks/useOnEnter.ts +59 -0
  60. package/src/hooks/useOnEvent.ts +37 -0
  61. package/src/hooks/useOnExit.ts +27 -0
  62. package/src/hooks/useOnTransition.ts +30 -0
  63. package/src/hooks/usePackage.ts +128 -0
  64. package/src/hooks/useParams.ts +33 -0
  65. package/src/hooks/usePlayer.ts +308 -0
  66. package/src/hooks/useQuery.ts +184 -0
  67. package/src/hooks/useRealtimeQuery.ts +222 -0
  68. package/src/hooks/useRole.ts +191 -0
  69. package/src/hooks/useRouteParams.ts +100 -0
  70. package/src/hooks/useRouter.ts +347 -0
  71. package/src/hooks/useServerAction.ts +178 -0
  72. package/src/hooks/useServerState.ts +284 -0
  73. package/src/hooks/useToast.ts +164 -0
  74. package/src/hooks/useTransition.ts +39 -0
  75. package/src/hooks/useView.ts +102 -0
  76. package/src/hooks/useWhileIn.ts +48 -0
  77. package/src/hooks/useWorkflow.ts +63 -0
  78. package/src/index.ts +465 -0
  79. package/src/loader/experience-workflow-loader.ts +192 -0
  80. package/src/loader/index.ts +6 -0
  81. package/src/local/LocalEngine.ts +388 -0
  82. package/src/local/LocalEngineAdapter.ts +175 -0
  83. package/src/local/LocalEngineContext.ts +30 -0
  84. package/src/logger.ts +37 -0
  85. package/src/mixins.ts +1160 -0
  86. package/src/providers/RuntimeContext.ts +20 -0
  87. package/src/providers/WorkflowProvider.tsx +28 -0
  88. package/src/routing/instance-key.ts +107 -0
  89. package/src/server/transition-context.ts +172 -0
  90. package/src/testing/index.ts +9 -0
  91. package/src/testing/useBlueprintTestRunner.ts +91 -0
  92. package/src/testing/useGraphAnalysis.ts +18 -0
  93. package/src/testing/useTestRunner.ts +77 -0
  94. package/src/testing.ts +995 -0
  95. package/src/types/workflow-inference.ts +158 -0
  96. package/src/types.ts +114 -0
  97. package/tsconfig.json +27 -0
  98. 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
+ });