prev-cli 0.24.19 → 0.25.0
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/cli.js +2006 -1703
- package/dist/previews/components/cart-item/index.d.ts +5 -0
- package/dist/previews/components/price-tag/index.d.ts +6 -0
- package/dist/previews/screens/cart/empty.d.ts +1 -0
- package/dist/previews/screens/cart/index.d.ts +1 -0
- package/dist/previews/screens/payment/error.d.ts +1 -0
- package/dist/previews/screens/payment/index.d.ts +1 -0
- package/dist/previews/screens/payment/processing.d.ts +1 -0
- package/dist/previews/screens/receipt/index.d.ts +1 -0
- package/dist/previews/shared/data.d.ts +30 -0
- package/dist/src/content/config-parser.d.ts +30 -0
- package/dist/src/content/flow-verifier.d.ts +21 -0
- package/dist/src/content/preview-types.d.ts +288 -0
- package/dist/{vite → src/content}/previews.d.ts +3 -11
- package/dist/{preview-runtime → src/preview-runtime}/build-optimized.d.ts +2 -0
- package/dist/{preview-runtime → src/preview-runtime}/build.d.ts +1 -1
- package/dist/src/preview-runtime/region-bridge.d.ts +1 -0
- package/dist/{preview-runtime → src/preview-runtime}/types.d.ts +18 -0
- package/dist/src/preview-runtime/vendors.d.ts +11 -0
- package/dist/{renderers → src/renderers}/index.d.ts +1 -1
- package/dist/{renderers → src/renderers}/types.d.ts +3 -31
- package/dist/src/server/build.d.ts +6 -0
- package/dist/src/server/dev.d.ts +13 -0
- package/dist/src/server/plugins/aliases.d.ts +5 -0
- package/dist/src/server/plugins/mdx.d.ts +5 -0
- package/dist/src/server/plugins/virtual-modules.d.ts +8 -0
- package/dist/src/server/preview.d.ts +10 -0
- package/dist/src/server/routes/component-bundle.d.ts +1 -0
- package/dist/src/server/routes/jsx-bundle.d.ts +3 -0
- package/dist/src/server/routes/og-image.d.ts +15 -0
- package/dist/src/server/routes/preview-bundle.d.ts +1 -0
- package/dist/src/server/routes/preview-config.d.ts +1 -0
- package/dist/src/server/routes/tokens.d.ts +1 -0
- package/dist/{vite → src/server}/start.d.ts +5 -2
- package/dist/{ui → src/ui}/button.d.ts +1 -1
- package/dist/{validators → src/validators}/index.d.ts +0 -5
- package/dist/{validators → src/validators}/semantic-validator.d.ts +2 -3
- package/package.json +8 -11
- package/src/jsx/CLAUDE.md +18 -0
- package/src/jsx/jsx-runtime.ts +1 -1
- package/src/preview-runtime/CLAUDE.md +21 -0
- package/src/preview-runtime/build-optimized.ts +189 -73
- package/src/preview-runtime/build.ts +75 -79
- package/src/preview-runtime/fast-template.html +5 -1
- package/src/preview-runtime/region-bridge.test.ts +41 -0
- package/src/preview-runtime/region-bridge.ts +101 -0
- package/src/preview-runtime/types.ts +6 -0
- package/src/preview-runtime/vendors.ts +215 -22
- package/src/primitives/CLAUDE.md +17 -0
- package/src/theme/CLAUDE.md +20 -0
- package/src/theme/Preview.tsx +10 -4
- package/src/theme/Toolbar.tsx +2 -2
- package/src/theme/entry.tsx +247 -121
- package/src/theme/hooks/useAnnotations.ts +77 -0
- package/src/theme/hooks/useApprovalStatus.ts +50 -0
- package/src/theme/hooks/useSnapshots.ts +147 -0
- package/src/theme/hooks/useStorage.ts +26 -0
- package/src/theme/hooks/useTokenOverrides.ts +56 -0
- package/src/theme/hooks/useViewport.ts +23 -0
- package/src/theme/icons.tsx +39 -1
- package/src/theme/index.html +18 -0
- package/src/theme/mdx-components.tsx +1 -1
- package/src/theme/previews/AnnotationLayer.tsx +285 -0
- package/src/theme/previews/AnnotationPin.tsx +61 -0
- package/src/theme/previews/AnnotationThread.tsx +257 -0
- package/src/theme/previews/CLAUDE.md +18 -0
- package/src/theme/previews/ComponentPreview.tsx +487 -107
- package/src/theme/previews/FlowDiagram.tsx +111 -0
- package/src/theme/previews/FlowPreview.tsx +938 -174
- package/src/theme/previews/PreviewRouter.tsx +1 -4
- package/src/theme/previews/ScreenPreview.tsx +515 -175
- package/src/theme/previews/SnapshotButton.tsx +68 -0
- package/src/theme/previews/SnapshotCompare.tsx +216 -0
- package/src/theme/previews/SnapshotPanel.tsx +274 -0
- package/src/theme/previews/StatusBadge.tsx +66 -0
- package/src/theme/previews/StatusDropdown.tsx +158 -0
- package/src/theme/previews/TokenPlayground.tsx +438 -0
- package/src/theme/previews/ViewportControls.tsx +67 -0
- package/src/theme/previews/flow-diagram.test.ts +141 -0
- package/src/theme/previews/flow-diagram.ts +109 -0
- package/src/theme/previews/flow-navigation.test.ts +90 -0
- package/src/theme/previews/flow-navigation.ts +47 -0
- package/src/theme/previews/machines/derived.test.ts +225 -0
- package/src/theme/previews/machines/derived.ts +73 -0
- package/src/theme/previews/machines/flow-machine.test.ts +379 -0
- package/src/theme/previews/machines/flow-machine.ts +207 -0
- package/src/theme/previews/machines/screen-machine.test.ts +149 -0
- package/src/theme/previews/machines/screen-machine.ts +76 -0
- package/src/theme/previews/stores/flow-store.test.ts +157 -0
- package/src/theme/previews/stores/flow-store.ts +49 -0
- package/src/theme/previews/stores/screen-store.test.ts +68 -0
- package/src/theme/previews/stores/screen-store.ts +33 -0
- package/src/theme/storage.test.ts +97 -0
- package/src/theme/storage.ts +71 -0
- package/src/theme/styles.css +296 -25
- package/src/theme/types.ts +64 -0
- package/src/tokens/CLAUDE.md +16 -0
- package/src/tokens/resolver.ts +1 -1
- package/dist/preview-runtime/vendors.d.ts +0 -6
- package/dist/vite/config-parser.d.ts +0 -13
- package/dist/vite/config.d.ts +0 -12
- package/dist/vite/plugins/config-plugin.d.ts +0 -3
- package/dist/vite/plugins/debug-plugin.d.ts +0 -3
- package/dist/vite/plugins/entry-plugin.d.ts +0 -2
- package/dist/vite/plugins/fumadocs-plugin.d.ts +0 -9
- package/dist/vite/plugins/pages-plugin.d.ts +0 -5
- package/dist/vite/plugins/previews-plugin.d.ts +0 -2
- package/dist/vite/plugins/tokens-plugin.d.ts +0 -2
- package/dist/vite/preview-types.d.ts +0 -70
- package/src/theme/previews/AtlasPreview.tsx +0 -528
- package/dist/{cli.d.ts → src/cli.d.ts} +0 -0
- package/dist/{config → src/config}/index.d.ts +0 -0
- package/dist/{config → src/config}/loader.d.ts +0 -0
- package/dist/{config → src/config}/schema.d.ts +0 -0
- package/dist/{vite → src/content}/pages.d.ts +0 -0
- package/dist/{jsx → src/jsx}/adapters/html.d.ts +0 -0
- package/dist/{jsx → src/jsx}/adapters/react.d.ts +0 -0
- package/dist/{jsx → src/jsx}/define-component.d.ts +0 -0
- package/dist/{jsx → src/jsx}/index.d.ts +0 -0
- package/dist/{jsx → src/jsx}/jsx-runtime.d.ts +0 -0
- package/dist/{jsx → src/jsx}/migrate.d.ts +0 -0
- package/dist/{jsx → src/jsx}/schemas/index.d.ts +0 -0
- package/dist/{jsx → src/jsx}/schemas/primitives.d.ts +10 -10
- package/dist/{jsx → src/jsx}/schemas/tokens.d.ts +3 -3
- /package/dist/{jsx → src/jsx}/validation.d.ts +0 -0
- /package/dist/{jsx → src/jsx}/vnode.d.ts +0 -0
- /package/dist/{migrate.d.ts → src/migrate.d.ts} +0 -0
- /package/dist/{preview-runtime → src/preview-runtime}/tailwind.d.ts +0 -0
- /package/dist/{primitives → src/primitives}/index.d.ts +0 -0
- /package/dist/{primitives → src/primitives}/migrate.d.ts +0 -0
- /package/dist/{primitives → src/primitives}/parser.d.ts +0 -0
- /package/dist/{primitives → src/primitives}/template-parser.d.ts +0 -0
- /package/dist/{primitives → src/primitives}/template-renderer.d.ts +0 -0
- /package/dist/{primitives → src/primitives}/types.d.ts +0 -0
- /package/dist/{renderers → src/renderers}/html/index.d.ts +0 -0
- /package/dist/{renderers → src/renderers}/react/index.d.ts +0 -0
- /package/dist/{renderers → src/renderers}/registry.d.ts +0 -0
- /package/dist/{renderers → src/renderers}/render.d.ts +0 -0
- /package/dist/{tokens → src/tokens}/defaults.d.ts +0 -0
- /package/dist/{tokens → src/tokens}/resolver.d.ts +0 -0
- /package/dist/{tokens → src/tokens}/utils.d.ts +0 -0
- /package/dist/{tokens → src/tokens}/validation.d.ts +0 -0
- /package/dist/{typecheck → src/typecheck}/index.d.ts +0 -0
- /package/dist/{ui → src/ui}/card.d.ts +0 -0
- /package/dist/{ui → src/ui}/index.d.ts +0 -0
- /package/dist/{ui → src/ui}/utils.d.ts +0 -0
- /package/dist/{utils → src/utils}/cache.d.ts +0 -0
- /package/dist/{utils → src/utils}/debug.d.ts +0 -0
- /package/dist/{utils → src/utils}/port.d.ts +0 -0
- /package/dist/{validators → src/validators}/schema-validator.d.ts +0 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
flowReducer,
|
|
4
|
+
createFlowInitialState,
|
|
5
|
+
type FlowMachineState,
|
|
6
|
+
type FlowAction,
|
|
7
|
+
} from './flow-machine'
|
|
8
|
+
import type { FlowStep } from '../../../content/preview-types'
|
|
9
|
+
|
|
10
|
+
// --- Test fixtures ---
|
|
11
|
+
|
|
12
|
+
const steps: FlowStep[] = [
|
|
13
|
+
{
|
|
14
|
+
id: 'step1',
|
|
15
|
+
title: 'Sign Up',
|
|
16
|
+
screen: 'signup',
|
|
17
|
+
regions: {
|
|
18
|
+
submit: { goto: 'step2' },
|
|
19
|
+
'login-link': { goto: 'step3' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'step2',
|
|
24
|
+
title: 'Dashboard',
|
|
25
|
+
screen: 'dashboard',
|
|
26
|
+
regions: {
|
|
27
|
+
'upgrade-btn': {
|
|
28
|
+
outcomes: {
|
|
29
|
+
success: { goto: 'step3', label: 'Paid' },
|
|
30
|
+
failure: { goto: 'step1' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'step3',
|
|
37
|
+
title: 'Settings',
|
|
38
|
+
screen: 'settings',
|
|
39
|
+
terminal: true,
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const linearSteps: FlowStep[] = [
|
|
44
|
+
{ id: 'a', title: 'First', screen: 'page-a' },
|
|
45
|
+
{ id: 'b', title: 'Second', screen: 'page-b' },
|
|
46
|
+
{ id: 'c', title: 'Third', screen: 'page-c', terminal: true },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
function loadedState(stepsData: FlowStep[] = steps): FlowMachineState {
|
|
50
|
+
const initial = createFlowInitialState()
|
|
51
|
+
return flowReducer(initial, {
|
|
52
|
+
type: 'loaded',
|
|
53
|
+
steps: stepsData,
|
|
54
|
+
name: 'Test Flow',
|
|
55
|
+
description: 'A test flow',
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Tests ---
|
|
60
|
+
|
|
61
|
+
describe('createFlowInitialState', () => {
|
|
62
|
+
test('returns loading state with no steps', () => {
|
|
63
|
+
const state = createFlowInitialState()
|
|
64
|
+
expect(state.status).toBe('loading')
|
|
65
|
+
expect(state.steps).toEqual([])
|
|
66
|
+
expect(state.currentStepId).toBeNull()
|
|
67
|
+
expect(state.history).toEqual([])
|
|
68
|
+
expect(state.outcomePicker).toBeNull()
|
|
69
|
+
expect(state.showOverlay).toBe(true)
|
|
70
|
+
expect(state.isFullscreen).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('flowReducer — loaded', () => {
|
|
75
|
+
test('sets steps, name, status=ready, navigates to first step', () => {
|
|
76
|
+
const state = loadedState()
|
|
77
|
+
expect(state.status).toBe('ready')
|
|
78
|
+
expect(state.name).toBe('Test Flow')
|
|
79
|
+
expect(state.description).toBe('A test flow')
|
|
80
|
+
expect(state.steps).toEqual(steps)
|
|
81
|
+
expect(state.currentStepId).toBe('step1')
|
|
82
|
+
expect(state.history).toEqual(['step1'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('handles empty steps array', () => {
|
|
86
|
+
const initial = createFlowInitialState()
|
|
87
|
+
const state = flowReducer(initial, { type: 'loaded', steps: [], name: 'Empty' })
|
|
88
|
+
expect(state.status).toBe('ready')
|
|
89
|
+
expect(state.currentStepId).toBeNull()
|
|
90
|
+
expect(state.history).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('flowReducer — load_error', () => {
|
|
95
|
+
test('sets status=error with message', () => {
|
|
96
|
+
const initial = createFlowInitialState()
|
|
97
|
+
const state = flowReducer(initial, { type: 'load_error', error: 'Network failure' })
|
|
98
|
+
expect(state.status).toBe('error')
|
|
99
|
+
expect(state.error).toBe('Network failure')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('sets status=error without message', () => {
|
|
103
|
+
const initial = createFlowInitialState()
|
|
104
|
+
const state = flowReducer(initial, { type: 'load_error' })
|
|
105
|
+
expect(state.status).toBe('error')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('flowReducer — goto', () => {
|
|
110
|
+
test('navigates to target step and appends to history', () => {
|
|
111
|
+
const state = loadedState()
|
|
112
|
+
const next = flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
113
|
+
expect(next.currentStepId).toBe('step2')
|
|
114
|
+
expect(next.history).toEqual(['step1', 'step2'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('clears outcome picker on navigation', () => {
|
|
118
|
+
let state = loadedState()
|
|
119
|
+
// Simulate having an outcome picker open
|
|
120
|
+
state = { ...state, outcomePicker: { outcomes: { a: { goto: 'step2' } } } }
|
|
121
|
+
const next = flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
122
|
+
expect(next.outcomePicker).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('ignores goto to non-existent step', () => {
|
|
126
|
+
const state = loadedState()
|
|
127
|
+
const next = flowReducer(state, { type: 'goto', stepId: 'nonexistent' })
|
|
128
|
+
expect(next.currentStepId).toBe('step1')
|
|
129
|
+
expect(next.history).toEqual(['step1'])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('flowReducer — back', () => {
|
|
134
|
+
test('pops history and navigates to previous step', () => {
|
|
135
|
+
let state = loadedState()
|
|
136
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
137
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step3' })
|
|
138
|
+
expect(state.history).toEqual(['step1', 'step2', 'step3'])
|
|
139
|
+
|
|
140
|
+
const prev = flowReducer(state, { type: 'back' })
|
|
141
|
+
expect(prev.currentStepId).toBe('step2')
|
|
142
|
+
expect(prev.history).toEqual(['step1', 'step2'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('does nothing when at first step', () => {
|
|
146
|
+
const state = loadedState()
|
|
147
|
+
const same = flowReducer(state, { type: 'back' })
|
|
148
|
+
expect(same.currentStepId).toBe('step1')
|
|
149
|
+
expect(same.history).toEqual(['step1'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('clears outcome picker on back', () => {
|
|
153
|
+
let state = loadedState()
|
|
154
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
155
|
+
state = { ...state, outcomePicker: { outcomes: { a: { goto: 'step3' } } } }
|
|
156
|
+
const prev = flowReducer(state, { type: 'back' })
|
|
157
|
+
expect(prev.outcomePicker).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('flowReducer — linear_next', () => {
|
|
162
|
+
test('moves to next step in array order for non-region steps', () => {
|
|
163
|
+
const state = loadedState(linearSteps)
|
|
164
|
+
const next = flowReducer(state, { type: 'linear_next' })
|
|
165
|
+
expect(next.currentStepId).toBe('b')
|
|
166
|
+
expect(next.history).toEqual(['a', 'b'])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('does nothing at last step', () => {
|
|
170
|
+
let state = loadedState(linearSteps)
|
|
171
|
+
state = flowReducer(state, { type: 'goto', stepId: 'c' })
|
|
172
|
+
const same = flowReducer(state, { type: 'linear_next' })
|
|
173
|
+
expect(same.currentStepId).toBe('c')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('does nothing for steps with regions (regions required)', () => {
|
|
177
|
+
const state = loadedState(steps) // step1 has regions
|
|
178
|
+
const same = flowReducer(state, { type: 'linear_next' })
|
|
179
|
+
expect(same.currentStepId).toBe('step1')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('does nothing for terminal steps', () => {
|
|
183
|
+
let state = loadedState(linearSteps)
|
|
184
|
+
state = flowReducer(state, { type: 'goto', stepId: 'c' }) // c is terminal
|
|
185
|
+
const same = flowReducer(state, { type: 'linear_next' })
|
|
186
|
+
expect(same.currentStepId).toBe('c')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('flowReducer — linear_prev', () => {
|
|
191
|
+
test('moves back to previous step via history', () => {
|
|
192
|
+
let state = loadedState(linearSteps)
|
|
193
|
+
state = flowReducer(state, { type: 'linear_next' }) // a -> b
|
|
194
|
+
const prev = flowReducer(state, { type: 'linear_prev' })
|
|
195
|
+
expect(prev.currentStepId).toBe('a')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('does nothing at first step', () => {
|
|
199
|
+
const state = loadedState(linearSteps)
|
|
200
|
+
const same = flowReducer(state, { type: 'linear_prev' })
|
|
201
|
+
expect(same.currentStepId).toBe('a')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('flowReducer — region_click', () => {
|
|
206
|
+
test('simple goto region navigates to target', () => {
|
|
207
|
+
const state = loadedState()
|
|
208
|
+
const next = flowReducer(state, { type: 'region_click', region: 'submit' })
|
|
209
|
+
expect(next.currentStepId).toBe('step2')
|
|
210
|
+
expect(next.history).toEqual(['step1', 'step2'])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('outcomes region opens outcome picker', () => {
|
|
214
|
+
let state = loadedState()
|
|
215
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step2' }) // step2 has outcomes
|
|
216
|
+
const next = flowReducer(state, { type: 'region_click', region: 'upgrade-btn' })
|
|
217
|
+
expect(next.outcomePicker).toEqual({
|
|
218
|
+
outcomes: {
|
|
219
|
+
success: { goto: 'step3', label: 'Paid' },
|
|
220
|
+
failure: { goto: 'step1' },
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
expect(next.currentStepId).toBe('step2') // doesn't navigate yet
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('unknown region does nothing', () => {
|
|
227
|
+
const state = loadedState()
|
|
228
|
+
const same = flowReducer(state, { type: 'region_click', region: 'nonexistent' })
|
|
229
|
+
expect(same.currentStepId).toBe('step1')
|
|
230
|
+
expect(same.outcomePicker).toBeNull()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('region click on step with no regions does nothing', () => {
|
|
234
|
+
let state = loadedState()
|
|
235
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step3' }) // terminal, no regions
|
|
236
|
+
const same = flowReducer(state, { type: 'region_click', region: 'anything' })
|
|
237
|
+
expect(same.currentStepId).toBe('step3')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('flowReducer — pick_outcome', () => {
|
|
242
|
+
test('navigates to chosen outcome and clears picker', () => {
|
|
243
|
+
let state = loadedState()
|
|
244
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
245
|
+
state = flowReducer(state, { type: 'region_click', region: 'upgrade-btn' })
|
|
246
|
+
expect(state.outcomePicker).not.toBeNull()
|
|
247
|
+
|
|
248
|
+
const next = flowReducer(state, { type: 'pick_outcome', stepId: 'step3' })
|
|
249
|
+
expect(next.currentStepId).toBe('step3')
|
|
250
|
+
expect(next.outcomePicker).toBeNull()
|
|
251
|
+
expect(next.history).toEqual(['step1', 'step2', 'step3'])
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('flowReducer — cancel_picker', () => {
|
|
256
|
+
test('clears outcome picker without navigating', () => {
|
|
257
|
+
let state = loadedState()
|
|
258
|
+
state = flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
259
|
+
state = flowReducer(state, { type: 'region_click', region: 'upgrade-btn' })
|
|
260
|
+
expect(state.outcomePicker).not.toBeNull()
|
|
261
|
+
|
|
262
|
+
const next = flowReducer(state, { type: 'cancel_picker' })
|
|
263
|
+
expect(next.outcomePicker).toBeNull()
|
|
264
|
+
expect(next.currentStepId).toBe('step2') // unchanged
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('flowReducer — toggle_overlay', () => {
|
|
269
|
+
test('toggles showOverlay', () => {
|
|
270
|
+
const state = loadedState()
|
|
271
|
+
expect(state.showOverlay).toBe(true)
|
|
272
|
+
|
|
273
|
+
const toggled = flowReducer(state, { type: 'toggle_overlay' })
|
|
274
|
+
expect(toggled.showOverlay).toBe(false)
|
|
275
|
+
|
|
276
|
+
const back = flowReducer(toggled, { type: 'toggle_overlay' })
|
|
277
|
+
expect(back.showOverlay).toBe(true)
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe('flowReducer — toggle_fullscreen', () => {
|
|
282
|
+
test('toggles isFullscreen', () => {
|
|
283
|
+
const state = loadedState()
|
|
284
|
+
expect(state.isFullscreen).toBe(false)
|
|
285
|
+
|
|
286
|
+
const toggled = flowReducer(state, { type: 'toggle_fullscreen' })
|
|
287
|
+
expect(toggled.isFullscreen).toBe(true)
|
|
288
|
+
|
|
289
|
+
const back = flowReducer(toggled, { type: 'toggle_fullscreen' })
|
|
290
|
+
expect(back.isFullscreen).toBe(false)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// --- Codex review fixes ---
|
|
295
|
+
|
|
296
|
+
describe('flowReducer — loaded normalizes step IDs', () => {
|
|
297
|
+
test('auto-generates IDs for steps without explicit id', () => {
|
|
298
|
+
const noIdSteps: FlowStep[] = [
|
|
299
|
+
{ screen: 'page-a' },
|
|
300
|
+
{ screen: 'page-b' },
|
|
301
|
+
{ id: 'explicit', screen: 'page-c' },
|
|
302
|
+
]
|
|
303
|
+
const initial = createFlowInitialState()
|
|
304
|
+
const state = flowReducer(initial, { type: 'loaded', steps: noIdSteps, name: 'Auto ID' })
|
|
305
|
+
expect(state.status).toBe('ready')
|
|
306
|
+
// First step should have a generated id
|
|
307
|
+
expect(state.currentStepId).toBeTruthy()
|
|
308
|
+
expect(state.steps[0].id).toBeTruthy()
|
|
309
|
+
expect(state.steps[1].id).toBeTruthy()
|
|
310
|
+
// Explicit id preserved
|
|
311
|
+
expect(state.steps[2].id).toBe('explicit')
|
|
312
|
+
// All IDs are unique
|
|
313
|
+
const ids = state.steps.map(s => s.id)
|
|
314
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('linear_next works with auto-generated step IDs', () => {
|
|
318
|
+
const noIdSteps: FlowStep[] = [
|
|
319
|
+
{ screen: 'page-a' },
|
|
320
|
+
{ screen: 'page-b' },
|
|
321
|
+
]
|
|
322
|
+
const initial = createFlowInitialState()
|
|
323
|
+
const state = flowReducer(initial, { type: 'loaded', steps: noIdSteps, name: 'Test' })
|
|
324
|
+
const next = flowReducer(state, { type: 'linear_next' })
|
|
325
|
+
expect(next.currentStepId).toBe(state.steps[1].id)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('flowReducer — loaded clears stale error', () => {
|
|
330
|
+
test('clears error after load_error -> loaded recovery', () => {
|
|
331
|
+
let state = createFlowInitialState()
|
|
332
|
+
state = flowReducer(state, { type: 'load_error', error: 'Network failure' })
|
|
333
|
+
expect(state.status).toBe('error')
|
|
334
|
+
expect(state.error).toBe('Network failure')
|
|
335
|
+
|
|
336
|
+
state = flowReducer(state, { type: 'loaded', steps: linearSteps, name: 'Recovered' })
|
|
337
|
+
expect(state.status).toBe('ready')
|
|
338
|
+
expect(state.error).toBeUndefined()
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe('flowReducer — loaded handles ID collisions', () => {
|
|
343
|
+
test('avoids collision when explicit ID matches step-${i} pattern', () => {
|
|
344
|
+
const collidingSteps: FlowStep[] = [
|
|
345
|
+
{ id: 'step-1', screen: 'page-a' }, // explicit id matches generated pattern
|
|
346
|
+
{ screen: 'page-b' }, // would naively get step-1
|
|
347
|
+
{ screen: 'page-c' }, // would naively get step-2
|
|
348
|
+
]
|
|
349
|
+
const initial = createFlowInitialState()
|
|
350
|
+
const state = flowReducer(initial, { type: 'loaded', steps: collidingSteps, name: 'Collision' })
|
|
351
|
+
const ids = state.steps.map(s => s.id)
|
|
352
|
+
// All IDs must be unique
|
|
353
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
354
|
+
// Explicit ID preserved
|
|
355
|
+
expect(state.steps[0].id).toBe('step-1')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('handles duplicate explicit IDs by making them unique', () => {
|
|
359
|
+
const dupSteps: FlowStep[] = [
|
|
360
|
+
{ id: 'dup', screen: 'page-a' },
|
|
361
|
+
{ id: 'dup', screen: 'page-b' },
|
|
362
|
+
{ screen: 'page-c' },
|
|
363
|
+
]
|
|
364
|
+
const initial = createFlowInitialState()
|
|
365
|
+
const state = flowReducer(initial, { type: 'loaded', steps: dupSteps, name: 'Dup' })
|
|
366
|
+
const ids = state.steps.map(s => s.id)
|
|
367
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('flowReducer — immutability', () => {
|
|
372
|
+
test('does not mutate previous state', () => {
|
|
373
|
+
const state = loadedState()
|
|
374
|
+
const historyCopy = [...state.history]
|
|
375
|
+
flowReducer(state, { type: 'goto', stepId: 'step2' })
|
|
376
|
+
expect(state.history).toEqual(historyCopy)
|
|
377
|
+
expect(state.currentStepId).toBe('step1')
|
|
378
|
+
})
|
|
379
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Pure flow state machine — zero React imports
|
|
2
|
+
|
|
3
|
+
import type { FlowStep } from '../../../content/preview-types'
|
|
4
|
+
|
|
5
|
+
// --- State ---
|
|
6
|
+
|
|
7
|
+
export interface FlowMachineState {
|
|
8
|
+
status: 'loading' | 'ready' | 'error'
|
|
9
|
+
name: string
|
|
10
|
+
description?: string
|
|
11
|
+
steps: FlowStep[]
|
|
12
|
+
currentStepId: string | null
|
|
13
|
+
history: string[]
|
|
14
|
+
outcomePicker: { outcomes: Record<string, { goto: string; label?: string }> } | null
|
|
15
|
+
showOverlay: boolean
|
|
16
|
+
isFullscreen: boolean
|
|
17
|
+
error?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Actions ---
|
|
21
|
+
|
|
22
|
+
export type FlowAction =
|
|
23
|
+
| { type: 'loaded'; steps: FlowStep[]; name: string; description?: string }
|
|
24
|
+
| { type: 'load_error'; error?: string }
|
|
25
|
+
| { type: 'goto'; stepId: string }
|
|
26
|
+
| { type: 'back' }
|
|
27
|
+
| { type: 'linear_next' }
|
|
28
|
+
| { type: 'linear_prev' }
|
|
29
|
+
| { type: 'region_click'; region: string }
|
|
30
|
+
| { type: 'pick_outcome'; stepId: string }
|
|
31
|
+
| { type: 'cancel_picker' }
|
|
32
|
+
| { type: 'toggle_overlay' }
|
|
33
|
+
| { type: 'toggle_fullscreen' }
|
|
34
|
+
|
|
35
|
+
// --- Initial state ---
|
|
36
|
+
|
|
37
|
+
export function createFlowInitialState(): FlowMachineState {
|
|
38
|
+
return {
|
|
39
|
+
status: 'loading',
|
|
40
|
+
name: '',
|
|
41
|
+
steps: [],
|
|
42
|
+
currentStepId: null,
|
|
43
|
+
history: [],
|
|
44
|
+
outcomePicker: null,
|
|
45
|
+
showOverlay: true,
|
|
46
|
+
isFullscreen: false,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Helpers ---
|
|
51
|
+
|
|
52
|
+
function findStep(steps: FlowStep[], id: string): FlowStep | undefined {
|
|
53
|
+
return steps.find(s => s.id === id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stepIndex(steps: FlowStep[], id: string): number {
|
|
57
|
+
return steps.findIndex(s => s.id === id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function canLinearNext(step: FlowStep): boolean {
|
|
61
|
+
if (step.terminal) return false
|
|
62
|
+
if (step.regions && Object.keys(step.regions).length > 0) return false
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- ID normalization ---
|
|
67
|
+
|
|
68
|
+
function normalizeStepIds(steps: FlowStep[]): FlowStep[] {
|
|
69
|
+
const usedIds = new Set<string>()
|
|
70
|
+
// First pass: collect all explicit IDs
|
|
71
|
+
for (const step of steps) {
|
|
72
|
+
if (step.id) usedIds.add(step.id)
|
|
73
|
+
}
|
|
74
|
+
// Second pass: assign unique IDs to steps without one, or with duplicate IDs
|
|
75
|
+
let counter = 0
|
|
76
|
+
const seen = new Set<string>()
|
|
77
|
+
return steps.map((step) => {
|
|
78
|
+
if (step.id && !seen.has(step.id)) {
|
|
79
|
+
seen.add(step.id)
|
|
80
|
+
return step
|
|
81
|
+
}
|
|
82
|
+
// Generate a unique ID that doesn't collide
|
|
83
|
+
while (usedIds.has(`step-${counter}`)) counter++
|
|
84
|
+
const id = `step-${counter}`
|
|
85
|
+
usedIds.add(id)
|
|
86
|
+
seen.add(id)
|
|
87
|
+
counter++
|
|
88
|
+
return { ...step, id }
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Reducer ---
|
|
93
|
+
|
|
94
|
+
export function flowReducer(state: FlowMachineState, action: FlowAction): FlowMachineState {
|
|
95
|
+
switch (action.type) {
|
|
96
|
+
case 'loaded': {
|
|
97
|
+
const normalized = normalizeStepIds(action.steps)
|
|
98
|
+
const firstId = normalized.length > 0 ? normalized[0].id! : null
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
status: 'ready',
|
|
102
|
+
name: action.name,
|
|
103
|
+
description: action.description,
|
|
104
|
+
steps: normalized,
|
|
105
|
+
currentStepId: firstId,
|
|
106
|
+
history: firstId ? [firstId] : [],
|
|
107
|
+
error: undefined,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case 'load_error':
|
|
112
|
+
return { ...state, status: 'error', error: action.error }
|
|
113
|
+
|
|
114
|
+
case 'goto': {
|
|
115
|
+
const target = findStep(state.steps, action.stepId)
|
|
116
|
+
if (!target) return state
|
|
117
|
+
return {
|
|
118
|
+
...state,
|
|
119
|
+
currentStepId: action.stepId,
|
|
120
|
+
history: [...state.history, action.stepId],
|
|
121
|
+
outcomePicker: null,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case 'back': {
|
|
126
|
+
if (state.history.length <= 1) return state
|
|
127
|
+
const newHistory = state.history.slice(0, -1)
|
|
128
|
+
return {
|
|
129
|
+
...state,
|
|
130
|
+
currentStepId: newHistory[newHistory.length - 1],
|
|
131
|
+
history: newHistory,
|
|
132
|
+
outcomePicker: null,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case 'linear_next': {
|
|
137
|
+
if (!state.currentStepId) return state
|
|
138
|
+
const current = findStep(state.steps, state.currentStepId)
|
|
139
|
+
if (!current || !canLinearNext(current)) return state
|
|
140
|
+
const idx = stepIndex(state.steps, state.currentStepId)
|
|
141
|
+
if (idx >= state.steps.length - 1) return state
|
|
142
|
+
const nextStep = state.steps[idx + 1]
|
|
143
|
+
const nextId = nextStep.id!
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
currentStepId: nextId,
|
|
147
|
+
history: [...state.history, nextId],
|
|
148
|
+
outcomePicker: null,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'linear_prev':
|
|
153
|
+
return flowReducer(state, { type: 'back' })
|
|
154
|
+
|
|
155
|
+
case 'region_click': {
|
|
156
|
+
if (!state.currentStepId) return state
|
|
157
|
+
const step = findStep(state.steps, state.currentStepId)
|
|
158
|
+
if (!step?.regions) return state
|
|
159
|
+
|
|
160
|
+
const region = step.regions[action.region]
|
|
161
|
+
if (!region) return state
|
|
162
|
+
|
|
163
|
+
if ('goto' in region) {
|
|
164
|
+
const target = findStep(state.steps, region.goto)
|
|
165
|
+
if (!target) return state
|
|
166
|
+
return {
|
|
167
|
+
...state,
|
|
168
|
+
currentStepId: region.goto,
|
|
169
|
+
history: [...state.history, region.goto],
|
|
170
|
+
outcomePicker: null,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if ('outcomes' in region) {
|
|
175
|
+
return {
|
|
176
|
+
...state,
|
|
177
|
+
outcomePicker: { outcomes: region.outcomes },
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return state
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case 'pick_outcome': {
|
|
185
|
+
const target = findStep(state.steps, action.stepId)
|
|
186
|
+
if (!target) return state
|
|
187
|
+
return {
|
|
188
|
+
...state,
|
|
189
|
+
currentStepId: action.stepId,
|
|
190
|
+
history: [...state.history, action.stepId],
|
|
191
|
+
outcomePicker: null,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'cancel_picker':
|
|
196
|
+
return { ...state, outcomePicker: null }
|
|
197
|
+
|
|
198
|
+
case 'toggle_overlay':
|
|
199
|
+
return { ...state, showOverlay: !state.showOverlay }
|
|
200
|
+
|
|
201
|
+
case 'toggle_fullscreen':
|
|
202
|
+
return { ...state, isFullscreen: !state.isFullscreen }
|
|
203
|
+
|
|
204
|
+
default:
|
|
205
|
+
return state
|
|
206
|
+
}
|
|
207
|
+
}
|