@bendyline/squisq-react 1.4.1 → 2.0.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 (45) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +185 -27
  3. package/dist/index.js +1323 -614
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.global.js +54 -37
  6. package/dist/squisq-player.global.js.map +1 -1
  7. package/dist/standalone-source.js +1 -1
  8. package/package.json +2 -2
  9. package/src/BlockRenderer.tsx +53 -17
  10. package/src/DocControlsSlideshow.tsx +235 -7
  11. package/src/DocPlayer.tsx +462 -195
  12. package/src/DocPlayerWithSidebar.tsx +4 -0
  13. package/src/DocProgressBar.tsx +40 -1
  14. package/src/LinearDocView.tsx +135 -62
  15. package/src/MarkdownRenderer.tsx +40 -97
  16. package/src/MediaClipLayer.tsx +12 -2
  17. package/src/__tests__/BlockRenderer.test.tsx +79 -8
  18. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  19. package/src/__tests__/DocPlayer.test.tsx +556 -2
  20. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  21. package/src/__tests__/LinearDocView.test.tsx +91 -11
  22. package/src/__tests__/MapLayer.test.tsx +63 -0
  23. package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
  24. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  25. package/src/__tests__/MediaContext.test.tsx +51 -0
  26. package/src/__tests__/PathLayer.test.tsx +12 -1
  27. package/src/__tests__/VideoLayer.test.tsx +94 -0
  28. package/src/__tests__/fillStyle.test.tsx +3 -2
  29. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  30. package/src/__tests__/useAudioSync.test.ts +49 -0
  31. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  32. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  33. package/src/hooks/MediaContext.tsx +12 -3
  34. package/src/hooks/useAudioSync.ts +61 -12
  35. package/src/hooks/useDocPlayback.ts +40 -12
  36. package/src/hooks/useViewportOrientation.ts +2 -4
  37. package/src/index.ts +5 -2
  38. package/src/layers/MapLayer.tsx +7 -6
  39. package/src/layers/PathLayer.tsx +20 -11
  40. package/src/layers/ShapeLayer.tsx +4 -2
  41. package/src/layers/TextLayer.tsx +4 -3
  42. package/src/layers/TreeLayer.tsx +167 -0
  43. package/src/layers/VideoLayer.tsx +20 -6
  44. package/src/standalone-entry.tsx +91 -14
  45. package/src/types.ts +19 -13
@@ -1,7 +1,9 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render } from '@testing-library/react';
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { DocPlayer } from '../DocPlayer';
4
4
  import type { Doc } from '@bendyline/squisq/schemas';
5
+ import type { AudioController } from '../hooks/AudioController';
6
+ import type { SquisqRenderAPI } from '../types';
5
7
 
