@devbycrux/editor 0.1.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/README.md +165 -0
- package/package.json +46 -0
- package/src/__tests__/adapter-contract.test.ts +123 -0
- package/src/__tests__/adapter.test.ts +185 -0
- package/src/__tests__/schema.test.ts +104 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +529 -0
- package/src/carousel/CarouselRenderModal.tsx +243 -0
- package/src/carousel/OverlayErrorBoundary.tsx +99 -0
- package/src/carousel/OverlayPicker.tsx +145 -0
- package/src/carousel/SlideCanvas.tsx +588 -0
- package/src/carousel/SlidePropertyPanel.tsx +349 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
- package/src/crop/CanvasCropOverlay.tsx +193 -0
- package/src/crop/__tests__/crop-math.test.ts +174 -0
- package/src/crop/crop-math.ts +125 -0
- package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
- package/src/gestures/helpers/drag.ts +24 -0
- package/src/gestures/helpers/element-transform.ts +15 -0
- package/src/gestures/helpers/resize.ts +60 -0
- package/src/gestures/helpers/rotate.ts +44 -0
- package/src/gestures/helpers/snap.ts +64 -0
- package/src/gestures/hooks/useOverlayDrag.ts +106 -0
- package/src/gestures/hooks/useOverlayResize.ts +67 -0
- package/src/gestures/hooks/useOverlayRotate.ts +64 -0
- package/src/gestures/index.ts +16 -0
- package/src/index.ts +112 -0
- package/src/overlays/contract.ts +41 -0
- package/src/preview/OverlayPreview.tsx +196 -0
- package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
- package/src/schema.ts +194 -0
- package/src/state/__tests__/project-reducer.test.ts +957 -0
- package/src/state/__tests__/use-project-state.test.tsx +258 -0
- package/src/state/mutation-queue.ts +62 -0
- package/src/state/project-reducer.ts +328 -0
- package/src/state/use-project-state.ts +442 -0
- package/src/test-setup.ts +1 -0
- package/src/text/FontPicker.tsx +218 -0
- package/src/text/InlineTextEditor.tsx +92 -0
- package/src/text/TextFormattingToolbar.tsx +248 -0
- package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
- package/src/theme.ts +93 -0
- package/src/types.ts +325 -0
- package/src/ui/__tests__/button.test.tsx +17 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +32 -0
- package/src/ui/index.ts +16 -0
- package/src/ui/input.tsx +15 -0
- package/src/ui/label.tsx +10 -0
- package/src/ui/select.tsx +23 -0
- package/src/ui/switch.tsx +31 -0
- package/src/ui/textarea.tsx +15 -0
- package/src/ui/utils.ts +7 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { projectReducer } from '../project-reducer'
|
|
3
|
+
import { createMutationQueue } from '../mutation-queue'
|
|
4
|
+
import type { Project, Slide, CarouselElement, OverlayElement, ImageElement } from '../../types'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixture helpers (shared with slide-CRUD section below)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function makeSlide(id: string, overrides: Partial<Slide> = {}): Slide {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
base_color: '#ffffff',
|
|
14
|
+
elements: [] as CarouselElement[],
|
|
15
|
+
...overrides,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeImageEl(id: string): ImageElement {
|
|
20
|
+
return { id, type: 'image', src: 'img.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Fixture helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
//
|
|
28
|
+
// Ported from mission-control's project-reducer.test.ts. The fixture is
|
|
29
|
+
// adapted to Montaj's canonical `Project` shape (schema.ts) — which requires
|
|
30
|
+
// `version` and `assets` in addition to MC's subset. The carousel slide /
|
|
31
|
+
// element shapes are identical, so the body of every assertion is unchanged.
|
|
32
|
+
|
|
33
|
+
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
34
|
+
const slides: Slide[] = [
|
|
35
|
+
{
|
|
36
|
+
id: 'slide-0',
|
|
37
|
+
base_color: '#ffffff',
|
|
38
|
+
elements: [
|
|
39
|
+
{
|
|
40
|
+
id: 'el-0-0',
|
|
41
|
+
type: 'image',
|
|
42
|
+
src: 'https://example.com/img0.png',
|
|
43
|
+
x: 0,
|
|
44
|
+
y: 0,
|
|
45
|
+
w: 100,
|
|
46
|
+
h: 100,
|
|
47
|
+
rotation: 0,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'el-0-1',
|
|
51
|
+
type: 'overlay',
|
|
52
|
+
overlay: { template: 'tpl-a', props: { text: 'hello', color: 'red' } },
|
|
53
|
+
frame: 0,
|
|
54
|
+
x: 10,
|
|
55
|
+
y: 10,
|
|
56
|
+
w: 80,
|
|
57
|
+
h: 40,
|
|
58
|
+
rotation: 0,
|
|
59
|
+
},
|
|
60
|
+
] as CarouselElement[],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'slide-1',
|
|
64
|
+
base_color: '#000000',
|
|
65
|
+
elements: [
|
|
66
|
+
{
|
|
67
|
+
id: 'el-1-0',
|
|
68
|
+
type: 'overlay',
|
|
69
|
+
overlay: { template: 'tpl-b', props: { text: 'world', size: '24px' } },
|
|
70
|
+
frame: 1,
|
|
71
|
+
x: 0,
|
|
72
|
+
y: 0,
|
|
73
|
+
w: 200,
|
|
74
|
+
h: 50,
|
|
75
|
+
rotation: 45,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'el-1-1',
|
|
79
|
+
type: 'image',
|
|
80
|
+
src: 'https://example.com/img1.png',
|
|
81
|
+
x: 5,
|
|
82
|
+
y: 5,
|
|
83
|
+
w: 50,
|
|
84
|
+
h: 50,
|
|
85
|
+
rotation: 10,
|
|
86
|
+
},
|
|
87
|
+
] as CarouselElement[],
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
version: '1',
|
|
93
|
+
id: 'proj-1',
|
|
94
|
+
name: 'Test Project',
|
|
95
|
+
workflow: 'carousel',
|
|
96
|
+
status: 'pending',
|
|
97
|
+
editingPrompt: 'make it pop',
|
|
98
|
+
settings: { resolution: [1080, 1080] },
|
|
99
|
+
assets: [],
|
|
100
|
+
slides,
|
|
101
|
+
...overrides,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Tests
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe('projectReducer', () => {
|
|
110
|
+
it('1. SSE applies the new project data', () => {
|
|
111
|
+
const prev = makeProject()
|
|
112
|
+
const next = makeProject({ id: 'proj-2', name: 'Replaced', status: 'draft' })
|
|
113
|
+
|
|
114
|
+
const result = projectReducer(prev, { type: 'sse', project: next })
|
|
115
|
+
|
|
116
|
+
// Scalar fields reflect `next`. Identity is no longer guaranteed —
|
|
117
|
+
// the reducer now runs a reference-preserving structural merge, so the
|
|
118
|
+
// returned object may be a new wrapper that reuses unchanged sub-refs.
|
|
119
|
+
expect(result.id).toBe('proj-2')
|
|
120
|
+
expect(result.name).toBe('Replaced')
|
|
121
|
+
expect(result.status).toBe('draft')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('2. updateOverlayProp patches the right path', () => {
|
|
125
|
+
const prev = makeProject()
|
|
126
|
+
|
|
127
|
+
// Target: slide[1].element[0] (el-1-0, the overlay), prop "text"
|
|
128
|
+
const result = projectReducer(prev, {
|
|
129
|
+
type: 'updateOverlayProp',
|
|
130
|
+
slideId: 'slide-1',
|
|
131
|
+
elementId: 'el-1-0',
|
|
132
|
+
key: 'text',
|
|
133
|
+
value: 'updated',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// New top-level ref
|
|
137
|
+
expect(result).not.toBe(prev)
|
|
138
|
+
|
|
139
|
+
const prevSlide1 = prev.slides![1]
|
|
140
|
+
const resultSlide1 = result.slides![1]
|
|
141
|
+
|
|
142
|
+
// New slide ref for the changed slide
|
|
143
|
+
expect(resultSlide1).not.toBe(prevSlide1)
|
|
144
|
+
|
|
145
|
+
// New element ref
|
|
146
|
+
const prevEl = prevSlide1.elements[0] as OverlayElement
|
|
147
|
+
const resultEl = resultSlide1.elements[0] as OverlayElement
|
|
148
|
+
expect(resultEl).not.toBe(prevEl)
|
|
149
|
+
|
|
150
|
+
// Prop updated
|
|
151
|
+
expect(resultEl.overlay.props['text']).toBe('updated')
|
|
152
|
+
|
|
153
|
+
// Other props on the same overlay untouched
|
|
154
|
+
expect(resultEl.overlay.props['size']).toBe('24px')
|
|
155
|
+
|
|
156
|
+
// Sibling element in slide-1 untouched (same ref)
|
|
157
|
+
expect(resultSlide1.elements[1]).toBe(prevSlide1.elements[1])
|
|
158
|
+
|
|
159
|
+
// slide-0 untouched (same ref)
|
|
160
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('4. setStatus updates only status', () => {
|
|
164
|
+
const prev = makeProject()
|
|
165
|
+
const result = projectReducer(prev, { type: 'setStatus', status: 'final' })
|
|
166
|
+
|
|
167
|
+
expect(result).not.toBe(prev)
|
|
168
|
+
expect(result.status).toBe('final')
|
|
169
|
+
|
|
170
|
+
// Everything else unchanged
|
|
171
|
+
expect(result.id).toBe(prev.id)
|
|
172
|
+
expect(result.name).toBe(prev.name)
|
|
173
|
+
expect(result.slides).toBe(prev.slides)
|
|
174
|
+
expect(result.settings).toBe(prev.settings)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('5. rollback restores the snapshot', () => {
|
|
178
|
+
const current = makeProject({ status: 'draft' })
|
|
179
|
+
const snapshot = makeProject({ id: 'snap-proj', status: 'storyboard_ready' })
|
|
180
|
+
|
|
181
|
+
const result = projectReducer(current, { type: 'rollback', snapshot })
|
|
182
|
+
|
|
183
|
+
expect(result).toBe(snapshot)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('createMutationQueue', () => {
|
|
188
|
+
it('6. enqueue runs N ops in submission order', async () => {
|
|
189
|
+
const queue = createMutationQueue()
|
|
190
|
+
const startOrder: number[] = []
|
|
191
|
+
|
|
192
|
+
// Op i resolves after (30 - i*10)ms so they would naturally finish in reverse order.
|
|
193
|
+
// We track when each fn STARTS, not when it finishes.
|
|
194
|
+
const promises = [0, 1, 2].map((i) =>
|
|
195
|
+
queue.enqueue(async () => {
|
|
196
|
+
startOrder.push(i)
|
|
197
|
+
await new Promise<void>((r) => setTimeout(r, 30 - i * 10))
|
|
198
|
+
return i
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
await Promise.all(promises)
|
|
203
|
+
|
|
204
|
+
expect(startOrder).toEqual([0, 1, 2])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('7. rejection on op K does not break op K+1', async () => {
|
|
208
|
+
const queue = createMutationQueue()
|
|
209
|
+
const secondCalled: boolean[] = []
|
|
210
|
+
|
|
211
|
+
const err = new Error('op-0 failed')
|
|
212
|
+
|
|
213
|
+
const p0 = queue.enqueue(async () => {
|
|
214
|
+
throw err
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const p1 = queue.enqueue(async () => {
|
|
218
|
+
secondCalled.push(true)
|
|
219
|
+
return 42
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// p0 should reject with err
|
|
223
|
+
await expect(p0).rejects.toBe(err)
|
|
224
|
+
|
|
225
|
+
// p1 should resolve to 42
|
|
226
|
+
const result = await p1
|
|
227
|
+
expect(result).toBe(42)
|
|
228
|
+
|
|
229
|
+
// Second fn was actually called
|
|
230
|
+
expect(secondCalled).toEqual([true])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('isPending reflects in-flight ops; onceDrained fires on busy → idle', async () => {
|
|
234
|
+
const queue = createMutationQueue()
|
|
235
|
+
expect(queue.isPending()).toBe(false)
|
|
236
|
+
|
|
237
|
+
let release!: () => void
|
|
238
|
+
const gate = new Promise<void>((r) => { release = r })
|
|
239
|
+
const op = queue.enqueue(() => gate)
|
|
240
|
+
expect(queue.isPending()).toBe(true)
|
|
241
|
+
|
|
242
|
+
let drained = false
|
|
243
|
+
queue.onceDrained(() => { drained = true })
|
|
244
|
+
// Still busy — callback must not have fired yet.
|
|
245
|
+
expect(drained).toBe(false)
|
|
246
|
+
|
|
247
|
+
release()
|
|
248
|
+
await op
|
|
249
|
+
// Give the queued decrement microtask a chance to run.
|
|
250
|
+
await Promise.resolve()
|
|
251
|
+
await Promise.resolve()
|
|
252
|
+
expect(queue.isPending()).toBe(false)
|
|
253
|
+
expect(drained).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('onceDrained on an idle queue fires on the next microtask', async () => {
|
|
257
|
+
const queue = createMutationQueue()
|
|
258
|
+
let drained = false
|
|
259
|
+
queue.onceDrained(() => { drained = true })
|
|
260
|
+
expect(drained).toBe(false) // not synchronous
|
|
261
|
+
await Promise.resolve()
|
|
262
|
+
expect(drained).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Image crop tests
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe('updateImageCrop', () => {
|
|
271
|
+
const CROP = { x: 0.1, y: 0.2, w: 0.5, h: 0.6 }
|
|
272
|
+
|
|
273
|
+
it('8. sets a crop on the targeted image element', () => {
|
|
274
|
+
const prev = makeProject()
|
|
275
|
+
const result = projectReducer(prev, {
|
|
276
|
+
type: 'updateImageCrop',
|
|
277
|
+
slideId: 'slide-0',
|
|
278
|
+
elementId: 'el-0-0',
|
|
279
|
+
crop: CROP,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
expect(result).not.toBe(prev)
|
|
283
|
+
|
|
284
|
+
const resultEl = result.slides![0].elements[0] as ImageElement
|
|
285
|
+
expect(resultEl.crop).toEqual(CROP)
|
|
286
|
+
|
|
287
|
+
// Other geometry fields untouched
|
|
288
|
+
expect(resultEl.src).toBe('https://example.com/img0.png')
|
|
289
|
+
expect(resultEl.x).toBe(0)
|
|
290
|
+
expect(resultEl.y).toBe(0)
|
|
291
|
+
expect(resultEl.w).toBe(100)
|
|
292
|
+
expect(resultEl.h).toBe(100)
|
|
293
|
+
|
|
294
|
+
// Sibling element in same slide untouched (same ref)
|
|
295
|
+
expect(result.slides![0].elements[1]).toBe(prev.slides![0].elements[1])
|
|
296
|
+
|
|
297
|
+
// Other slide untouched (same ref)
|
|
298
|
+
expect(result.slides![1]).toBe(prev.slides![1])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('9. crop: undefined clears an existing crop', () => {
|
|
302
|
+
// Build a project with an existing crop on el-0-0
|
|
303
|
+
const withCrop = makeProject()
|
|
304
|
+
const afterSet = projectReducer(withCrop, {
|
|
305
|
+
type: 'updateImageCrop',
|
|
306
|
+
slideId: 'slide-0',
|
|
307
|
+
elementId: 'el-0-0',
|
|
308
|
+
crop: CROP,
|
|
309
|
+
})
|
|
310
|
+
expect((afterSet.slides![0].elements[0] as ImageElement).crop).toEqual(CROP)
|
|
311
|
+
|
|
312
|
+
// Now clear it
|
|
313
|
+
const afterClear = projectReducer(afterSet, {
|
|
314
|
+
type: 'updateImageCrop',
|
|
315
|
+
slideId: 'slide-0',
|
|
316
|
+
elementId: 'el-0-0',
|
|
317
|
+
crop: undefined,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const resultEl = afterClear.slides![0].elements[0] as ImageElement
|
|
321
|
+
expect(resultEl.crop).toBeUndefined()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('10. updateImageCrop is a no-op when target element is an overlay', () => {
|
|
325
|
+
const prev = makeProject()
|
|
326
|
+
// el-0-1 is an overlay element
|
|
327
|
+
const result = projectReducer(prev, {
|
|
328
|
+
type: 'updateImageCrop',
|
|
329
|
+
slideId: 'slide-0',
|
|
330
|
+
elementId: 'el-0-1',
|
|
331
|
+
crop: CROP,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// The targeted overlay element is returned as-is (same ref)
|
|
335
|
+
expect(result.slides![0].elements[1]).toBe(prev.slides![0].elements[1])
|
|
336
|
+
|
|
337
|
+
// No crop field on the overlay element
|
|
338
|
+
const overlayEl = result.slides![0].elements[1] as OverlayElement
|
|
339
|
+
expect((overlayEl as unknown as { crop?: unknown }).crop).toBeUndefined()
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('resizeElement preserves crop on images', () => {
|
|
344
|
+
it('12. resizeElement preserves any existing crop on an image element', () => {
|
|
345
|
+
const CROP = { x: 0.1, y: 0.2, w: 0.5, h: 0.6 }
|
|
346
|
+
|
|
347
|
+
const withCrop = projectReducer(makeProject(), {
|
|
348
|
+
type: 'updateImageCrop',
|
|
349
|
+
slideId: 'slide-0',
|
|
350
|
+
elementId: 'el-0-0',
|
|
351
|
+
crop: CROP,
|
|
352
|
+
})
|
|
353
|
+
expect((withCrop.slides![0].elements[0] as ImageElement).crop).toEqual(CROP)
|
|
354
|
+
|
|
355
|
+
const result = projectReducer(withCrop, {
|
|
356
|
+
type: 'resizeElement',
|
|
357
|
+
slideId: 'slide-0',
|
|
358
|
+
elementId: 'el-0-0',
|
|
359
|
+
x: 5,
|
|
360
|
+
y: 5,
|
|
361
|
+
w: 200,
|
|
362
|
+
h: 150,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const resultEl = result.slides![0].elements[0] as ImageElement
|
|
366
|
+
expect(resultEl.x).toBe(5)
|
|
367
|
+
expect(resultEl.y).toBe(5)
|
|
368
|
+
expect(resultEl.w).toBe(200)
|
|
369
|
+
expect(resultEl.h).toBe(150)
|
|
370
|
+
expect(resultEl.crop).toEqual(CROP)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('13. resizeElement does not alter overlay elements unexpectedly', () => {
|
|
374
|
+
const prev = makeProject()
|
|
375
|
+
|
|
376
|
+
// Resize the overlay element el-0-1
|
|
377
|
+
const result = projectReducer(prev, {
|
|
378
|
+
type: 'resizeElement',
|
|
379
|
+
slideId: 'slide-0',
|
|
380
|
+
elementId: 'el-0-1',
|
|
381
|
+
x: 20,
|
|
382
|
+
y: 20,
|
|
383
|
+
w: 60,
|
|
384
|
+
h: 30,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const resultEl = result.slides![0].elements[1] as OverlayElement
|
|
388
|
+
expect(resultEl.type).toBe('overlay')
|
|
389
|
+
expect(resultEl.x).toBe(20)
|
|
390
|
+
expect(resultEl.y).toBe(20)
|
|
391
|
+
expect(resultEl.w).toBe(60)
|
|
392
|
+
expect(resultEl.h).toBe(30)
|
|
393
|
+
|
|
394
|
+
// overlay-specific fields untouched
|
|
395
|
+
expect(resultEl.overlay).toEqual(prev.slides![0].elements[1].type === 'overlay'
|
|
396
|
+
? (prev.slides![0].elements[1] as OverlayElement).overlay
|
|
397
|
+
: undefined)
|
|
398
|
+
|
|
399
|
+
// No spurious crop field introduced
|
|
400
|
+
expect((resultEl as unknown as { crop?: unknown }).crop).toBeUndefined()
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
describe('sse merge — reference preservation', () => {
|
|
405
|
+
it('returns the same project reference when next is structurally identical', () => {
|
|
406
|
+
const a = makeProject()
|
|
407
|
+
const b = JSON.parse(JSON.stringify(a)) as Project
|
|
408
|
+
const result = projectReducer(a, { type: 'sse', project: b })
|
|
409
|
+
expect(result).toBe(a) // identity, not deep-equal
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('preserves unchanged slide refs when one slide changes', () => {
|
|
413
|
+
const a = makeProject()
|
|
414
|
+
const b = JSON.parse(JSON.stringify(a)) as Project
|
|
415
|
+
b.slides![1].base_color = '#ff00ff'
|
|
416
|
+
const result = projectReducer(a, { type: 'sse', project: b })
|
|
417
|
+
expect(result).not.toBe(a)
|
|
418
|
+
expect(result.slides![0]).toBe(a.slides![0])
|
|
419
|
+
expect(result.slides![1]).not.toBe(a.slides![1])
|
|
420
|
+
expect(result.slides![1].base_color).toBe('#ff00ff')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('preserves unchanged element refs when one element changes', () => {
|
|
424
|
+
const a = makeProject()
|
|
425
|
+
const b = JSON.parse(JSON.stringify(a)) as Project
|
|
426
|
+
b.slides![0].elements[0] = { ...b.slides![0].elements[0], x: 999 }
|
|
427
|
+
const result = projectReducer(a, { type: 'sse', project: b })
|
|
428
|
+
expect(result.slides![0].elements[0]).not.toBe(a.slides![0].elements[0])
|
|
429
|
+
expect(result.slides![0].elements[1]).toBe(a.slides![0].elements[1])
|
|
430
|
+
expect(result.slides![1]).toBe(a.slides![1])
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('preserves all slide refs when slides are reordered with same ids', () => {
|
|
434
|
+
// This test catches a by-index merge regression: if a future implementation
|
|
435
|
+
// matches slides positionally, slide-0 and slide-1 swap, deepEqual fails
|
|
436
|
+
// on both positions, and both slides get fresh refs. With by-id matching,
|
|
437
|
+
// both refs are preserved (the slides themselves didn't change, only their
|
|
438
|
+
// order in the array).
|
|
439
|
+
const a = makeProject()
|
|
440
|
+
const b = JSON.parse(JSON.stringify(a)) as Project
|
|
441
|
+
const [s0, s1] = b.slides!
|
|
442
|
+
b.slides = [s1, s0]
|
|
443
|
+
const result = projectReducer(a, { type: 'sse', project: b })
|
|
444
|
+
// slides array itself must be a new ref (different order)
|
|
445
|
+
expect(result.slides).not.toBe(a.slides)
|
|
446
|
+
// individual slide refs must be the originals (only their position moved)
|
|
447
|
+
expect(result.slides![0]).toBe(a.slides![1])
|
|
448
|
+
expect(result.slides![1]).toBe(a.slides![0])
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('handles slide list length change', () => {
|
|
452
|
+
const a = makeProject()
|
|
453
|
+
const b = JSON.parse(JSON.stringify(a)) as Project
|
|
454
|
+
b.slides!.push({ id: 'slide-new', base_color: '#abcdef', elements: [] } as Slide)
|
|
455
|
+
const result = projectReducer(a, { type: 'sse', project: b })
|
|
456
|
+
expect(result.slides).toHaveLength(a.slides!.length + 1)
|
|
457
|
+
expect(result.slides![0]).toBe(a.slides![0])
|
|
458
|
+
expect(result.slides![1]).toBe(a.slides![1])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('preserves slides array ref when only top-level scalar changes', () => {
|
|
462
|
+
const a = makeProject({ status: 'draft' })
|
|
463
|
+
const b = JSON.parse(JSON.stringify(a)) as Project
|
|
464
|
+
b.status = 'final'
|
|
465
|
+
const result = projectReducer(a, { type: 'sse', project: b })
|
|
466
|
+
expect(result.status).toBe('final')
|
|
467
|
+
expect(result.slides).toBe(a.slides)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('treats {field: undefined} as structurally equal to {} (json round-trip)', () => {
|
|
471
|
+
// After clearing a field (e.g. resizeElement writes `{...el, crop: undefined}`),
|
|
472
|
+
// the local state has the key present with undefined. JSON serialization on
|
|
473
|
+
// the server drops undefined-valued keys, so the SSE echo arrives with the
|
|
474
|
+
// key absent. These must compare equal so the identity short-circuit fires
|
|
475
|
+
// on PUT echoes — otherwise every image resize triggers a re-render storm.
|
|
476
|
+
const a = makeProject()
|
|
477
|
+
// Locally, simulate a cleared crop: explicit `crop: undefined` on the image.
|
|
478
|
+
const aWithUndefined = {
|
|
479
|
+
...a,
|
|
480
|
+
slides: a.slides!.map((s, i) => i === 0
|
|
481
|
+
? { ...s, elements: s.elements.map((el, j) => j === 0 ? { ...el, crop: undefined } : el) }
|
|
482
|
+
: s,
|
|
483
|
+
),
|
|
484
|
+
}
|
|
485
|
+
// SSE echo: the same project after JSON round-trip — undefined keys dropped.
|
|
486
|
+
const echoed = JSON.parse(JSON.stringify(aWithUndefined)) as Project
|
|
487
|
+
const result = projectReducer(aWithUndefined, { type: 'sse', project: echoed })
|
|
488
|
+
expect(result).toBe(aWithUndefined)
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// addSlide
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
describe('addSlide', () => {
|
|
497
|
+
it('appends a new slide when afterSlideId is not provided', () => {
|
|
498
|
+
const prev = makeProject()
|
|
499
|
+
const newSlide = makeSlide('slide-new')
|
|
500
|
+
const result = projectReducer(prev, { type: 'addSlide', slide: newSlide })
|
|
501
|
+
|
|
502
|
+
expect(result).not.toBe(prev)
|
|
503
|
+
expect(result.slides).toHaveLength(3)
|
|
504
|
+
expect(result.slides![2]).toBe(newSlide)
|
|
505
|
+
// Original slides unchanged (same refs)
|
|
506
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
507
|
+
expect(result.slides![1]).toBe(prev.slides![1])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('inserts after the specified slide when afterSlideId is found', () => {
|
|
511
|
+
const prev = makeProject()
|
|
512
|
+
const newSlide = makeSlide('slide-new')
|
|
513
|
+
const result = projectReducer(prev, { type: 'addSlide', slide: newSlide, afterSlideId: 'slide-0' })
|
|
514
|
+
|
|
515
|
+
expect(result.slides).toHaveLength(3)
|
|
516
|
+
expect(result.slides![0].id).toBe('slide-0')
|
|
517
|
+
expect(result.slides![1].id).toBe('slide-new')
|
|
518
|
+
expect(result.slides![2].id).toBe('slide-1')
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('appends when afterSlideId is not found', () => {
|
|
522
|
+
const prev = makeProject()
|
|
523
|
+
const newSlide = makeSlide('slide-new')
|
|
524
|
+
const result = projectReducer(prev, { type: 'addSlide', slide: newSlide, afterSlideId: 'nonexistent' })
|
|
525
|
+
|
|
526
|
+
expect(result.slides).toHaveLength(3)
|
|
527
|
+
expect(result.slides![2].id).toBe('slide-new')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('does not mutate the original slides array', () => {
|
|
531
|
+
const prev = makeProject()
|
|
532
|
+
const originalSlides = prev.slides!.slice()
|
|
533
|
+
projectReducer(prev, { type: 'addSlide', slide: makeSlide('slide-new') })
|
|
534
|
+
expect(prev.slides).toEqual(originalSlides)
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// removeSlide
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
describe('removeSlide', () => {
|
|
543
|
+
it('removes the slide with the matching id', () => {
|
|
544
|
+
const prev = makeProject()
|
|
545
|
+
const result = projectReducer(prev, { type: 'removeSlide', slideId: 'slide-0' })
|
|
546
|
+
|
|
547
|
+
expect(result.slides).toHaveLength(1)
|
|
548
|
+
expect(result.slides![0].id).toBe('slide-1')
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('is a structural no-op when slideId does not exist', () => {
|
|
552
|
+
const prev = makeProject()
|
|
553
|
+
const result = projectReducer(prev, { type: 'removeSlide', slideId: 'nonexistent' })
|
|
554
|
+
|
|
555
|
+
expect(result.slides).toHaveLength(2)
|
|
556
|
+
// Both slide refs preserved
|
|
557
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
558
|
+
expect(result.slides![1]).toBe(prev.slides![1])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('does not mutate the original slides array', () => {
|
|
562
|
+
const prev = makeProject()
|
|
563
|
+
const originalLength = prev.slides!.length
|
|
564
|
+
projectReducer(prev, { type: 'removeSlide', slideId: 'slide-0' })
|
|
565
|
+
expect(prev.slides).toHaveLength(originalLength)
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// duplicateSlide
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
describe('duplicateSlide', () => {
|
|
574
|
+
it('inserts newSlide immediately after the source slide', () => {
|
|
575
|
+
const prev = makeProject()
|
|
576
|
+
const newSlide = makeSlide('slide-dup')
|
|
577
|
+
const result = projectReducer(prev, { type: 'duplicateSlide', slideId: 'slide-0', newSlide })
|
|
578
|
+
|
|
579
|
+
expect(result.slides).toHaveLength(3)
|
|
580
|
+
expect(result.slides![0].id).toBe('slide-0')
|
|
581
|
+
expect(result.slides![1].id).toBe('slide-dup')
|
|
582
|
+
expect(result.slides![2].id).toBe('slide-1')
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('returns the same state when the source slideId is not found', () => {
|
|
586
|
+
const prev = makeProject()
|
|
587
|
+
const result = projectReducer(prev, { type: 'duplicateSlide', slideId: 'ghost', newSlide: makeSlide('x') })
|
|
588
|
+
expect(result).toBe(prev)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('newSlide has a distinct id from the source', () => {
|
|
592
|
+
const prev = makeProject()
|
|
593
|
+
const newSlide = makeSlide('slide-dup-new')
|
|
594
|
+
const result = projectReducer(prev, { type: 'duplicateSlide', slideId: 'slide-0', newSlide })
|
|
595
|
+
const ids = result.slides!.map((s) => s.id)
|
|
596
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// reorderSlides
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
describe('reorderSlides', () => {
|
|
605
|
+
it('moves a slide from one index to another', () => {
|
|
606
|
+
const prev = makeProject()
|
|
607
|
+
// Move slide-1 (index 1) to index 0
|
|
608
|
+
const result = projectReducer(prev, { type: 'reorderSlides', fromIndex: 1, toIndex: 0 })
|
|
609
|
+
|
|
610
|
+
expect(result.slides).toHaveLength(2)
|
|
611
|
+
expect(result.slides![0].id).toBe('slide-1')
|
|
612
|
+
expect(result.slides![1].id).toBe('slide-0')
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('is a no-op when fromIndex is out of bounds', () => {
|
|
616
|
+
const prev = makeProject()
|
|
617
|
+
const result = projectReducer(prev, { type: 'reorderSlides', fromIndex: 5, toIndex: 0 })
|
|
618
|
+
expect(result).toBe(prev)
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('is a no-op when toIndex is out of bounds', () => {
|
|
622
|
+
const prev = makeProject()
|
|
623
|
+
const result = projectReducer(prev, { type: 'reorderSlides', fromIndex: 0, toIndex: 99 })
|
|
624
|
+
expect(result).toBe(prev)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('is a no-op when fromIndex is negative', () => {
|
|
628
|
+
const prev = makeProject()
|
|
629
|
+
const result = projectReducer(prev, { type: 'reorderSlides', fromIndex: -1, toIndex: 0 })
|
|
630
|
+
expect(result).toBe(prev)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('does not mutate the original slides array', () => {
|
|
634
|
+
const prev = makeProject()
|
|
635
|
+
const originalFirst = prev.slides![0].id
|
|
636
|
+
projectReducer(prev, { type: 'reorderSlides', fromIndex: 0, toIndex: 1 })
|
|
637
|
+
expect(prev.slides![0].id).toBe(originalFirst)
|
|
638
|
+
})
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// updateSlide
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
describe('updateSlide', () => {
|
|
646
|
+
it('shallow-patches the matching slide', () => {
|
|
647
|
+
const prev = makeProject()
|
|
648
|
+
const result = projectReducer(prev, {
|
|
649
|
+
type: 'updateSlide',
|
|
650
|
+
slideId: 'slide-0',
|
|
651
|
+
patch: { base_color: '#abcdef' },
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
expect(result.slides![0].base_color).toBe('#abcdef')
|
|
655
|
+
// Elements untouched (same ref)
|
|
656
|
+
expect(result.slides![0].elements).toBe(prev.slides![0].elements)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('leaves other slides untouched (same refs)', () => {
|
|
660
|
+
const prev = makeProject()
|
|
661
|
+
const result = projectReducer(prev, {
|
|
662
|
+
type: 'updateSlide',
|
|
663
|
+
slideId: 'slide-0',
|
|
664
|
+
patch: { base_color: '#111111' },
|
|
665
|
+
})
|
|
666
|
+
expect(result.slides![1]).toBe(prev.slides![1])
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('does not mutate the input state', () => {
|
|
670
|
+
const prev = makeProject()
|
|
671
|
+
const originalColor = prev.slides![0].base_color
|
|
672
|
+
projectReducer(prev, { type: 'updateSlide', slideId: 'slide-0', patch: { base_color: '#999' } })
|
|
673
|
+
expect(prev.slides![0].base_color).toBe(originalColor)
|
|
674
|
+
})
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// duplicateElement
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
describe('duplicateElement', () => {
|
|
682
|
+
it('inserts newElement immediately after the source element', () => {
|
|
683
|
+
const prev = makeProject()
|
|
684
|
+
const newEl = makeImageEl('el-new')
|
|
685
|
+
const result = projectReducer(prev, {
|
|
686
|
+
type: 'duplicateElement',
|
|
687
|
+
slideId: 'slide-0',
|
|
688
|
+
elementId: 'el-0-0',
|
|
689
|
+
newElement: newEl as CarouselElement,
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
const els = result.slides![0].elements
|
|
693
|
+
expect(els).toHaveLength(3)
|
|
694
|
+
expect(els[0].id).toBe('el-0-0')
|
|
695
|
+
expect(els[1].id).toBe('el-new')
|
|
696
|
+
expect(els[2].id).toBe('el-0-1')
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('is a no-op on the slide when the source elementId does not exist', () => {
|
|
700
|
+
const prev = makeProject()
|
|
701
|
+
const result = projectReducer(prev, {
|
|
702
|
+
type: 'duplicateElement',
|
|
703
|
+
slideId: 'slide-0',
|
|
704
|
+
elementId: 'ghost',
|
|
705
|
+
newElement: makeImageEl('x') as CarouselElement,
|
|
706
|
+
})
|
|
707
|
+
expect(result.slides![0].elements).toHaveLength(prev.slides![0].elements.length)
|
|
708
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('newElement gets a distinct id from the source element', () => {
|
|
712
|
+
const prev = makeProject()
|
|
713
|
+
const newEl = makeImageEl('el-0-0-copy')
|
|
714
|
+
const result = projectReducer(prev, {
|
|
715
|
+
type: 'duplicateElement',
|
|
716
|
+
slideId: 'slide-0',
|
|
717
|
+
elementId: 'el-0-0',
|
|
718
|
+
newElement: newEl as CarouselElement,
|
|
719
|
+
})
|
|
720
|
+
const ids = result.slides![0].elements.map((e) => e.id)
|
|
721
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it('leaves other slides untouched', () => {
|
|
725
|
+
const prev = makeProject()
|
|
726
|
+
const result = projectReducer(prev, {
|
|
727
|
+
type: 'duplicateElement',
|
|
728
|
+
slideId: 'slide-0',
|
|
729
|
+
elementId: 'el-0-0',
|
|
730
|
+
newElement: makeImageEl('el-dup') as CarouselElement,
|
|
731
|
+
})
|
|
732
|
+
expect(result.slides![1]).toBe(prev.slides![1])
|
|
733
|
+
})
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
// reorderElement
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
describe('reorderElement', () => {
|
|
741
|
+
it('swaps the element forward (toward end of array)', () => {
|
|
742
|
+
const prev = makeProject()
|
|
743
|
+
// el-0-0 is index 0; move forward → should swap with el-0-1 at index 1
|
|
744
|
+
const result = projectReducer(prev, {
|
|
745
|
+
type: 'reorderElement',
|
|
746
|
+
slideId: 'slide-0',
|
|
747
|
+
elementId: 'el-0-0',
|
|
748
|
+
direction: 'forward',
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
const els = result.slides![0].elements
|
|
752
|
+
expect(els[0].id).toBe('el-0-1')
|
|
753
|
+
expect(els[1].id).toBe('el-0-0')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('swaps the element backward (toward start of array)', () => {
|
|
757
|
+
const prev = makeProject()
|
|
758
|
+
// el-0-1 is index 1; move backward → should swap with el-0-0 at index 0
|
|
759
|
+
const result = projectReducer(prev, {
|
|
760
|
+
type: 'reorderElement',
|
|
761
|
+
slideId: 'slide-0',
|
|
762
|
+
elementId: 'el-0-1',
|
|
763
|
+
direction: 'backward',
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
const els = result.slides![0].elements
|
|
767
|
+
expect(els[0].id).toBe('el-0-1')
|
|
768
|
+
expect(els[1].id).toBe('el-0-0')
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('is a no-op when moving the last element forward', () => {
|
|
772
|
+
const prev = makeProject()
|
|
773
|
+
// el-0-1 is the last element in slide-0
|
|
774
|
+
const result = projectReducer(prev, {
|
|
775
|
+
type: 'reorderElement',
|
|
776
|
+
slideId: 'slide-0',
|
|
777
|
+
elementId: 'el-0-1',
|
|
778
|
+
direction: 'forward',
|
|
779
|
+
})
|
|
780
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('is a no-op when moving the first element backward', () => {
|
|
784
|
+
const prev = makeProject()
|
|
785
|
+
// el-0-0 is the first element in slide-0
|
|
786
|
+
const result = projectReducer(prev, {
|
|
787
|
+
type: 'reorderElement',
|
|
788
|
+
slideId: 'slide-0',
|
|
789
|
+
elementId: 'el-0-0',
|
|
790
|
+
direction: 'backward',
|
|
791
|
+
})
|
|
792
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('is a no-op when the elementId does not exist', () => {
|
|
796
|
+
const prev = makeProject()
|
|
797
|
+
const result = projectReducer(prev, {
|
|
798
|
+
type: 'reorderElement',
|
|
799
|
+
slideId: 'slide-0',
|
|
800
|
+
elementId: 'ghost',
|
|
801
|
+
direction: 'forward',
|
|
802
|
+
})
|
|
803
|
+
expect(result.slides![0]).toBe(prev.slides![0])
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
it('does not mutate the original elements array', () => {
|
|
807
|
+
const prev = makeProject()
|
|
808
|
+
const originalFirst = prev.slides![0].elements[0].id
|
|
809
|
+
projectReducer(prev, {
|
|
810
|
+
type: 'reorderElement',
|
|
811
|
+
slideId: 'slide-0',
|
|
812
|
+
elementId: 'el-0-0',
|
|
813
|
+
direction: 'forward',
|
|
814
|
+
})
|
|
815
|
+
expect(prev.slides![0].elements[0].id).toBe(originalFirst)
|
|
816
|
+
})
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
// setOverlayFrame
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
|
|
823
|
+
describe('setOverlayFrame', () => {
|
|
824
|
+
it('updates the frame on the targeted overlay element', () => {
|
|
825
|
+
const prev = makeProject()
|
|
826
|
+
const result = projectReducer(prev, {
|
|
827
|
+
type: 'setOverlayFrame',
|
|
828
|
+
slideId: 'slide-0',
|
|
829
|
+
elementId: 'el-0-1',
|
|
830
|
+
frame: 7,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
const el = result.slides![0].elements[1] as OverlayElement
|
|
834
|
+
expect(el.frame).toBe(7)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('is a no-op on image elements (type guard)', () => {
|
|
838
|
+
const prev = makeProject()
|
|
839
|
+
// el-0-0 is an image element
|
|
840
|
+
const result = projectReducer(prev, {
|
|
841
|
+
type: 'setOverlayFrame',
|
|
842
|
+
slideId: 'slide-0',
|
|
843
|
+
elementId: 'el-0-0',
|
|
844
|
+
frame: 3,
|
|
845
|
+
})
|
|
846
|
+
expect(result.slides![0].elements[0]).toBe(prev.slides![0].elements[0])
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it('leaves other slides untouched', () => {
|
|
850
|
+
const prev = makeProject()
|
|
851
|
+
const result = projectReducer(prev, {
|
|
852
|
+
type: 'setOverlayFrame',
|
|
853
|
+
slideId: 'slide-0',
|
|
854
|
+
elementId: 'el-0-1',
|
|
855
|
+
frame: 2,
|
|
856
|
+
})
|
|
857
|
+
expect(result.slides![1]).toBe(prev.slides![1])
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it('does not mutate the original element', () => {
|
|
861
|
+
const prev = makeProject()
|
|
862
|
+
const originalFrame = (prev.slides![0].elements[1] as OverlayElement).frame
|
|
863
|
+
projectReducer(prev, {
|
|
864
|
+
type: 'setOverlayFrame',
|
|
865
|
+
slideId: 'slide-0',
|
|
866
|
+
elementId: 'el-0-1',
|
|
867
|
+
frame: 99,
|
|
868
|
+
})
|
|
869
|
+
expect((prev.slides![0].elements[1] as OverlayElement).frame).toBe(originalFrame)
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
// mediaId passthrough round-trip tests
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
describe('ImageElement mediaId passthrough', () => {
|
|
878
|
+
it('mediaId survives an sse merge unchanged', () => {
|
|
879
|
+
// Build a project whose first slide has an ImageElement with mediaId set.
|
|
880
|
+
const elementWithMedia: ImageElement = {
|
|
881
|
+
id: 'el-media-0',
|
|
882
|
+
type: 'image',
|
|
883
|
+
src: 'https://cdn.example.com/photo.jpg',
|
|
884
|
+
x: 0,
|
|
885
|
+
y: 0,
|
|
886
|
+
w: 200,
|
|
887
|
+
h: 200,
|
|
888
|
+
rotation: 0,
|
|
889
|
+
mediaId: 'hub-media-abc123',
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const prev: Project = {
|
|
893
|
+
version: '1',
|
|
894
|
+
id: 'proj-media',
|
|
895
|
+
name: 'Media Test',
|
|
896
|
+
workflow: 'carousel',
|
|
897
|
+
status: 'pending',
|
|
898
|
+
editingPrompt: '',
|
|
899
|
+
settings: { resolution: [1080, 1080] },
|
|
900
|
+
assets: [],
|
|
901
|
+
slides: [
|
|
902
|
+
{
|
|
903
|
+
id: 'slide-0',
|
|
904
|
+
base_color: '#ffffff',
|
|
905
|
+
elements: [elementWithMedia],
|
|
906
|
+
},
|
|
907
|
+
],
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Simulate an SSE echo: the server round-trips the same project data back.
|
|
911
|
+
const echo: Project = JSON.parse(JSON.stringify(prev))
|
|
912
|
+
|
|
913
|
+
const result = projectReducer(prev, { type: 'sse', project: echo })
|
|
914
|
+
|
|
915
|
+
const resultEl = result.slides![0].elements[0] as ImageElement
|
|
916
|
+
expect(resultEl.mediaId).toBe('hub-media-abc123')
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it('ImageElement without mediaId is valid (field is truly optional)', () => {
|
|
920
|
+
// This is a compile-time check that also runs as a runtime assertion:
|
|
921
|
+
// constructing an ImageElement without mediaId must be allowed.
|
|
922
|
+
const el: ImageElement = {
|
|
923
|
+
id: 'el-no-media',
|
|
924
|
+
type: 'image',
|
|
925
|
+
src: 'https://cdn.example.com/other.jpg',
|
|
926
|
+
x: 10,
|
|
927
|
+
y: 10,
|
|
928
|
+
w: 100,
|
|
929
|
+
h: 100,
|
|
930
|
+
rotation: 0,
|
|
931
|
+
// mediaId intentionally omitted
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
expect(el.mediaId).toBeUndefined()
|
|
935
|
+
|
|
936
|
+
const proj: Project = {
|
|
937
|
+
version: '1',
|
|
938
|
+
id: 'proj-no-media',
|
|
939
|
+
name: 'No Media Test',
|
|
940
|
+
workflow: 'carousel',
|
|
941
|
+
status: 'pending',
|
|
942
|
+
editingPrompt: '',
|
|
943
|
+
settings: { resolution: [1080, 1080] },
|
|
944
|
+
assets: [],
|
|
945
|
+
slides: [{ id: 'slide-0', base_color: '#ffffff', elements: [el] }],
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const echo: Project = JSON.parse(JSON.stringify(proj))
|
|
949
|
+
const result = projectReducer(proj, { type: 'sse', project: echo })
|
|
950
|
+
|
|
951
|
+
const resultEl = result.slides![0].elements[0] as ImageElement
|
|
952
|
+
// After JSON round-trip, undefined fields are absent — not undefined.
|
|
953
|
+
expect(resultEl.mediaId).toBeUndefined()
|
|
954
|
+
// Structural equality means the reducer reuses the same reference.
|
|
955
|
+
expect(result).toBe(proj)
|
|
956
|
+
})
|
|
957
|
+
})
|