@classic-homes/theme-svelte 1.21.0 → 1.22.1
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/dist/lib/components/Alert.svelte +8 -10
- package/dist/lib/components/DataPanel.svelte +19 -5
- package/dist/lib/components/HeaderSearch.svelte +18 -10
- package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
- package/dist/lib/components/layout/AppShell.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
- package/dist/lib/components/layout/Footer.svelte +6 -6
- package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
- package/dist/lib/components/layout/Sidebar.svelte +25 -21
- package/dist/lib/components/media/Image.svelte.d.ts +1 -1
- package/dist/lib/components/media/Lightbox.svelte +13 -9
- package/dist/lib/components/media/MediaCarousel.svelte +5 -0
- package/dist/lib/components/media/MediaGrid.svelte +4 -1
- package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
- package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
- package/dist/lib/components/studio/InsertPalette.svelte +4 -1
- package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
- package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
- package/dist/lib/components/studio/StudioCanvasItem.svelte +31 -4
- package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +7 -0
- package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
- package/dist/lib/notices/sanitize.js +10 -3
- package/dist/lib/notices/store.d.ts +3 -3
- package/dist/lib/notices/store.js +14 -2
- package/dist/lib/notices/types.d.ts +8 -8
- package/dist/lib/notices/types.js +5 -1
- package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
- package/dist/lib/stores/sidebar.svelte.js +61 -26
- package/dist/lib/utils/media.js +4 -3
- package/dist/lib/utils/url.d.ts +10 -0
- package/dist/lib/utils/url.js +21 -0
- package/dist/lib/utils.js +14 -1
- package/package.json +5 -4
- package/dist/__tests__/FileUpload.test.js +0 -45
- package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
- package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
- package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
- package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
- package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
- package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
- package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
- package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
- package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
- package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
- package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
- package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
- package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
- package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
- package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
- package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
- package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
- package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
- package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
- package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
- package/dist/__tests__/action-header.test.js +0 -150
- package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
- package/dist/__tests__/code-field.test.js +0 -65
- package/dist/__tests__/control-text-color.test.js +0 -29
- package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
- package/dist/__tests__/data-panel-viewport.test.js +0 -45
- package/dist/__tests__/dropdown-trigger.test.js +0 -39
- package/dist/__tests__/header-search.test.js +0 -90
- package/dist/__tests__/media-components.test.js +0 -206
- package/dist/__tests__/media-hls.test.js +0 -273
- package/dist/__tests__/media-matterport.test.js +0 -296
- package/dist/__tests__/media-players.test.js +0 -244
- package/dist/__tests__/media-scrubber.test.js +0 -44
- package/dist/__tests__/media-utils.test.js +0 -427
- package/dist/__tests__/nav-access.test.js +0 -145
- package/dist/__tests__/page-header.test.js +0 -36
- package/dist/__tests__/public-layout-drawer.test.js +0 -116
- package/dist/__tests__/setup.js +0 -56
- package/dist/__tests__/sidebar-collapse.test.js +0 -58
- package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
- package/dist/__tests__/sidebar-expansion.test.js +0 -59
- package/dist/__tests__/sidebar-items.test.js +0 -59
- package/dist/__tests__/sidebar-mobile.test.js +0 -86
- package/dist/__tests__/studio-canvas-item-reveal.test.js +0 -50
- package/dist/__tests__/studio.test.js +0 -240
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { render, screen, waitFor } from '@testing-library/svelte';
|
|
3
|
-
import userEvent from '@testing-library/user-event';
|
|
4
|
-
import MediaEmbed from '../lib/components/media/MediaEmbed.svelte';
|
|
5
|
-
import MediaGrid from '../lib/components/media/MediaGrid.svelte';
|
|
6
|
-
import { buildEmbedSrc, buildMatterportSrc, fetchMatterportMetadata, getEmbedThumbnail, getMediaLabel, isSpatialMedia, isTimeBasedMedia, matterportTour, parseEmbedUrl, } from '../lib/utils/media.js';
|
|
7
|
-
const MODEL_ID = 'SxQL3iGyoDo';
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
10
|
-
});
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
vi.restoreAllMocks();
|
|
13
|
-
});
|
|
14
|
-
describe('buildMatterportSrc', () => {
|
|
15
|
-
it('builds the showcase URL with quick start on by default', () => {
|
|
16
|
-
// Without `qs`, the space shows a black frame for several seconds.
|
|
17
|
-
expect(buildMatterportSrc(MODEL_ID)).toBe(`https://my.matterport.com/show/?m=${MODEL_ID}&qs=1`);
|
|
18
|
-
});
|
|
19
|
-
it('leaves Matterport’s own defaults alone unless asked', () => {
|
|
20
|
-
const src = buildMatterportSrc(MODEL_ID);
|
|
21
|
-
for (const untouched of ['brand', 'help', 'hr', 'vr', 'f', 'mls', 'play', 'ts']) {
|
|
22
|
-
expect(src).not.toContain(`${untouched}=`);
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
it('maps each option to its showcase parameter', () => {
|
|
26
|
-
const src = buildMatterportSrc(MODEL_ID, {
|
|
27
|
-
autoplay: true,
|
|
28
|
-
brand: false,
|
|
29
|
-
help: false,
|
|
30
|
-
highlightReel: false,
|
|
31
|
-
vr: false,
|
|
32
|
-
floorSelector: false,
|
|
33
|
-
mls: true,
|
|
34
|
-
lang: 'fr',
|
|
35
|
-
guidedTourStart: 5,
|
|
36
|
-
});
|
|
37
|
-
expect(src).toContain('play=1');
|
|
38
|
-
expect(src).toContain('brand=0');
|
|
39
|
-
expect(src).toContain('help=0');
|
|
40
|
-
expect(src).toContain('hr=0');
|
|
41
|
-
expect(src).toContain('vr=0');
|
|
42
|
-
expect(src).toContain('f=0');
|
|
43
|
-
expect(src).toContain('mls=1');
|
|
44
|
-
expect(src).toContain('lang=fr');
|
|
45
|
-
expect(src).toContain('ts=5');
|
|
46
|
-
});
|
|
47
|
-
it('can turn quick start off', () => {
|
|
48
|
-
expect(buildMatterportSrc(MODEL_ID, { quickStart: false })).not.toContain('qs=');
|
|
49
|
-
});
|
|
50
|
-
it('floors a fractional guided-tour delay, which the player requires', () => {
|
|
51
|
-
expect(buildMatterportSrc(MODEL_ID, { guidedTourStart: 5.8 })).toContain('ts=5');
|
|
52
|
-
expect(buildMatterportSrc(MODEL_ID, { guidedTourStart: -3 })).toContain('ts=0');
|
|
53
|
-
});
|
|
54
|
-
it('passes arbitrary showcase parameters through, coercing booleans', () => {
|
|
55
|
-
const src = buildMatterportSrc(MODEL_ID, { params: { nozoom: true, tourcta: 0, dh: '1' } });
|
|
56
|
-
expect(src).toContain('nozoom=1');
|
|
57
|
-
expect(src).toContain('tourcta=0');
|
|
58
|
-
expect(src).toContain('dh=1');
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
describe('parseEmbedUrl - Matterport', () => {
|
|
62
|
-
it('parses a showcase URL', () => {
|
|
63
|
-
expect(parseEmbedUrl(`https://my.matterport.com/show/?m=${MODEL_ID}`)).toEqual({
|
|
64
|
-
provider: 'matterport',
|
|
65
|
-
videoId: MODEL_ID,
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
it('ignores the other parameters already on the URL', () => {
|
|
69
|
-
expect(parseEmbedUrl(`https://my.matterport.com/show/?m=${MODEL_ID}&brand=0&qs=1`)).toEqual({
|
|
70
|
-
provider: 'matterport',
|
|
71
|
-
videoId: MODEL_ID,
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
it('rejects a Matterport URL carrying no space', () => {
|
|
75
|
-
// Marketing and discover pages have no `m` parameter to embed.
|
|
76
|
-
expect(parseEmbedUrl('https://matterport.com/discover/space/some-place')).toBeNull();
|
|
77
|
-
expect(parseEmbedUrl('https://my.matterport.com/show/')).toBeNull();
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
describe('buildEmbedSrc - Matterport', () => {
|
|
81
|
-
it('routes the provider to the showcase builder', () => {
|
|
82
|
-
expect(buildEmbedSrc('matterport', MODEL_ID)).toBe(`https://my.matterport.com/show/?m=${MODEL_ID}&qs=1`);
|
|
83
|
-
});
|
|
84
|
-
it('forwards showcase options', () => {
|
|
85
|
-
expect(buildEmbedSrc('matterport', MODEL_ID, { matterport: { brand: false } })).toContain('brand=0');
|
|
86
|
-
});
|
|
87
|
-
it('starts the space moving when the facade is activated', () => {
|
|
88
|
-
expect(buildEmbedSrc('matterport', MODEL_ID, { autoplay: true })).toContain('play=1');
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
describe('Matterport media items', () => {
|
|
92
|
-
it('builds an embed item for a space', () => {
|
|
93
|
-
const item = matterportTour(MODEL_ID, { title: 'Show home', caption: 'Ground floor' });
|
|
94
|
-
expect(item).toMatchObject({
|
|
95
|
-
kind: 'embed',
|
|
96
|
-
provider: 'matterport',
|
|
97
|
-
id: MODEL_ID,
|
|
98
|
-
videoId: MODEL_ID,
|
|
99
|
-
title: 'Show home',
|
|
100
|
-
caption: 'Ground floor',
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
it('carries showcase options onto the item', () => {
|
|
104
|
-
const item = matterportTour(MODEL_ID, { brand: false, guidedTourStart: 3 });
|
|
105
|
-
expect(item.matterport).toMatchObject({ brand: false, guidedTourStart: 3 });
|
|
106
|
-
});
|
|
107
|
-
it('is spatial, not time-based, so it gets no play affordance or duration', () => {
|
|
108
|
-
const item = matterportTour(MODEL_ID);
|
|
109
|
-
expect(isSpatialMedia(item)).toBe(true);
|
|
110
|
-
expect(isTimeBasedMedia(item)).toBe(false);
|
|
111
|
-
});
|
|
112
|
-
it('falls back to a tour label rather than calling itself a video', () => {
|
|
113
|
-
expect(getMediaLabel(matterportTour(MODEL_ID), 2)).toBe('3D tour 3');
|
|
114
|
-
expect(getMediaLabel(matterportTour(MODEL_ID))).toBe('Untitled 3D tour');
|
|
115
|
-
});
|
|
116
|
-
it('derives no thumbnail, because Matterport publishes none', () => {
|
|
117
|
-
expect(getEmbedThumbnail('matterport', MODEL_ID)).toBeNull();
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
describe('fetchMatterportMetadata', () => {
|
|
121
|
-
it('returns the space name and the hero thumbnail', async () => {
|
|
122
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
123
|
-
ok: true,
|
|
124
|
-
json: async () => ({
|
|
125
|
-
sid: MODEL_ID,
|
|
126
|
-
name: 'Construction Site',
|
|
127
|
-
image: 'https://cdn/expired.jpg',
|
|
128
|
-
images: [
|
|
129
|
-
{ is_hero: false, signed_src: 'https://cdn/other.jpg' },
|
|
130
|
-
{
|
|
131
|
-
is_hero: true,
|
|
132
|
-
signed_src: 'https://cdn/hero-full.jpg',
|
|
133
|
-
thumbnail_signed_src: 'https://cdn/hero-thumb.jpg',
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
}),
|
|
137
|
-
}));
|
|
138
|
-
await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toEqual({
|
|
139
|
-
name: 'Construction Site',
|
|
140
|
-
image: 'https://cdn/hero-thumb.jpg',
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
it('ignores the top-level image, which Matterport hands out already expired', async () => {
|
|
144
|
-
// Requesting that URL answers 410 Gone; only the signatures inside
|
|
145
|
-
// `images` are freshly minted.
|
|
146
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
147
|
-
ok: true,
|
|
148
|
-
json: async () => ({
|
|
149
|
-
name: 'Space',
|
|
150
|
-
image: 'https://cdn/expired.jpg',
|
|
151
|
-
images: [{ is_hero: true, thumbnail_signed_src: 'https://cdn/fresh.jpg' }],
|
|
152
|
-
}),
|
|
153
|
-
}));
|
|
154
|
-
const metadata = await fetchMatterportMetadata(MODEL_ID);
|
|
155
|
-
expect(metadata?.image).toBe('https://cdn/fresh.jpg');
|
|
156
|
-
expect(metadata?.image).not.toContain('expired');
|
|
157
|
-
});
|
|
158
|
-
it('falls back to the full-size signed image when there is no thumbnail', async () => {
|
|
159
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
160
|
-
ok: true,
|
|
161
|
-
json: async () => ({
|
|
162
|
-
name: 'Space',
|
|
163
|
-
images: [{ is_hero: true, signed_src: 'https://cdn/full.jpg' }],
|
|
164
|
-
}),
|
|
165
|
-
}));
|
|
166
|
-
await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toMatchObject({
|
|
167
|
-
image: 'https://cdn/full.jpg',
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
it('uses a non-hero image when the space marks no hero', async () => {
|
|
171
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
172
|
-
ok: true,
|
|
173
|
-
json: async () => ({
|
|
174
|
-
name: 'Space',
|
|
175
|
-
images: [{ thumbnail_signed_src: 'https://cdn/first.jpg' }],
|
|
176
|
-
}),
|
|
177
|
-
}));
|
|
178
|
-
await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toMatchObject({
|
|
179
|
-
image: 'https://cdn/first.jpg',
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
it('reports no image when the space has none', async () => {
|
|
183
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ name: 'Space', images: [] }) }));
|
|
184
|
-
await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toEqual({
|
|
185
|
-
name: 'Space',
|
|
186
|
-
image: null,
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
it('resolves to null on a failed response rather than throwing', async () => {
|
|
190
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
191
|
-
await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toBeNull();
|
|
192
|
-
});
|
|
193
|
-
it('resolves to null when the request itself fails', async () => {
|
|
194
|
-
// A preview is an enhancement; it must never break a render.
|
|
195
|
-
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
|
196
|
-
await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toBeNull();
|
|
197
|
-
});
|
|
198
|
-
it('does not call out for an empty id', async () => {
|
|
199
|
-
const fetchMock = vi.fn();
|
|
200
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
201
|
-
await expect(fetchMatterportMetadata('')).resolves.toBeNull();
|
|
202
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
describe('MediaEmbed with Matterport', () => {
|
|
206
|
-
it('shows a tour facade that opens rather than plays', () => {
|
|
207
|
-
const { container } = render(MediaEmbed, {
|
|
208
|
-
props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home' },
|
|
209
|
-
});
|
|
210
|
-
expect(container.querySelector('iframe')).toBeNull();
|
|
211
|
-
expect(screen.getByRole('button', { name: 'Open Show home as a 3D tour on Matterport' })).toBeInTheDocument();
|
|
212
|
-
});
|
|
213
|
-
it('accepts modelId as an alias for videoId', () => {
|
|
214
|
-
const { container } = render(MediaEmbed, {
|
|
215
|
-
props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home', eager: true },
|
|
216
|
-
});
|
|
217
|
-
expect(container.querySelector('iframe')).toHaveAttribute('src', `https://my.matterport.com/show/?m=${MODEL_ID}&qs=1`);
|
|
218
|
-
});
|
|
219
|
-
it('grants the permissions the showcase needs for VR', () => {
|
|
220
|
-
const { container } = render(MediaEmbed, {
|
|
221
|
-
props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home', eager: true },
|
|
222
|
-
});
|
|
223
|
-
const iframe = container.querySelector('iframe');
|
|
224
|
-
expect(iframe.getAttribute('allow')).toContain('xr-spatial-tracking');
|
|
225
|
-
expect(iframe).toHaveAttribute('allowfullscreen');
|
|
226
|
-
});
|
|
227
|
-
it('resolves the space from a full showcase URL', () => {
|
|
228
|
-
const { container } = render(MediaEmbed, {
|
|
229
|
-
props: {
|
|
230
|
-
url: `https://my.matterport.com/show/?m=${MODEL_ID}`,
|
|
231
|
-
title: 'Show home',
|
|
232
|
-
eager: true,
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
expect(container.querySelector('iframe')?.getAttribute('src')).toContain(`m=${MODEL_ID}`);
|
|
236
|
-
});
|
|
237
|
-
it('makes no request to Matterport by default', async () => {
|
|
238
|
-
const fetchMock = vi.fn();
|
|
239
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
240
|
-
render(MediaEmbed, {
|
|
241
|
-
props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home' },
|
|
242
|
-
});
|
|
243
|
-
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
|
244
|
-
});
|
|
245
|
-
it('looks up the preview image only when asked', async () => {
|
|
246
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
247
|
-
ok: true,
|
|
248
|
-
json: async () => ({
|
|
249
|
-
name: 'Construction Site',
|
|
250
|
-
images: [{ is_hero: true, thumbnail_signed_src: 'https://cdn/preview.jpg' }],
|
|
251
|
-
}),
|
|
252
|
-
});
|
|
253
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
254
|
-
const { container } = render(MediaEmbed, {
|
|
255
|
-
props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home', fetchMetadata: true },
|
|
256
|
-
});
|
|
257
|
-
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
|
258
|
-
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn/preview.jpg'));
|
|
259
|
-
});
|
|
260
|
-
it('skips the lookup when a poster was supplied', async () => {
|
|
261
|
-
const fetchMock = vi.fn();
|
|
262
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
263
|
-
render(MediaEmbed, {
|
|
264
|
-
props: {
|
|
265
|
-
provider: 'matterport',
|
|
266
|
-
modelId: MODEL_ID,
|
|
267
|
-
title: 'Show home',
|
|
268
|
-
fetchMetadata: true,
|
|
269
|
-
poster: '/own.jpg',
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
|
273
|
-
});
|
|
274
|
-
it('activates the showcase on click, with the space set moving', async () => {
|
|
275
|
-
const user = userEvent.setup();
|
|
276
|
-
const { container } = render(MediaEmbed, {
|
|
277
|
-
props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home' },
|
|
278
|
-
});
|
|
279
|
-
await user.click(screen.getByRole('button', { name: /Open Show home/ }));
|
|
280
|
-
const src = container.querySelector('iframe')?.getAttribute('src') ?? '';
|
|
281
|
-
expect(src).toContain(`m=${MODEL_ID}`);
|
|
282
|
-
expect(src).toContain('play=1');
|
|
283
|
-
});
|
|
284
|
-
it('reports an unresolvable tour as a tour, not a video', () => {
|
|
285
|
-
render(MediaEmbed, { props: { provider: 'matterport', title: 'Show home' } });
|
|
286
|
-
expect(screen.getByRole('alert')).toHaveTextContent('This 3D tour could not be loaded.');
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
describe('MediaGrid with Matterport tours', () => {
|
|
290
|
-
it('names tour tiles and shows no duration badge', () => {
|
|
291
|
-
const items = [matterportTour(MODEL_ID, { title: 'Show home' }), matterportTour('AbCdEfGhIjK')];
|
|
292
|
-
render(MediaGrid, { props: { items, label: 'Tours', lightbox: false } });
|
|
293
|
-
expect(screen.getByRole('button', { name: 'Show home' })).toBeInTheDocument();
|
|
294
|
-
expect(screen.getByRole('button', { name: '3D tour 2' })).toBeInTheDocument();
|
|
295
|
-
});
|
|
296
|
-
});
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
3
|
-
import userEvent from '@testing-library/user-event';
|
|
4
|
-
import Audio from '../lib/components/media/Audio.svelte';
|
|
5
|
-
import Video from '../lib/components/media/Video.svelte';
|
|
6
|
-
const captions = [{ src: '/clip.en.vtt', srclang: 'en', label: 'English', default: true }];
|
|
7
|
-
describe('Video', () => {
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
vi.clearAllMocks();
|
|
10
|
-
// The dev-only captions/autoplay warnings are asserted explicitly below;
|
|
11
|
-
// silence them everywhere else so the output stays readable.
|
|
12
|
-
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
13
|
-
});
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
vi.restoreAllMocks();
|
|
16
|
-
});
|
|
17
|
-
it('renders a labelled, focusable region', () => {
|
|
18
|
-
render(Video, { props: { src: '/clip.mp4', label: 'Product tour', tracks: captions } });
|
|
19
|
-
const region = screen.getByRole('region', { name: 'Product tour' });
|
|
20
|
-
expect(region).toBeInTheDocument();
|
|
21
|
-
// Focusable so the playback shortcuts have somewhere to land.
|
|
22
|
-
expect(region).toHaveAttribute('tabindex', '0');
|
|
23
|
-
});
|
|
24
|
-
it('suppresses the native controls in favour of the themed bar', () => {
|
|
25
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
26
|
-
const video = container.querySelector('video');
|
|
27
|
-
expect(video).not.toHaveAttribute('controls');
|
|
28
|
-
expect(video).toHaveAttribute('playsinline');
|
|
29
|
-
expect(video).toHaveAttribute('preload', 'metadata');
|
|
30
|
-
});
|
|
31
|
-
it('renders a source element per entry, with its MIME type', () => {
|
|
32
|
-
const { container } = render(Video, {
|
|
33
|
-
props: {
|
|
34
|
-
src: [
|
|
35
|
-
{ src: '/clip.webm', type: 'video/webm' },
|
|
36
|
-
{ src: '/clip.mp4', type: 'video/mp4' },
|
|
37
|
-
],
|
|
38
|
-
tracks: captions,
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
const sources = container.querySelectorAll('source');
|
|
42
|
-
expect(sources).toHaveLength(2);
|
|
43
|
-
expect(sources[0]).toHaveAttribute('type', 'video/webm');
|
|
44
|
-
});
|
|
45
|
-
it('guesses the MIME type for a bare URL', () => {
|
|
46
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
47
|
-
expect(container.querySelector('source')).toHaveAttribute('type', 'video/mp4');
|
|
48
|
-
});
|
|
49
|
-
it('renders the caption tracks it is given', () => {
|
|
50
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
51
|
-
const track = container.querySelector('track');
|
|
52
|
-
expect(track).toHaveAttribute('srclang', 'en');
|
|
53
|
-
expect(track).toHaveAttribute('label', 'English');
|
|
54
|
-
expect(track).toHaveAttribute('kind', 'subtitles');
|
|
55
|
-
});
|
|
56
|
-
it('starts play from the centre button', async () => {
|
|
57
|
-
const user = userEvent.setup();
|
|
58
|
-
render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
59
|
-
await user.click(screen.getByRole('button', { name: 'Play video' }));
|
|
60
|
-
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
|
61
|
-
});
|
|
62
|
-
it('toggles playback from the keyboard', async () => {
|
|
63
|
-
render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
64
|
-
const region = screen.getByRole('region');
|
|
65
|
-
await fireEvent.keyDown(region, { key: ' ' });
|
|
66
|
-
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
|
67
|
-
});
|
|
68
|
-
it('ignores shortcuts that carry a modifier, so browser chords still work', async () => {
|
|
69
|
-
render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
70
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: ' ', ctrlKey: true });
|
|
71
|
-
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
|
72
|
-
});
|
|
73
|
-
it('seeks with the arrow keys', async () => {
|
|
74
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
75
|
-
const video = container.querySelector('video');
|
|
76
|
-
// jsdom leaves duration NaN, so pin it to make seeking observable.
|
|
77
|
-
Object.defineProperty(video, 'duration', { value: 100, configurable: true });
|
|
78
|
-
video.currentTime = 50;
|
|
79
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: 'ArrowRight' });
|
|
80
|
-
expect(video.currentTime).toBe(55);
|
|
81
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: 'ArrowLeft' });
|
|
82
|
-
expect(video.currentTime).toBe(50);
|
|
83
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: 'l' });
|
|
84
|
-
expect(video.currentTime).toBe(60);
|
|
85
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: 'Home' });
|
|
86
|
-
expect(video.currentTime).toBe(0);
|
|
87
|
-
});
|
|
88
|
-
it('mutes with the M key', async () => {
|
|
89
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
90
|
-
const video = container.querySelector('video');
|
|
91
|
-
expect(video.muted).toBe(false);
|
|
92
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: 'm' });
|
|
93
|
-
expect(video.muted).toBe(true);
|
|
94
|
-
});
|
|
95
|
-
it('exposes the seek bar and transport controls with accessible names', () => {
|
|
96
|
-
render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
97
|
-
expect(screen.getByRole('slider', { name: 'Seek' })).toBeInTheDocument();
|
|
98
|
-
expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
|
|
99
|
-
expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument();
|
|
100
|
-
expect(screen.getByRole('button', { name: 'Enter fullscreen' })).toBeInTheDocument();
|
|
101
|
-
});
|
|
102
|
-
it('hides the fullscreen and picture-in-picture buttons on request', () => {
|
|
103
|
-
render(Video, {
|
|
104
|
-
props: {
|
|
105
|
-
src: '/clip.mp4',
|
|
106
|
-
tracks: captions,
|
|
107
|
-
showFullscreen: false,
|
|
108
|
-
showPictureInPicture: false,
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
expect(screen.queryByRole('button', { name: 'Enter fullscreen' })).not.toBeInTheDocument();
|
|
112
|
-
expect(screen.queryByRole('button', { name: 'Picture in picture' })).not.toBeInTheDocument();
|
|
113
|
-
});
|
|
114
|
-
it('ignores a stall reported while paused, and keeps the play button reachable', async () => {
|
|
115
|
-
// Regression: `waiting` was only ever cleared by the `playing` event, so a
|
|
116
|
-
// stall reported on a paused element hid the play button forever. Browsers
|
|
117
|
-
// emit `waiting` on paused elements routinely, especially when several
|
|
118
|
-
// players compete for bandwidth on one page.
|
|
119
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
120
|
-
const video = container.querySelector('video');
|
|
121
|
-
expect(screen.getByRole('button', { name: 'Play video' })).toBeInTheDocument();
|
|
122
|
-
await fireEvent(video, new Event('waiting'));
|
|
123
|
-
expect(container.querySelector('.animate-spin')).toBeNull();
|
|
124
|
-
expect(screen.getByRole('button', { name: 'Play video' })).toBeInTheDocument();
|
|
125
|
-
});
|
|
126
|
-
it('clears a stall once the element reports it can play again', async () => {
|
|
127
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
128
|
-
const video = container.querySelector('video');
|
|
129
|
-
// Enter a genuine buffering state: playing, then starved of data.
|
|
130
|
-
await fireEvent(video, new Event('play'));
|
|
131
|
-
await fireEvent(video, new Event('waiting'));
|
|
132
|
-
expect(container.querySelector('.animate-spin')).not.toBeNull();
|
|
133
|
-
// `playing` is not the only way out — `canplay` must recover it too.
|
|
134
|
-
await fireEvent(video, new Event('canplay'));
|
|
135
|
-
expect(container.querySelector('.animate-spin')).toBeNull();
|
|
136
|
-
});
|
|
137
|
-
it('shows the spinner only while playback is actually held up', async () => {
|
|
138
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
139
|
-
const video = container.querySelector('video');
|
|
140
|
-
await fireEvent(video, new Event('play'));
|
|
141
|
-
await fireEvent(video, new Event('waiting'));
|
|
142
|
-
expect(container.querySelector('.animate-spin')).not.toBeNull();
|
|
143
|
-
// Pausing mid-stall ends the buffering state from the viewer's point of view.
|
|
144
|
-
await fireEvent(video, new Event('pause'));
|
|
145
|
-
expect(container.querySelector('.animate-spin')).toBeNull();
|
|
146
|
-
});
|
|
147
|
-
it('offers a replay button once playback ends', async () => {
|
|
148
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
149
|
-
const video = container.querySelector('video');
|
|
150
|
-
await fireEvent(video, new Event('play'));
|
|
151
|
-
Object.defineProperty(video, 'currentTime', { value: 3, configurable: true });
|
|
152
|
-
await fireEvent(video, new Event('timeupdate'));
|
|
153
|
-
await fireEvent(video, new Event('ended'));
|
|
154
|
-
expect(screen.getByRole('button', { name: 'Replay video' })).toBeInTheDocument();
|
|
155
|
-
});
|
|
156
|
-
it('never seeks in response to the playhead advancing', async () => {
|
|
157
|
-
// Guards the invariant that playback progress alone must never write back
|
|
158
|
-
// to `currentTime`. Note the Bits UI echo that originally broke this is
|
|
159
|
-
// not reproducible under jsdom — the gate itself is covered in
|
|
160
|
-
// media-scrubber.test.ts, and the loop was verified fixed in a browser.
|
|
161
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
162
|
-
const video = container.querySelector('video');
|
|
163
|
-
Object.defineProperty(video, 'duration', { value: 30, configurable: true });
|
|
164
|
-
let seeks = 0;
|
|
165
|
-
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime');
|
|
166
|
-
let time = 0;
|
|
167
|
-
Object.defineProperty(video, 'currentTime', {
|
|
168
|
-
configurable: true,
|
|
169
|
-
get: () => time,
|
|
170
|
-
set: (value) => {
|
|
171
|
-
seeks += 1;
|
|
172
|
-
time = value;
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
void descriptor;
|
|
176
|
-
await fireEvent(video, new Event('play'));
|
|
177
|
-
// Advance the playhead the way playback does.
|
|
178
|
-
for (const t of [0.5, 1, 1.5, 2]) {
|
|
179
|
-
time = t;
|
|
180
|
-
await fireEvent(video, new Event('timeupdate'));
|
|
181
|
-
}
|
|
182
|
-
expect(seeks).toBe(0);
|
|
183
|
-
});
|
|
184
|
-
it('does not zero the volume when muting', async () => {
|
|
185
|
-
// The volume slider is controlled too: muting pushes 0 into it, and
|
|
186
|
-
// echoing that back as a real volume change left unmuting silent.
|
|
187
|
-
const user = userEvent.setup();
|
|
188
|
-
const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
|
|
189
|
-
const video = container.querySelector('video');
|
|
190
|
-
await user.click(screen.getByRole('button', { name: 'Mute' }));
|
|
191
|
-
expect(video.muted).toBe(true);
|
|
192
|
-
expect(video.volume).toBe(1);
|
|
193
|
-
await user.click(screen.getByRole('button', { name: 'Unmute' }));
|
|
194
|
-
expect(video.muted).toBe(false);
|
|
195
|
-
expect(video.volume).toBe(1);
|
|
196
|
-
});
|
|
197
|
-
it('warns in development when no captions are supplied', () => {
|
|
198
|
-
render(Video, { props: { src: '/clip.mp4' } });
|
|
199
|
-
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('No caption tracks'));
|
|
200
|
-
});
|
|
201
|
-
it('warns when autoplay is requested without muting', () => {
|
|
202
|
-
render(Video, { props: { src: '/clip.mp4', tracks: captions, autoplay: true } });
|
|
203
|
-
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('`autoplay` without `muted`'));
|
|
204
|
-
});
|
|
205
|
-
it('mutes automatically when autoplay is set, since browsers require it', () => {
|
|
206
|
-
const { container } = render(Video, {
|
|
207
|
-
props: { src: '/clip.mp4', tracks: captions, autoplay: true },
|
|
208
|
-
});
|
|
209
|
-
// Svelte binds `muted` as a DOM property rather than an attribute.
|
|
210
|
-
expect(container.querySelector('video').muted).toBe(true);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
describe('Audio', () => {
|
|
214
|
-
beforeEach(() => {
|
|
215
|
-
vi.clearAllMocks();
|
|
216
|
-
});
|
|
217
|
-
it('names the region after the track title', () => {
|
|
218
|
-
render(Audio, { props: { src: '/episode.mp3', title: 'Episode 12' } });
|
|
219
|
-
expect(screen.getByRole('region', { name: 'Audio player: Episode 12' })).toBeInTheDocument();
|
|
220
|
-
});
|
|
221
|
-
it('shows the title and artist', () => {
|
|
222
|
-
render(Audio, { props: { src: '/episode.mp3', title: 'Episode 12', artist: 'Design Notes' } });
|
|
223
|
-
expect(screen.getByText('Episode 12')).toBeInTheDocument();
|
|
224
|
-
expect(screen.getByText('Design Notes')).toBeInTheDocument();
|
|
225
|
-
});
|
|
226
|
-
it('omits the video-only controls', () => {
|
|
227
|
-
render(Audio, { props: { src: '/episode.mp3' } });
|
|
228
|
-
expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
|
|
229
|
-
expect(screen.queryByRole('button', { name: 'Enter fullscreen' })).not.toBeInTheDocument();
|
|
230
|
-
expect(screen.queryByRole('button', { name: 'Picture in picture' })).not.toBeInTheDocument();
|
|
231
|
-
});
|
|
232
|
-
it('toggles playback from the keyboard', async () => {
|
|
233
|
-
render(Audio, { props: { src: '/episode.mp3' } });
|
|
234
|
-
await fireEvent.keyDown(screen.getByRole('region'), { key: 'k' });
|
|
235
|
-
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
|
236
|
-
});
|
|
237
|
-
it('does not hijack typing inside a text field', async () => {
|
|
238
|
-
render(Audio, { props: { src: '/episode.mp3' } });
|
|
239
|
-
const input = document.createElement('input');
|
|
240
|
-
screen.getByRole('region').appendChild(input);
|
|
241
|
-
await fireEvent.keyDown(input, { key: ' ' });
|
|
242
|
-
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
3
|
-
import MediaScrubber from '../lib/components/media/internal/MediaScrubber.svelte';
|
|
4
|
-
/**
|
|
5
|
-
* The scrub bar is a *controlled* slider: its value follows the playhead.
|
|
6
|
-
* Bits UI emits `onValueChange` for those programmatic updates as well as for
|
|
7
|
-
* real input, so seeking on every change fed the playhead straight back in —
|
|
8
|
-
* pinning playback at the first step and starving the element into a permanent
|
|
9
|
-
* buffering loop. The player only seeks while the viewer is genuinely
|
|
10
|
-
* interacting, and these cover that gate.
|
|
11
|
-
*/
|
|
12
|
-
describe('MediaScrubber seek gating', () => {
|
|
13
|
-
const base = { currentTime: 0, duration: 30 };
|
|
14
|
-
it('does not seek without an interaction', async () => {
|
|
15
|
-
const onSeek = vi.fn();
|
|
16
|
-
render(MediaScrubber, { props: { ...base, onSeek } });
|
|
17
|
-
const slider = screen.getByRole('slider', { name: 'Seek' });
|
|
18
|
-
// Arrow keys reach Bits UI, but the gate is shut until a pointer or key
|
|
19
|
-
// interaction is captured on the wrapper first.
|
|
20
|
-
await fireEvent.keyDown(slider, { key: 'ArrowRight' });
|
|
21
|
-
// Sanity: the control exists and is wired up.
|
|
22
|
-
expect(slider).toBeInTheDocument();
|
|
23
|
-
expect(onSeek.mock.calls.every(([value]) => typeof value === 'number')).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
it('opens the gate on pointer interaction and closes it on release', async () => {
|
|
26
|
-
const onSeek = vi.fn();
|
|
27
|
-
const { container } = render(MediaScrubber, { props: { ...base, onSeek } });
|
|
28
|
-
const wrapper = container.querySelector('.group\\/scrubber');
|
|
29
|
-
await fireEvent.pointerDown(wrapper);
|
|
30
|
-
await fireEvent(window, new Event('pointerup'));
|
|
31
|
-
// The window-level release listener must be torn down cleanly.
|
|
32
|
-
expect(wrapper).toBeInTheDocument();
|
|
33
|
-
});
|
|
34
|
-
it('is disabled until a duration is known, so it cannot seek to nowhere', () => {
|
|
35
|
-
const onSeek = vi.fn();
|
|
36
|
-
render(MediaScrubber, { props: { currentTime: 0, duration: 0, onSeek } });
|
|
37
|
-
expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute('aria-disabled', 'true');
|
|
38
|
-
expect(onSeek).not.toHaveBeenCalled();
|
|
39
|
-
});
|
|
40
|
-
it('announces spoken timestamps rather than raw seconds', () => {
|
|
41
|
-
render(MediaScrubber, { props: { currentTime: 83, duration: 245, onSeek: vi.fn() } });
|
|
42
|
-
expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute('aria-valuetext', '1 minute 23 seconds of 4 minutes 5 seconds');
|
|
43
|
-
});
|
|
44
|
-
});
|