@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
3
|
import { LinearDocView } from '../LinearDocView';
|
|
4
4
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
5
5
|
import { DARK_SURFACE, DEFAULT_THEME, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
|
|
@@ -58,6 +58,20 @@ describe('LinearDocView', () => {
|
|
|
58
58
|
expect((el as HTMLElement).style.overflowY).toBe('auto');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it('globally scrolls with up and down arrows when enabled', () => {
|
|
62
|
+
const doc = mkDoc([mkBlock({ contents: [paragraph(text('Scrollable body'))] })]);
|
|
63
|
+
const { container } = render(<LinearDocView doc={doc} globalKeyboardShortcuts />);
|
|
64
|
+
const scroller = container.querySelector<HTMLElement>('.squisq-linear')!;
|
|
65
|
+
const scrollBy = vi.fn();
|
|
66
|
+
Object.defineProperty(scroller, 'scrollBy', { configurable: true, value: scrollBy });
|
|
67
|
+
|
|
68
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' });
|
|
69
|
+
fireEvent.keyDown(document, { key: 'ArrowUp' });
|
|
70
|
+
|
|
71
|
+
expect(scrollBy).toHaveBeenNthCalledWith(1, { top: 64, behavior: 'smooth' });
|
|
72
|
+
expect(scrollBy).toHaveBeenNthCalledWith(2, { top: -64, behavior: 'smooth' });
|
|
73
|
+
});
|
|
74
|
+
|
|
61
75
|
it('renders preamble content (no heading)', () => {
|
|
62
76
|
const doc = mkDoc([
|
|
63
77
|
mkBlock({
|
|
@@ -176,6 +190,24 @@ describe('LinearDocView', () => {
|
|
|
176
190
|
expect(svg).toBeTruthy();
|
|
177
191
|
});
|
|
178
192
|
|
|
193
|
+
it('renders transform-generated template blocks without authoring nodes', () => {
|
|
194
|
+
const doc = mkDoc([
|
|
195
|
+
mkBlock({
|
|
196
|
+
id: 'transform-stat',
|
|
197
|
+
template: 'statHighlight',
|
|
198
|
+
stat: '42%',
|
|
199
|
+
description: 'Year-over-year growth',
|
|
200
|
+
} as Partial<Block>),
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
204
|
+
const section = container.querySelector('[data-block-id="transform-stat"]');
|
|
205
|
+
expect(section?.getAttribute('data-template')).toBe('statHighlight');
|
|
206
|
+
expect(section?.querySelector('.squisq-linear-card svg')).toBeTruthy();
|
|
207
|
+
expect(section?.textContent).toContain('42%');
|
|
208
|
+
expect(section?.textContent).toContain('Year-over-year growth');
|
|
209
|
+
});
|
|
210
|
+
|
|
179
211
|
it('renders children recursively', () => {
|
|
180
212
|
const doc = mkDoc([
|
|
181
213
|
mkBlock({
|
|
@@ -198,6 +230,22 @@ describe('LinearDocView', () => {
|
|
|
198
230
|
expect(container.textContent).toContain('Child body');
|
|
199
231
|
});
|
|
200
232
|
|
|
233
|
+
it('assigns unique pre-order indices across nested and top-level blocks', () => {
|
|
234
|
+
const doc = mkDoc([
|
|
235
|
+
mkBlock({
|
|
236
|
+
id: 'parent',
|
|
237
|
+
children: [mkBlock({ id: 'child' })],
|
|
238
|
+
}),
|
|
239
|
+
mkBlock({ id: 'sibling' }),
|
|
240
|
+
]);
|
|
241
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
242
|
+
expect(
|
|
243
|
+
Array.from(container.querySelectorAll('.squisq-linear-section')).map((node) =>
|
|
244
|
+
node.getAttribute('data-block-index'),
|
|
245
|
+
),
|
|
246
|
+
).toEqual(['0', '1', '2']);
|
|
247
|
+
});
|
|
248
|
+
|
|
201
249
|
it('renders multiple top-level blocks', () => {
|
|
202
250
|
const doc = mkDoc([
|
|
203
251
|
mkBlock({
|
|
@@ -301,7 +349,7 @@ describe('LinearDocView markdown prop', () => {
|
|
|
301
349
|
});
|
|
302
350
|
|
|
303
351
|
describe('LinearDocView unknown template annotations', () => {
|
|
304
|
-
it('
|
|
352
|
+
it('renders the canonical visible fallback without hidden console output', () => {
|
|
305
353
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
306
354
|
const doc = mkDoc([
|
|
307
355
|
mkBlock({
|
|
@@ -317,17 +365,49 @@ describe('LinearDocView unknown template annotations', () => {
|
|
|
317
365
|
]);
|
|
318
366
|
|
|
319
367
|
const { container } = render(<LinearDocView doc={doc} />);
|
|
320
|
-
// Renders as plain markdown: heading + body, no SVG card.
|
|
321
368
|
expect(container.textContent).toContain('Mystery Section');
|
|
322
369
|
expect(container.textContent).toContain('Fallback body content');
|
|
323
|
-
expect(container.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
render(<LinearDocView doc={doc} />);
|
|
327
|
-
const sentinelCalls = warnSpy.mock.calls.filter((c) =>
|
|
328
|
-
String(c[0]).includes('no-such-template-xyz'),
|
|
329
|
-
);
|
|
330
|
-
expect(sentinelCalls.length).toBe(1);
|
|
370
|
+
expect(container.textContent).toContain('Unknown template "no-such-template-xyz"');
|
|
371
|
+
expect(container.querySelector('.squisq-linear-card')).not.toBeNull();
|
|
372
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
331
373
|
warnSpy.mockRestore();
|
|
332
374
|
});
|
|
333
375
|
});
|
|
376
|
+
|
|
377
|
+
describe('LinearDocView custom template materialization', () => {
|
|
378
|
+
it('renders document-scoped templates through the canonical API', () => {
|
|
379
|
+
const doc: Doc = {
|
|
380
|
+
...mkDoc([
|
|
381
|
+
mkBlock({
|
|
382
|
+
id: 'custom-1',
|
|
383
|
+
sourceHeading: {
|
|
384
|
+
type: 'heading',
|
|
385
|
+
depth: 2,
|
|
386
|
+
children: [text('Custom Hero')],
|
|
387
|
+
templateAnnotation: { template: 'hero' },
|
|
388
|
+
},
|
|
389
|
+
contents: [paragraph(text('Custom body'))],
|
|
390
|
+
}),
|
|
391
|
+
]),
|
|
392
|
+
customTemplates: [
|
|
393
|
+
{
|
|
394
|
+
name: 'hero',
|
|
395
|
+
label: 'Hero',
|
|
396
|
+
viewport: { width: 1920, height: 1080 },
|
|
397
|
+
layers: [
|
|
398
|
+
{
|
|
399
|
+
id: 'hero-title',
|
|
400
|
+
type: 'text',
|
|
401
|
+
position: { x: '5%', y: '10%', width: '90%' },
|
|
402
|
+
content: { text: '{title}: {content}', style: { fontSize: 48, color: '#000000' } },
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
410
|
+
expect(container.textContent).toContain('Custom Hero: Custom body');
|
|
411
|
+
expect(container.textContent).not.toContain('Unknown template');
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { MapLayer as MapLayerSchema } from '@bendyline/squisq/schemas';
|
|
4
|
+
|
|
5
|
+
const { composeMapImage } = vi.hoisted(() => ({
|
|
6
|
+
composeMapImage: vi.fn(async () => 'data:image/png;base64,map'),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../utils/mapTileUtils', () => ({ composeMapImage }));
|
|
9
|
+
|
|
10
|
+
import { MapLayer } from '../layers/MapLayer';
|
|
11
|
+
|
|
12
|
+
function layer(
|
|
13
|
+
markers: MapLayerSchema['content']['markers'],
|
|
14
|
+
showAttribution = true,
|
|
15
|
+
): MapLayerSchema {
|
|
16
|
+
return {
|
|
17
|
+
id: 'map',
|
|
18
|
+
type: 'map',
|
|
19
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
20
|
+
content: {
|
|
21
|
+
center: { lat: 1, lng: 2 },
|
|
22
|
+
zoom: 4,
|
|
23
|
+
style: 'road',
|
|
24
|
+
markers,
|
|
25
|
+
showAttribution,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('MapLayer dependencies', () => {
|
|
31
|
+
beforeEach(() => composeMapImage.mockClear());
|
|
32
|
+
|
|
33
|
+
it('recomposes when markers or attribution change', async () => {
|
|
34
|
+
const firstMarkers = [{ lat: 1, lng: 2, label: 'A' }];
|
|
35
|
+
const { rerender } = render(
|
|
36
|
+
<svg>
|
|
37
|
+
<MapLayer
|
|
38
|
+
layer={layer(firstMarkers)}
|
|
39
|
+
basePath="."
|
|
40
|
+
viewport={{ width: 100, height: 100 }}
|
|
41
|
+
blockTime={0}
|
|
42
|
+
/>
|
|
43
|
+
</svg>,
|
|
44
|
+
);
|
|
45
|
+
await waitFor(() => expect(composeMapImage).toHaveBeenCalledTimes(1));
|
|
46
|
+
|
|
47
|
+
const secondMarkers = [{ lat: 3, lng: 4, label: 'B' }];
|
|
48
|
+
rerender(
|
|
49
|
+
<svg>
|
|
50
|
+
<MapLayer
|
|
51
|
+
layer={layer(secondMarkers, false)}
|
|
52
|
+
basePath="."
|
|
53
|
+
viewport={{ width: 100, height: 100 }}
|
|
54
|
+
blockTime={0}
|
|
55
|
+
/>
|
|
56
|
+
</svg>,
|
|
57
|
+
);
|
|
58
|
+
await waitFor(() => expect(composeMapImage).toHaveBeenCalledTimes(2));
|
|
59
|
+
expect(composeMapImage).toHaveBeenLastCalledWith(
|
|
60
|
+
expect.objectContaining({ markers: secondMarkers, showAttribution: false }),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -289,7 +289,7 @@ describe('MarkdownRenderer', () => {
|
|
|
289
289
|
expect(container.querySelector('span')).toBeNull();
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
it('preserves
|
|
292
|
+
it('preserves trusted HTML structure without executable attributes', () => {
|
|
293
293
|
const { container } = render(
|
|
294
294
|
<MarkdownRenderer
|
|
295
295
|
nodes={parseNodes('<div><span class="trusted" onclick="alert(1)">ok</span></div>')}
|
|
@@ -297,7 +297,18 @@ describe('MarkdownRenderer', () => {
|
|
|
297
297
|
/>,
|
|
298
298
|
);
|
|
299
299
|
const span = container.querySelector('span.trusted');
|
|
300
|
-
expect(span?.getAttribute('onclick')).
|
|
300
|
+
expect(span?.getAttribute('onclick')).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('blocks executable URLs under the trusted policy', () => {
|
|
304
|
+
const { container } = render(
|
|
305
|
+
<MarkdownRenderer
|
|
306
|
+
nodes={parseNodes('<a href="javascript:alert(1)">bad</a><img src="javascript:x">')}
|
|
307
|
+
htmlPolicy="trusted"
|
|
308
|
+
/>,
|
|
309
|
+
);
|
|
310
|
+
expect(container.querySelector('a')?.getAttribute('href')).toBeNull();
|
|
311
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBeNull();
|
|
301
312
|
});
|
|
302
313
|
|
|
303
314
|
// Host-affecting tags (style/script/…) must never reach the document.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { act, render, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { MediaProvider, ScheduledClip } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { MediaClipLayer } from '../MediaClipLayer';
|
|
5
|
+
import { MediaContext } from '../hooks/MediaContext';
|
|
6
|
+
|
|
7
|
+
afterEach(() => vi.restoreAllMocks());
|
|
8
|
+
|
|
9
|
+
describe('MediaClipLayer', () => {
|
|
10
|
+
it('mutes scheduled audio when the player muted contract is enabled', () => {
|
|
11
|
+
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
|
12
|
+
const clip: ScheduledClip = {
|
|
13
|
+
id: 'narration',
|
|
14
|
+
kind: 'audio',
|
|
15
|
+
src: 'narration.mp3',
|
|
16
|
+
absoluteStart: 0,
|
|
17
|
+
absoluteEnd: 5,
|
|
18
|
+
sourceIn: 0,
|
|
19
|
+
anchor: 'document',
|
|
20
|
+
};
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<MediaClipLayer schedule={[clip]} currentTime={0} isPlaying={false} basePath="." muted />,
|
|
23
|
+
);
|
|
24
|
+
expect(container.querySelector('audio')?.muted).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('restores a paused clip position after an async media URL resolves', async () => {
|
|
28
|
+
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
|
29
|
+
let resolveUrl!: (url: string) => void;
|
|
30
|
+
const provider = {
|
|
31
|
+
resolveUrl: vi.fn(
|
|
32
|
+
() =>
|
|
33
|
+
new Promise<string>((resolve) => {
|
|
34
|
+
resolveUrl = resolve;
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
} as unknown as MediaProvider;
|
|
38
|
+
const clip: ScheduledClip = {
|
|
39
|
+
id: 'background-video',
|
|
40
|
+
kind: 'video',
|
|
41
|
+
src: 'background.mp4',
|
|
42
|
+
absoluteStart: 0,
|
|
43
|
+
absoluteEnd: 10,
|
|
44
|
+
sourceIn: 2,
|
|
45
|
+
anchor: 'document',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<MediaContext.Provider value={provider}>
|
|
50
|
+
<MediaClipLayer
|
|
51
|
+
schedule={[clip]}
|
|
52
|
+
currentTime={5}
|
|
53
|
+
isPlaying={false}
|
|
54
|
+
basePath="/media"
|
|
55
|
+
muted
|
|
56
|
+
/>
|
|
57
|
+
</MediaContext.Provider>,
|
|
58
|
+
);
|
|
59
|
+
const video = container.querySelector('video')!;
|
|
60
|
+
await waitFor(() => expect(video.currentTime).toBe(7));
|
|
61
|
+
|
|
62
|
+
// Replacing an HTMLMediaElement source resets its playback position in a
|
|
63
|
+
// browser. Simulate that reset before the provider's blob URL arrives.
|
|
64
|
+
video.currentTime = 0;
|
|
65
|
+
await act(async () => resolveUrl('blob:resolved-background'));
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(video.getAttribute('src')).toBe('blob:resolved-background'));
|
|
68
|
+
expect(video.currentTime).toBe(7);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { MediaContext, useMediaUrl } from '../hooks/MediaContext';
|
|
5
|
+
|
|
6
|
+
function deferred<T>() {
|
|
7
|
+
let resolve!: (value: T) => void;
|
|
8
|
+
let reject!: (reason?: unknown) => void;
|
|
9
|
+
const promise = new Promise<T>((res, rej) => {
|
|
10
|
+
resolve = res;
|
|
11
|
+
reject = rej;
|
|
12
|
+
});
|
|
13
|
+
return { promise, resolve, reject };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Probe({ path }: { path: string }) {
|
|
17
|
+
return <span data-testid="url">{useMediaUrl(path, '.')}</span>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useMediaUrl', () => {
|
|
21
|
+
it('clears a stale URL and consumes provider rejection', async () => {
|
|
22
|
+
const first = deferred<string>();
|
|
23
|
+
const second = deferred<string>();
|
|
24
|
+
const resolveUrl = vi
|
|
25
|
+
.fn()
|
|
26
|
+
.mockReturnValueOnce(first.promise)
|
|
27
|
+
.mockReturnValueOnce(second.promise);
|
|
28
|
+
const provider = { resolveUrl } as unknown as MediaProvider;
|
|
29
|
+
const { rerender } = render(
|
|
30
|
+
<MediaContext.Provider value={provider}>
|
|
31
|
+
<Probe path="first.png" />
|
|
32
|
+
</MediaContext.Provider>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByTestId('url').textContent).toBe('./first.png');
|
|
35
|
+
|
|
36
|
+
rerender(
|
|
37
|
+
<MediaContext.Provider value={provider}>
|
|
38
|
+
<Probe path="second.png" />
|
|
39
|
+
</MediaContext.Provider>,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByTestId('url').textContent).toBe('./second.png');
|
|
42
|
+
|
|
43
|
+
await act(async () => {
|
|
44
|
+
first.resolve('blob:first');
|
|
45
|
+
await first.promise;
|
|
46
|
+
second.reject(new Error('missing'));
|
|
47
|
+
await second.promise.catch(() => undefined);
|
|
48
|
+
});
|
|
49
|
+
expect(screen.getByTestId('url').textContent).toBe('./second.png');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -11,7 +11,7 @@ function renderPath(layer: PathLayerType) {
|
|
|
11
11
|
<PathLayer layer={layer} viewport={viewport} blockTime={0} />
|
|
12
12
|
</svg>,
|
|
13
13
|
);
|
|
14
|
-
return container.querySelector('path')!;
|
|
14
|
+
return container.querySelector('.block-layer--path > path')!;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
describe('PathLayer', () => {
|
|
@@ -70,4 +70,15 @@ describe('PathLayer', () => {
|
|
|
70
70
|
});
|
|
71
71
|
expect(path.getAttribute('d')).toBe('M 1 2 L 3 4');
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it('reads the old serialized arrow field without exposing it in the PathLayer type', () => {
|
|
75
|
+
const legacyLayer = {
|
|
76
|
+
id: 'legacy-arrow',
|
|
77
|
+
type: 'path',
|
|
78
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
79
|
+
content: { d: 'M 0 0 L 100 100', arrow: 'end' },
|
|
80
|
+
} as unknown as PathLayerType;
|
|
81
|
+
|
|
82
|
+
expect(renderPath(legacyLayer).getAttribute('marker-end')).toMatch(/^url\(#marker-end-/);
|
|
83
|
+
});
|
|
73
84
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
5
|
+
import type { VideoLayer as VideoLayerType } from '@bendyline/squisq/schemas';
|
|
6
|
+
import { VideoLayer } from '../layers/VideoLayer';
|
|
7
|
+
|
|
8
|
+
const layer: VideoLayerType = {
|
|
9
|
+
id: 'video',
|
|
10
|
+
type: 'video',
|
|
11
|
+
position: { x: 0, y: 0, width: 640, height: 360 },
|
|
12
|
+
content: {
|
|
13
|
+
src: 'clip.mp4',
|
|
14
|
+
alt: 'Demo clip',
|
|
15
|
+
clipStart: 2,
|
|
16
|
+
clipEnd: 8,
|
|
17
|
+
startAt: 1,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
|
23
|
+
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('VideoLayer playback synchronization', () => {
|
|
32
|
+
it('joins the document clock when mounted partway through a block', async () => {
|
|
33
|
+
const { container, rerender } = render(
|
|
34
|
+
<svg>
|
|
35
|
+
<VideoLayer
|
|
36
|
+
layer={layer}
|
|
37
|
+
basePath="/media"
|
|
38
|
+
viewport={{ width: 640, height: 360 }}
|
|
39
|
+
blockTime={4}
|
|
40
|
+
isPlaying
|
|
41
|
+
/>
|
|
42
|
+
</svg>,
|
|
43
|
+
);
|
|
44
|
+
const video = container.querySelector('video')!;
|
|
45
|
+
|
|
46
|
+
// clipStart 2 + (blockTime 4 - startAt 1) = source time 5.
|
|
47
|
+
await waitFor(() => expect(video.currentTime).toBe(5));
|
|
48
|
+
|
|
49
|
+
rerender(
|
|
50
|
+
<svg>
|
|
51
|
+
<VideoLayer
|
|
52
|
+
layer={layer}
|
|
53
|
+
basePath="/media"
|
|
54
|
+
viewport={{ width: 640, height: 360 }}
|
|
55
|
+
blockTime={6}
|
|
56
|
+
isPlaying
|
|
57
|
+
/>
|
|
58
|
+
</svg>,
|
|
59
|
+
);
|
|
60
|
+
expect(video.currentTime).toBe(7);
|
|
61
|
+
|
|
62
|
+
rerender(
|
|
63
|
+
<svg>
|
|
64
|
+
<VideoLayer
|
|
65
|
+
layer={layer}
|
|
66
|
+
basePath="/media"
|
|
67
|
+
viewport={{ width: 640, height: 360 }}
|
|
68
|
+
blockTime={20}
|
|
69
|
+
isPlaying
|
|
70
|
+
/>
|
|
71
|
+
</svg>,
|
|
72
|
+
);
|
|
73
|
+
expect(video.currentTime).toBe(8);
|
|
74
|
+
expect(video.pause).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('holds at the in-point until startAt', async () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<svg>
|
|
80
|
+
<VideoLayer
|
|
81
|
+
layer={layer}
|
|
82
|
+
basePath="/media"
|
|
83
|
+
viewport={{ width: 640, height: 360 }}
|
|
84
|
+
blockTime={0.5}
|
|
85
|
+
isPlaying
|
|
86
|
+
/>
|
|
87
|
+
</svg>,
|
|
88
|
+
);
|
|
89
|
+
const video = container.querySelector('video')!;
|
|
90
|
+
|
|
91
|
+
await waitFor(() => expect(video.currentTime).toBe(2));
|
|
92
|
+
expect(video.pause).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -75,8 +75,9 @@ describe('ShapeLayer fill/border', () => {
|
|
|
75
75
|
/>
|
|
76
76
|
</svg>,
|
|
77
77
|
);
|
|
78
|
-
|
|
79
|
-
expect(
|
|
78
|
+
const gradient = container.querySelector('linearGradient');
|
|
79
|
+
expect(gradient).not.toBeNull();
|
|
80
|
+
expect(container.querySelector('rect')!.getAttribute('fill')).toBe(`url(#${gradient!.id})`);
|
|
80
81
|
});
|
|
81
82
|
});
|
|
82
83
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
3
|
+
import { getHandle, mount, unmount } from '../standalone-entry';
|
|
4
|
+
import * as standalone from '../standalone-entry';
|
|
5
|
+
|
|
6
|
+
function doc(id: string): Doc {
|
|
7
|
+
return {
|
|
8
|
+
articleId: id,
|
|
9
|
+
duration: 2,
|
|
10
|
+
blocks: [{ id: `${id}-block`, startTime: 0, duration: 2, audioSegment: 0, layers: [] }],
|
|
11
|
+
audio: { segments: [] },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function animatedDoc(id: string): Doc {
|
|
16
|
+
const result = doc(id);
|
|
17
|
+
result.blocks[0].layers = [
|
|
18
|
+
{
|
|
19
|
+
type: 'text',
|
|
20
|
+
id: `${id}-title`,
|
|
21
|
+
content: { text: 'Standalone motion', style: { fontSize: 48, color: '#fff' } },
|
|
22
|
+
position: { x: 100, y: 100 },
|
|
23
|
+
animation: { type: 'fadeIn', duration: 1 },
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mountedElements: Element[] = [];
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const element of mountedElements.splice(0)) unmount(element);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('standalone player instance handles', () => {
|
|
36
|
+
it('does not expose the removed mountStatic compatibility alias', () => {
|
|
37
|
+
expect('mountStatic' in standalone).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns the render API for exactly the mounted player instance', async () => {
|
|
41
|
+
const firstRoot = document.createElement('div');
|
|
42
|
+
const secondRoot = document.createElement('div');
|
|
43
|
+
document.body.append(firstRoot, secondRoot);
|
|
44
|
+
mountedElements.push(firstRoot, secondRoot);
|
|
45
|
+
|
|
46
|
+
const first = mount(firstRoot, doc('first'), { renderMode: true });
|
|
47
|
+
const second = mount(secondRoot, doc('second'), { renderMode: true });
|
|
48
|
+
const [firstAPI, secondAPI] = await Promise.all([first.renderAPI, second.renderAPI]);
|
|
49
|
+
|
|
50
|
+
expect(firstAPI?.getBlocks()[0].id).toBe('first-block');
|
|
51
|
+
expect(secondAPI?.getBlocks()[0].id).toBe('second-block');
|
|
52
|
+
expect(getHandle(firstRoot)).toBe(first);
|
|
53
|
+
expect(getHandle(secondRoot)).toBe(second);
|
|
54
|
+
expect('seekTo' in window).toBe(false);
|
|
55
|
+
expect('getDuration' in window).toBe(false);
|
|
56
|
+
expect('squisqActivePlayerId' in window).toBe(false);
|
|
57
|
+
expect('squisqPlayers' in window).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('resolves a null render API outside render mode and owns unmounting', async () => {
|
|
61
|
+
const root = document.createElement('div');
|
|
62
|
+
document.body.append(root);
|
|
63
|
+
mountedElements.push(root);
|
|
64
|
+
|
|
65
|
+
const handle = mount(root, doc('static'), { mode: 'static' });
|
|
66
|
+
expect(await handle.renderAPI).toBeNull();
|
|
67
|
+
expect(getHandle(root)).toBe(handle);
|
|
68
|
+
|
|
69
|
+
handle.unmount();
|
|
70
|
+
expect(getHandle(root)).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('prevents a stale handle from unmounting a newer player in the same element', async () => {
|
|
74
|
+
const root = document.createElement('div');
|
|
75
|
+
document.body.append(root);
|
|
76
|
+
mountedElements.push(root);
|
|
77
|
+
|
|
78
|
+
const first = mount(root, doc('first'), { renderMode: true });
|
|
79
|
+
expect((await first.renderAPI)?.getBlocks()[0].id).toBe('first-block');
|
|
80
|
+
|
|
81
|
+
const second = mount(root, doc('second'), { renderMode: true });
|
|
82
|
+
expect((await second.renderAPI)?.getBlocks()[0].id).toBe('second-block');
|
|
83
|
+
expect(first.getRenderAPI()).toBeNull();
|
|
84
|
+
|
|
85
|
+
first.unmount();
|
|
86
|
+
expect(getHandle(root)).toBe(second);
|
|
87
|
+
expect(second.getRenderAPI()?.getBlocks()[0].id).toBe('second-block');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('forwards the animationsEnabled render policy to the mounted player', async () => {
|
|
91
|
+
const root = document.createElement('div');
|
|
92
|
+
document.body.append(root);
|
|
93
|
+
mountedElements.push(root);
|
|
94
|
+
|
|
95
|
+
const handle = mount(root, animatedDoc('motionless'), {
|
|
96
|
+
renderMode: true,
|
|
97
|
+
animationsEnabled: false,
|
|
98
|
+
});
|
|
99
|
+
await handle.renderAPI;
|
|
100
|
+
|
|
101
|
+
expect(root.querySelector('[class*="anim-"]')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { AudioTrack } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { useAudioSync } from '../hooks/useAudioSync';
|
|
5
|
+
|
|
6
|
+
const track: AudioTrack = {
|
|
7
|
+
segments: [{ src: 'https://cdn.example.test/a.mp3', name: 'a', duration: 2, startTime: 0 }],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
afterEach(() => vi.restoreAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('useAudioSync resource loading', () => {
|
|
13
|
+
it('does not prefix absolute URLs and revokes a blob that resolves after cleanup', async () => {
|
|
14
|
+
let resolveFetch!: (value: unknown) => void;
|
|
15
|
+
const fetchPromise = new Promise((resolve) => {
|
|
16
|
+
resolveFetch = resolve;
|
|
17
|
+
});
|
|
18
|
+
const fetchSpy = vi
|
|
19
|
+
.spyOn(globalThis, 'fetch')
|
|
20
|
+
.mockReturnValue(fetchPromise as Promise<Response>);
|
|
21
|
+
const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:late');
|
|
22
|
+
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
23
|
+
const audioRef = { current: null };
|
|
24
|
+
const { unmount } = renderHook(() => useAudioSync(audioRef, track, '.'));
|
|
25
|
+
|
|
26
|
+
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
|
27
|
+
expect(fetchSpy.mock.calls[0][0]).toBe('https://cdn.example.test/a.mp3');
|
|
28
|
+
unmount();
|
|
29
|
+
|
|
30
|
+
await act(async () => {
|
|
31
|
+
resolveFetch({ ok: true, blob: async () => new Blob(['audio']) });
|
|
32
|
+
await fetchPromise;
|
|
33
|
+
await Promise.resolve();
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
});
|
|
36
|
+
expect(create).toHaveBeenCalled();
|
|
37
|
+
expect(revoke).toHaveBeenCalledWith('blob:late');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not preload when an external controller disables the hook', async () => {
|
|
41
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
42
|
+
ok: true,
|
|
43
|
+
blob: async () => new Blob(['audio']),
|
|
44
|
+
} as Response);
|
|
45
|
+
renderHook(() => useAudioSync({ current: null }, track, '.', false));
|
|
46
|
+
await act(async () => Promise.resolve());
|
|
47
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|