@bendyline/squisq-react 2.0.1 → 2.2.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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
@@ -1,635 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
- import { DocPlayer } from '../DocPlayer';
4
- import type { Doc } from '@bendyline/squisq/schemas';
5
- import type { AudioController } from '../hooks/AudioController';
6
- import type { SquisqRenderAPI } from '../types';
7
-
8
- function minimalDoc(): Doc {
9
- return {
10
- articleId: 'smoke',
11
- duration: 5,
12
- blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
13
- audio: { segments: [] },
14
- };
15
- }
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
-
102
- describe('DocPlayer smoke test', () => {
103
- beforeAll(() => {
104
- vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
105
- });
106
-
107
- afterAll(() => {
108
- vi.restoreAllMocks();
109
- });
110
-
111
- it('renders without crashing in video mode (default)', () => {
112
- const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
113
- expect(container.firstChild).toBeTruthy();
114
- });
115
-
116
- it('renders without crashing in slideshow mode', () => {
117
- const { container } = render(
118
- <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="slideshow" />,
119
- );
120
- expect(container.firstChild).toBeTruthy();
121
- });
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
-
444
- it('renders without crashing in linear mode', () => {
445
- const { container } = render(
446
- <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
447
- );
448
- expect(container.firstChild).toBeTruthy();
449
- });
450
-
451
- it('exposes playback controls via onControlsReady', () => {
452
- let controls: { play: () => void; pause: () => void } | null = null;
453
- render(
454
- <DocPlayer
455
- doc={minimalDoc()}
456
- basePath="/test"
457
- showControls={false}
458
- onControlsReady={(c) => {
459
- controls = c;
460
- }}
461
- />,
462
- );
463
- expect(controls).not.toBeNull();
464
- expect(typeof controls!.play).toBe('function');
465
- expect(typeof controls!.pause).toBe('function');
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
- });
605
- });
606
-
607
- describe('DocPlayer front door (doc / markdown resolution)', () => {
608
- it('renders a doc built from the markdown prop', () => {
609
- const { container } = render(
610
- <DocPlayer markdown={'# Hello From Markdown\n\nBody text here.'} displayMode="linear" />,
611
- );
612
- expect(container.querySelector('.doc-player')).toBeTruthy();
613
- expect(container.textContent).toContain('Hello From Markdown');
614
- });
615
-
616
- it('doc wins over markdown when both are provided', () => {
617
- const { container } = render(
618
- <DocPlayer doc={minimalDoc()} markdown="# Markdown Loses" displayMode="linear" />,
619
- );
620
- expect(container.querySelector('.doc-player')).toBeTruthy();
621
- expect(container.textContent).not.toContain('Markdown Loses');
622
- });
623
-
624
- it('renders an empty state without throwing when neither doc nor markdown is given', () => {
625
- const { container } = render(<DocPlayer />);
626
- const empty = container.querySelector('.doc-player--empty');
627
- expect(empty).toBeTruthy();
628
- expect(empty!.classList.contains('doc-player')).toBe(true);
629
- });
630
-
631
- it('defaults basePath when omitted', () => {
632
- const { container } = render(<DocPlayer doc={minimalDoc()} />);
633
- expect(container.querySelector('.doc-player')).toBeTruthy();
634
- });
635
- });
@@ -1,41 +0,0 @@
1
- /**
2
- * Missing-stylesheet sentinel test.
3
- *
4
- * Lives in its own file so it owns a fresh module instance of DocPlayer
5
- * (vitest isolates module registries per test file) — the sentinel warning
6
- * is one-shot at module level, so this file's first mount deterministically
7
- * observes it.
8
- */
9
-
10
- import { describe, it, expect, vi, afterEach } from 'vitest';
11
- import { render } from '@testing-library/react';
12
- import { DocPlayer } from '../DocPlayer';
13
- import type { Doc } from '@bendyline/squisq/schemas';
14
-
15
- function minimalDoc(): Doc {
16
- return {
17
- articleId: 'sentinel',
18
- duration: 5,
19
- blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
20
- audio: { segments: [] },
21
- };
22
- }
23
-
24
- afterEach(() => {
25
- vi.restoreAllMocks();
26
- });
27
-
28
- describe('DocPlayer missing-CSS sentinel', () => {
29
- it('warns once (and only once) in dev when the stylesheet is not loaded', () => {
30
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
31
-
32
- render(<DocPlayer doc={minimalDoc()} />);
33
- const sentinelCalls = () =>
34
- warnSpy.mock.calls.filter((c) => String(c[0]).includes('squisq-react/styles'));
35
- expect(sentinelCalls().length).toBe(1);
36
-
37
- // Second mount must not warn again — module-level one-shot.
38
- render(<DocPlayer doc={minimalDoc()} />);
39
- expect(sentinelCalls().length).toBe(1);
40
- });
41
- });