6
8
  function minimalDoc(): Doc {
7
9
  return {
@@ -12,7 +14,100 @@ function minimalDoc(): Doc {
12
14
  };
13
15
  }
14
16
 
17
+ function docWithCover(): Doc {
18
+ return {
19
+ ...minimalDoc(),
20
+ startBlock: {
21
+ title: 'Managed Cover',
22
+ subtitle: 'Generated by Squisq',
23
+ },
24
+ };
25
+ }
26
+
27
+ function docWithTransition(): Doc {
28
+ return {
29
+ articleId: 'transition-doc',
30
+ duration: 10,
31
+ blocks: [
32
+ { id: 'first', startTime: 0, duration: 5, audioSegment: 0, layers: [] },
33
+ {
34
+ id: 'second',
35
+ startTime: 5,
36
+ duration: 5,
37
+ audioSegment: 0,
38
+ transition: { type: 'fade', duration: 1 },
39
+ layers: [
40
+ {
41
+ type: 'text',
42
+ id: 'animated-title',
43
+ content: { text: 'Second slide', style: { fontSize: 48, color: '#fff' } },
44
+ position: { x: 100, y: 100 },
45
+ animation: { type: 'fadeIn', duration: 1 },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ audio: { segments: [] },
51
+ };
52
+ }
53
+
54
+ function docWithThreeSlides(): Doc {
55
+ return {
56
+ articleId: 'three-slide-transition-doc',
57
+ duration: 15,
58
+ blocks: [
59
+ { id: 'first', startTime: 0, duration: 5, audioSegment: 0, layers: [] },
60
+ { id: 'second', startTime: 5, duration: 5, audioSegment: 0, layers: [] },
61
+ {
62
+ id: 'third',
63
+ startTime: 10,
64
+ duration: 5,
65
+ audioSegment: 0,
66
+ transition: { type: 'fade', duration: 1 },
67
+ layers: [],
68
+ },
69
+ ],
70
+ audio: { segments: [] },
71
+ };
72
+ }
73
+
74
+ function controller(overrides: Partial<AudioController> = {}): AudioController {
75
+ return {
76
+ currentTime: 0,
77
+ isPlaying: false,
78
+ currentSegment: 0,
79
+ totalDuration: 5,
80
+ isEnded: false,
81
+ isReady: true,
82
+ isAvailable: true,
83
+ play: vi.fn(async () => {}),
84
+ pause: vi.fn(async () => {}),
85
+ toggle: vi.fn(async () => {}),
86
+ seekTo: vi.fn(async () => {}),
87
+ skipToSegment: vi.fn(async () => {}),
88
+ restart: vi.fn(async () => {}),
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ function pointerEvent(type: string, clientX: number): MouseEvent {
94
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX });
95
+ Object.defineProperties(event, {
96
+ pointerId: { value: 1 },
97
+ pointerType: { value: 'touch' },
98
+ });
99
+ return event;
100
+ }
101
+
15
102
  describe('DocPlayer smoke test', () => {
103
+ beforeAll(() => {
104
+ vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
105
+ });
106
+
107
+ afterAll(() => {
108
+ vi.restoreAllMocks();
109
+ });
110
+
16
111
  it('renders without crashing in video mode (default)', () => {
17
112
  const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
18
113
  expect(container.firstChild).toBeTruthy();
@@ -25,6 +120,327 @@ describe('DocPlayer smoke test', () => {
25
120
  expect(container.firstChild).toBeTruthy();
26
121
  });
27
122
 
123
+ it('lists block summaries and jumps to a selected slideshow block', () => {
124
+ const audioController = controller({ totalDuration: 15 });
125
+ render(
126
+ <DocPlayer
127
+ doc={docWithThreeSlides()}
128
+ audioController={audioController}
129
+ displayMode="slideshow"
130
+ />,
131
+ );
132
+
133
+ fireEvent.click(screen.getByTestId('slide-counter'));
134
+ expect(screen.getByTestId('slide-picker-item-0').textContent).toContain('1First');
135
+ expect(screen.getByTestId('slide-picker-item-2').textContent).toContain('3Third');
136
+
137
+ fireEvent.click(screen.getByTestId('slide-picker-item-2'));
138
+ expect(audioController.seekTo).toHaveBeenCalledWith(10);
139
+ expect(screen.queryByTestId('slide-picker')).toBeNull();
140
+ });
141
+
142
+ it('uses global left and right arrows to change slides without focusing the player', () => {
143
+ const audioController = controller({ currentTime: 5, totalDuration: 15 });
144
+ render(
145
+ <DocPlayer
146
+ doc={docWithThreeSlides()}
147
+ audioController={audioController}
148
+ displayMode="slideshow"
149
+ globalKeyboardShortcuts
150
+ />,
151
+ );
152
+
153
+ fireEvent.keyDown(document, { key: 'ArrowLeft' });
154
+ fireEvent.keyDown(document, { key: 'ArrowRight' });
155
+
156
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(1, 0);
157
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(2, 10);
158
+ });
159
+
160
+ it('globally opens the slide picker with ArrowUp instead of changing slides', () => {
161
+ const audioController = controller({ currentTime: 5, totalDuration: 15 });
162
+ render(
163
+ <DocPlayer
164
+ doc={docWithThreeSlides()}
165
+ audioController={audioController}
166
+ displayMode="slideshow"
167
+ globalKeyboardShortcuts
168
+ />,
169
+ );
170
+
171
+ fireEvent.keyDown(document, { key: 'ArrowUp' });
172
+
173
+ expect(screen.getByTestId('slide-picker')).toBeTruthy();
174
+ expect(audioController.seekTo).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('globally seeks video with left and right arrows', () => {
178
+ const audioController = controller({ currentTime: 20, totalDuration: 60 });
179
+ render(
180
+ <DocPlayer
181
+ doc={docWithThreeSlides()}
182
+ audioController={audioController}
183
+ globalKeyboardShortcuts
184
+ />,
185
+ );
186
+
187
+ fireEvent.keyDown(document, { key: 'ArrowRight' });
188
+ fireEvent.keyDown(document, { key: 'ArrowLeft' });
189
+
190
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(1, 30);
191
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(2, 10);
192
+ });
193
+
194
+ it('handles a focused-player arrow only once when global shortcuts are enabled', () => {
195
+ const audioController = controller({ currentTime: 5, totalDuration: 15 });
196
+ const { container } = render(
197
+ <DocPlayer
198
+ doc={docWithThreeSlides()}
199
+ audioController={audioController}
200
+ displayMode="slideshow"
201
+ globalKeyboardShortcuts
202
+ />,
203
+ );
204
+
205
+ fireEvent.keyDown(container.querySelector<HTMLElement>('.doc-player')!, {
206
+ key: 'ArrowRight',
207
+ });
208
+
209
+ expect(audioController.seekTo).toHaveBeenCalledTimes(1);
210
+ expect(audioController.seekTo).toHaveBeenCalledWith(10);
211
+ });
212
+
213
+ it('does not globally intercept arrows from editable fields', () => {
214
+ const audioController = controller({ currentTime: 20, totalDuration: 60 });
215
+ render(
216
+ <>
217
+ <input aria-label="Editable field" />
218
+ <DocPlayer
219
+ doc={docWithThreeSlides()}
220
+ audioController={audioController}
221
+ globalKeyboardShortcuts
222
+ />
223
+ </>,
224
+ );
225
+
226
+ fireEvent.keyDown(screen.getByRole('textbox', { name: 'Editable field' }), {
227
+ key: 'ArrowRight',
228
+ });
229
+
230
+ expect(audioController.seekTo).not.toHaveBeenCalled();
231
+ });
232
+
233
+ it('shows the managed cover as the first slideshow entry by default', async () => {
234
+ const { container } = render(
235
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
236
+ );
237
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
238
+ expect(screen.getByTestId('slide-counter').textContent).toBe('Cover');
239
+ });
240
+
241
+ it('can suppress the managed cover slide', () => {
242
+ const { container } = render(
243
+ <DocPlayer
244
+ doc={docWithCover()}
245
+ basePath="/test"
246
+ displayMode="slideshow"
247
+ showCoverSlide={false}
248
+ />,
249
+ );
250
+ expect(container.textContent).not.toContain('Managed Cover');
251
+ expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1');
252
+ });
253
+
254
+ it('renders the managed cover without layer animation classes when animations are disabled', async () => {
255
+ const { container } = render(
256
+ <DocPlayer
257
+ doc={docWithCover()}
258
+ basePath="/test"
259
+ displayMode="slideshow"
260
+ animationsEnabled={false}
261
+ />,
262
+ );
263
+
264
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
265
+ expect(container.querySelector('[class*="anim-"]')).toBeNull();
266
+ });
267
+
268
+ it('uses static cuts without an outgoing slide while preserving timeline changes', () => {
269
+ const doc = docWithTransition();
270
+ const onRenderAPIReady = vi.fn();
271
+ const { container, rerender } = render(
272
+ <DocPlayer
273
+ doc={doc}
274
+ audioController={controller({ currentTime: 0, totalDuration: 10 })}
275
+ showControls={false}
276
+ renderMode
277
+ animationsEnabled={false}
278
+ onRenderAPIReady={onRenderAPIReady}
279
+ />,
280
+ );
281
+
282
+ rerender(
283
+ <DocPlayer
284
+ doc={doc}
285
+ audioController={controller({ currentTime: 5.1, totalDuration: 10 })}
286
+ showControls={false}
287
+ renderMode
288
+ animationsEnabled={false}
289
+ onRenderAPIReady={onRenderAPIReady}
290
+ />,
291
+ );
292
+
293
+ expect(container.querySelector('.doc-player__block--previous')).toBeNull();
294
+ expect(
295
+ container.querySelector('.doc-player__block--active [data-block-id="second"]'),
296
+ ).toBeTruthy();
297
+ expect(container.querySelector('.transition-fade-enter')).toBeNull();
298
+ expect(container.querySelector('[class*="anim-"]')).toBeNull();
299
+ });
300
+
301
+ it('keeps a swiped-to slide entrance transition without remounting the swiped-away slide', async () => {
302
+ const doc = docWithThreeSlides();
303
+ const secondSlideController = controller({ currentTime: 5, totalDuration: 15 });
304
+ const { container, rerender } = render(
305
+ <DocPlayer
306
+ doc={doc}
307
+ audioController={secondSlideController}
308
+ displayMode="slideshow"
309
+ showControls={false}
310
+ />,
311
+ );
312
+ const player = container.querySelector<HTMLElement>('.doc-player');
313
+ expect(player).not.toBeNull();
314
+ vi.spyOn(player!, 'getBoundingClientRect').mockReturnValue({
315
+ width: 1000,
316
+ height: 600,
317
+ x: 0,
318
+ y: 0,
319
+ top: 0,
320
+ right: 1000,
321
+ bottom: 600,
322
+ left: 0,
323
+ toJSON: () => ({}),
324
+ });
325
+
326
+ fireEvent(player!, pointerEvent('pointerdown', 800));
327
+ expect(player!.classList.contains('doc-player--grabbing')).toBe(true);
328
+ fireEvent(window, pointerEvent('pointermove', 400));
329
+ expect(
330
+ container.querySelector<HTMLElement>('.doc-player__block--active')?.style.transform,
331
+ ).toBe('translateX(-400px)');
332
+ fireEvent(window, pointerEvent('pointerup', 400));
333
+
334
+ await waitFor(() => expect(secondSlideController.seekTo).toHaveBeenCalledWith(10), {
335
+ timeout: 1000,
336
+ });
337
+ rerender(
338
+ <DocPlayer
339
+ doc={doc}
340
+ audioController={controller({ currentTime: 10, totalDuration: 15 })}
341
+ displayMode="slideshow"
342
+ showControls={false}
343
+ />,
344
+ );
345
+
346
+ expect(
347
+ container.querySelector(
348
+ '.doc-player__block--active [data-block-id="third"].transition-fade-enter',
349
+ ),
350
+ ).toBeTruthy();
351
+ expect(container.querySelector('.doc-player__block--previous')).toBeNull();
352
+ });
353
+
354
+ it('keeps outgoing slide context for ordinary slideshow button navigation', () => {
355
+ const doc = docWithThreeSlides();
356
+ const secondSlideController = controller({ currentTime: 5, totalDuration: 15 });
357
+ const { container, rerender } = render(
358
+ <DocPlayer doc={doc} audioController={secondSlideController} displayMode="slideshow" />,
359
+ );
360
+
361
+ fireEvent.click(screen.getByTestId('slide-next'));
362
+ expect(secondSlideController.seekTo).toHaveBeenCalledWith(10);
363
+ rerender(
364
+ <DocPlayer
365
+ doc={doc}
366
+ audioController={controller({ currentTime: 10, totalDuration: 15 })}
367
+ displayMode="slideshow"
368
+ />,
369
+ );
370
+
371
+ expect(
372
+ container.querySelector('.doc-player__block--previous [data-block-id="second"]'),
373
+ ).toBeTruthy();
374
+ expect(
375
+ container.querySelector(
376
+ '.doc-player__block--active [data-block-id="third"].transition-fade-enter',
377
+ ),
378
+ ).toBeTruthy();
379
+ });
380
+
381
+ it('advances from slideshow cover to slide 1', async () => {
382
+ const { container } = render(
383
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
384
+ );
385
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('Cover'));
386
+ fireEvent.click(screen.getByTestId('slide-next'));
387
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1'));
388
+ expect(container.textContent).not.toContain('Managed Cover');
389
+ });
390
+
391
+ it('accepts controlled cover visibility for a synchronized audience mirror', async () => {
392
+ const states: Array<{ isCoverVisible?: boolean }> = [];
393
+ const { container, rerender } = render(
394
+ <DocPlayer
395
+ doc={docWithCover()}
396
+ audioController={controller()}
397
+ displayMode="slideshow"
398
+ showControls={false}
399
+ coverVisible={false}
400
+ onPlaybackStateChange={(state) => states.push(state)}
401
+ />,
402
+ );
403
+
404
+ await waitFor(() => expect(container.textContent).not.toContain('Managed Cover'));
405
+ expect(states[states.length - 1]?.isCoverVisible).toBe(false);
406
+ expect(screen.queryByTestId('slide-counter')).toBeNull();
407
+
408
+ rerender(
409
+ <DocPlayer
410
+ doc={docWithCover()}
411
+ audioController={controller()}
412
+ displayMode="slideshow"
413
+ showControls={false}
414
+ coverVisible
415
+ onPlaybackStateChange={(state) => states.push(state)}
416
+ />,
417
+ );
418
+
419
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
420
+ expect(states[states.length - 1]?.isCoverVisible).toBe(true);
421
+ });
422
+
423
+ it('reinitializes cover state when a new document replaces the old one', async () => {
424
+ const { container, rerender } = render(
425
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
426
+ );
427
+ fireEvent.click(screen.getByTestId('slide-next'));
428
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1'));
429
+ rerender(
430
+ <DocPlayer
431
+ doc={{
432
+ ...docWithCover(),
433
+ articleId: 'replacement',
434
+ startBlock: { title: 'Replacement Cover' },
435
+ }}
436
+ basePath="/test"
437
+ displayMode="slideshow"
438
+ />,
439
+ );
440
+ await waitFor(() => expect(container.textContent).toContain('Replacement Cover'));
441
+ expect(screen.getByTestId('slide-counter').textContent).toBe('Cover');
442
+ });
443
+
28
444
  it('renders without crashing in linear mode', () => {
29
445
  const { container } = render(
30
446
  <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
@@ -48,6 +464,144 @@ describe('DocPlayer smoke test', () => {
48
464
  expect(typeof controls!.play).toBe('function');
49
465
  expect(typeof controls!.pause).toBe('function');
50
466
  });
467
+
468
+ it('handles keyboard shortcuts only in the focused player instance', () => {
469
+ const first = controller();
470
+ const second = controller();
471
+ const { container } = render(
472
+ <>
473
+ <DocPlayer doc={minimalDoc()} audioController={first} showControls={false} />
474
+ <DocPlayer doc={minimalDoc()} audioController={second} showControls={false} />
475
+ </>,
476
+ );
477
+ const players = container.querySelectorAll<HTMLElement>('.doc-player');
478
+ players[1].focus();
479
+ fireEvent.keyDown(players[1], { key: ' ' });
480
+ expect(second.toggle).toHaveBeenCalledOnce();
481
+ expect(first.toggle).not.toHaveBeenCalled();
482
+
483
+ const editable = document.createElement('div');
484
+ editable.setAttribute('contenteditable', 'true');
485
+ players[1].append(editable);
486
+ fireEvent.keyDown(editable, { key: ' ' });
487
+ expect(second.toggle).toHaveBeenCalledOnce();
488
+ });
489
+
490
+ it('keeps render APIs scoped to their player callbacks during sibling cleanup', () => {
491
+ const firstEvents: Array<SquisqRenderAPI | null> = [];
492
+ const secondEvents: Array<SquisqRenderAPI | null> = [];
493
+ const one = render(
494
+ <DocPlayer
495
+ doc={minimalDoc()}
496
+ renderMode
497
+ audioController={controller()}
498
+ onRenderAPIReady={(api) => firstEvents.push(api)}
499
+ />,
500
+ );
501
+ render(
502
+ <DocPlayer
503
+ doc={minimalDoc()}
504
+ renderMode
505
+ audioController={controller()}
506
+ onRenderAPIReady={(api) => secondEvents.push(api)}
507
+ />,
508
+ );
509
+ const secondAPI = secondEvents[secondEvents.length - 1];
510
+ expect(firstEvents[firstEvents.length - 1]).not.toBeNull();
511
+ expect(secondAPI).not.toBeNull();
512
+
513
+ one.unmount();
514
+
515
+ expect(firstEvents[firstEvents.length - 1]).toBeNull();
516
+ expect(secondEvents[secondEvents.length - 1]).toBe(secondAPI);
517
+ expect(typeof secondAPI?.seekTo).toBe('function');
518
+ expect('seekTo' in window).toBe(false);
519
+ expect('getDuration' in window).toBe(false);
520
+ expect('squisqActivePlayerId' in window).toBe(false);
521
+ expect('squisqPlayers' in window).toBe(false);
522
+ });
523
+
524
+ it('delivers and cleans up an instance render API through the callback', () => {
525
+ const observed: Array<SquisqRenderAPI | null> = [];
526
+ const { unmount } = render(
527
+ <DocPlayer
528
+ doc={minimalDoc()}
529
+ renderMode
530
+ audioController={controller()}
531
+ onRenderAPIReady={(api) => observed.push(api)}
532
+ />,
533
+ );
534
+
535
+ expect(observed[observed.length - 1]?.getDuration()).toBeGreaterThan(0);
536
+ unmount();
537
+ expect(observed[observed.length - 1]).toBeNull();
538
+ });
539
+
540
+ it('keeps the render API identity stable while dispatching to the latest controller', async () => {
541
+ const firstController = controller({ currentSegment: 0 });
542
+ const secondController = controller({ currentSegment: 1 });
543
+ const observed: Array<SquisqRenderAPI | null> = [];
544
+ const onRenderAPIReady = (api: SquisqRenderAPI | null) => observed.push(api);
545
+ const { rerender } = render(
546
+ <DocPlayer
547
+ doc={minimalDoc()}
548
+ renderMode
549
+ audioController={firstController}
550
+ onRenderAPIReady={onRenderAPIReady}
551
+ />,
552
+ );
553
+ const api = observed[observed.length - 1];
554
+ expect(api).not.toBeNull();
555
+
556
+ rerender(
557
+ <DocPlayer
558
+ doc={minimalDoc()}
559
+ renderMode
560
+ audioController={secondController}
561
+ onRenderAPIReady={onRenderAPIReady}
562
+ />,
563
+ );
564
+
565
+ expect(observed[observed.length - 1]).toBe(api);
566
+ await api!.seekTo(3.25);
567
+ expect(secondController.seekTo).toHaveBeenCalledWith(3.25);
568
+ expect(firstController.seekTo).not.toHaveBeenCalled();
569
+ });
570
+
571
+ it('refreshes instance render metadata when the doc prop changes', () => {
572
+ const firstDoc: Doc = { ...minimalDoc(), captions: { version: 1, phrases: [] } };
573
+ const observed: Array<SquisqRenderAPI | null> = [];
574
+ const onRenderAPIReady = (next: SquisqRenderAPI | null) => {
575
+ observed.push(next);
576
+ };
577
+ const { container, rerender } = render(
578
+ <DocPlayer
579
+ doc={firstDoc}
580
+ renderMode
581
+ audioController={controller()}
582
+ onRenderAPIReady={onRenderAPIReady}
583
+ />,
584
+ );
585
+ expect(container.querySelector('.doc-player')).toBeTruthy();
586
+ const nextDoc: Doc = {
587
+ ...minimalDoc(),
588
+ captions: {
589
+ version: 1,
590
+ phrases: [{ text: 'new caption', startTime: 0, endTime: 1, audioSegment: 0 }],
591
+ },
592
+ };
593
+ rerender(
594
+ <DocPlayer
595
+ doc={nextDoc}
596
+ renderMode
597
+ audioController={controller()}
598
+ onRenderAPIReady={onRenderAPIReady}
599
+ />,
600
+ );
601
+ expect(observed[observed.length - 1]?.getCaptions()).toEqual([
602
+ { text: 'new caption', startTime: 0, endTime: 1 },
603
+ ]);
604
+ });
51
605
  });
52
606
 
53
607
  describe('DocPlayer front door (doc / markdown resolution)', () => {
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render } from '@testing-library/react';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
3
  import { DocProgressBar } from '../DocProgressBar';
4
4
  import type { PlaybackState, PlaybackActions } from '../types';
5
5
 
@@ -73,4 +73,30 @@ describe('DocProgressBar fill', () => {
73
73
  );
74
74
  expect(fillWidth(container)).toBe('100%');
75
75
  });
76
+
77
+ it('exposes keyboard seeking and accessible marker buttons', () => {
78
+ const seekTo = vi.fn();
79
+ render(
80
+ <DocProgressBar
81
+ state={makeState({ currentTime: 12, totalDuration: 23 })}
82
+ actions={{ ...actions, seekTo }}
83
+ blockMarkers={[
84
+ {
85
+ block: { id: 'b', startTime: 8, duration: 5, audioSegment: 0, layers: [] },
86
+ index: 0,
87
+ position: 35,
88
+ title: 'Chapter two',
89
+ isSectionStart: false,
90
+ },
91
+ ]}
92
+ expandedBlocks={[]}
93
+ />,
94
+ );
95
+ const slider = screen.getByRole('slider', { name: 'Playback position' });
96
+ expect(slider.getAttribute('aria-valuenow')).toBe('12');
97
+ fireEvent.keyDown(slider, { key: 'ArrowRight' });
98
+ expect(seekTo).toHaveBeenCalledWith(17);
99
+ fireEvent.click(screen.getByRole('button', { name: 'Seek to Chapter two' }));
100
+ expect(seekTo).toHaveBeenLastCalledWith(8);
101
+ });
76
102
  });