@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.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. 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
+ })