@bendyline/squisq-react 1.4.2 → 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.
- package/README.md +30 -3
- package/dist/index.d.ts +174 -27
- package/dist/index.js +1244 -603
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.global.js +54 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +53 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +135 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +79 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +91 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +3 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +4 -2
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- package/src/types.ts +13 -13
|
@@ -2,6 +2,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
2
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 {
|
|
@@ -22,6 +24,81 @@ function docWithCover(): Doc {
|
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
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
|
+
|
|
25
102
|
describe('DocPlayer smoke test', () => {
|
|
26
103
|
beforeAll(() => {
|
|
27
104
|
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
|
@@ -43,6 +120,116 @@ describe('DocPlayer smoke test', () => {
|
|
|
43
120
|
expect(container.firstChild).toBeTruthy();
|
|
44
121
|
});
|
|
45
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
|
+
|
|
46
233
|
it('shows the managed cover as the first slideshow entry by default', async () => {
|
|
47
234
|
const { container } = render(
|
|
48
235
|
<DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
|
|
@@ -64,6 +251,133 @@ describe('DocPlayer smoke test', () => {
|
|
|
64
251
|
expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1');
|
|
65
252
|
});
|
|
66
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
|
+
|
|
67
381
|
it('advances from slideshow cover to slide 1', async () => {
|
|
68
382
|
const { container } = render(
|
|
69
383
|
<DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
|
|
@@ -74,6 +388,59 @@ describe('DocPlayer smoke test', () => {
|
|
|
74
388
|
expect(container.textContent).not.toContain('Managed Cover');
|
|
75
389
|
});
|
|
76
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
|
+
|
|
77
444
|
it('renders without crashing in linear mode', () => {
|
|
78
445
|
const { container } = render(
|
|
79
446
|
<DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
|
|
@@ -97,6 +464,144 @@ describe('DocPlayer smoke test', () => {
|
|
|
97
464
|
expect(typeof controls!.play).toBe('function');
|
|
98
465
|
expect(typeof controls!.pause).toBe('function');
|
|
99
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
|
+
});
|
|
100
605
|
});
|
|
101
606
|
|
|
102
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
|
});
|