diff --git a/client/src/App.tsx b/client/src/App.tsx index e4c72ab8e..80bbf931f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -284,7 +284,7 @@ function RouteFallback() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() const { loadPlugins } = usePluginStore() @@ -301,7 +301,7 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record }) => { setManaged(!!config?.managed) setDemoMode(!!config?.demo_mode) if (config?.dev_mode) setDevMode(true) @@ -359,6 +359,9 @@ export default function App() { return } } + // Last, so the version block above keeps its lines: the taint analyser + // re-raises its browser-storage finding for every line that moves under it. + if (config?.places_provider) setPlacesProvider(config.places_provider) }).catch(() => {}) }, []) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4d093ba64..d338a41a2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1216,11 +1216,11 @@ export const mapsApi = { * caller of this one function gets them without knowing they exist. Appended rather * than interleaved: the core list is ordered by relevance and has earned that order, * and a plugin's row carries its own `source` for a caller that wants to mark it. - */ - /** + * * `provider: 'google'` sends this one search to Google alone, the "search Google - * instead" link under a list the index answered with the wrong place. The - * server ignores it on an instance without a Google key. + * instead" link under a list the index answered with the wrong place. The server + * ignores it unless Google holds the keyed slot: without a Google key, or with + * Amap or OpenStreetMap picked as the provider, the index answers as usual. */ search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') => withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx index 4b2d7e7c3..779fd3cdb 100644 --- a/client/src/components/Collab/CollabLinks.test.tsx +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-LINKS-001 to FE-COMP-LINKS-008 +// FE-COMP-LINKS-001 to FE-COMP-LINKS-013 vi.mock('../../api/websocket', () => ({ connect: vi.fn(), @@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({ removeListener: vi.fn(), })); -import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -160,6 +162,65 @@ describe('CollabLinks', () => { expect(screen.getByText('ferries.example')).toBeInTheDocument(); }); + it('FE-COMP-LINKS-011: deleting asks first, and the DELETE goes out only once confirmed', async () => { + // The link goes for every member, and on the phone the button is a thumb's + // width from pin: the notes panel guards the same way. + const user = userEvent.setup(); + let deleted = false; + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }), + ); + render(); + await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i })); + expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument(); + expect(deleted).toBe(false); + expect(screen.getByText('Ferry timetable')).toBeInTheDocument(); + + await user.click(document.querySelector('button.bg-red-600') as HTMLElement); + await waitFor(() => expect(deleted).toBe(true)); + await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument()); + }); + + it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => { + // The icon remembers a favicon that failed, and an edit keeps the chip's id, + // so the corrected address kept the generic glyph until the panel remounted. + const user = userEvent.setup(); + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })), + http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })), + ); + render(); + const chip = await screen.findByRole('link', { name: /ferry timetable/i }); + fireEvent.error(chip.querySelector('img') as HTMLImageElement); + await waitFor(() => expect(chip.querySelector('img')).toBeNull()); + + await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i })); + const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i); + await user.clear(url); + await user.type(url, 'https://new.example/page'); + await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i })); + + await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img')) + .toHaveAttribute('src', 'https://new.example/favicon.ico')); + }); + + it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => { + // jsdom lays nothing out, so the rule is the assertion. The phone Collab tab + // mounts this chip; 22px buttons a pixel apart put delete one slip from pin. + const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8'); + const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions'); + expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1); + const touch = css.slice(at, css.indexOf('\n}', at)); + expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/); + const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? ''; + for (const side of ['width', 'height']) { + expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24); + } + // The height comes back through the margin, so the chip itself stays 36px. + expect(action).toMatch(/margin:\s*-4px 0/); + }); + it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { // collab_edit reserved for the owner, on somebody else's trip. seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index a9fb3f217..fbe688143 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -6,6 +6,7 @@ import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import ConfirmDialog from '../shared/ConfirmDialog' import EmptyState from '../shared/EmptyState' import { useToast } from '../shared/Toast' @@ -111,6 +112,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { const [links, setLinks] = useState([]) const [showForm, setShowForm] = useState(false) const [editing, setEditing] = useState(null) + const [pendingDeleteId, setPendingDeleteId] = useState(null) // Both are new objects on every render, so depending on them would reload the // list after each keystroke and drop a link that was just added back out of it. @@ -230,7 +232,9 @@ export default function CollabLinks({ tripId }: { tripId: number }) { {links.map(link => (
- + {/* Keyed on the address: the icon remembers a favicon that failed, and + an edit keeps the chip's id, so a corrected address got the glyph. */} + {link.title} {hostOf(link.url)} @@ -244,7 +248,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { - @@ -258,6 +262,16 @@ export default function CollabLinks({ tripId }: { tripId: number }) { {/* Create and edit modals */} {showForm && setShowForm(false)} onSave={add} />} {editing && setEditing(null)} onSave={data => edit(editing, data)} />} + + {/* The delete is shared: the link goes for every member, and on the phone the + button is a thumb's width from pin. Same guard as the notes panel. */} + setPendingDeleteId(null)} + onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)} + title={t('collab.links.confirmDeleteTitle')} + message={t('collab.links.confirmDeleteBody')} + />
) } diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index 7aa6c6ab5..124f88f5f 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040 +// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http, HttpResponse, delay } from 'msw' @@ -1045,4 +1045,30 @@ describe('EntryEditor', () => { expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument() expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument() }) + + it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => { + const user = userEvent.setup() + const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null } + const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] }) + + await user.click(screen.getByRole('button', { name: 'From Gallery' })) + + expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument() + expect(container.querySelector('svg.lucide-play')).toBeInTheDocument() + }) + + it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => { + // The /original of a video is the video file, which an cannot draw. + const user = userEvent.setup() + const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' } + const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' } + const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] }) + + await user.click(screen.getByRole('button', { name: 'From Gallery' })) + for (const id of [100, 200]) { + const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement + fireEvent.error(img) + expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`) + } + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 5818a5d66..8a73cbe92 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from 'react' +import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react' import { localIsoDate } from '../../utils/localDate' import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react' import { normalizeImageFiles } from '../../utils/convertHeic' @@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn type PendingProviderGroup = ProviderPhotoGroup & { provider: string } +// A photo whose thumbnail is missing can still be drawn from its original. A +// clip's original is the video file, which no can show, so a clip gets +// no fallback (#2341). +function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) { + if (p.media_type === 'video') return undefined + return (e: SyntheticEvent) => { + const img = e.currentTarget + if (!img.src.includes('/original')) img.src = photoUrl(p, 'original') + } +} + +// No poster to show and falling back to /original would hand an the clip +// itself, so this tile stays a play badge. +function ClipTile() { + return ( +
+ +
+ ) +} + export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: { entry: JourneyEntry journeyId: number @@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all" style={{ paddingTop: '100%' }} > - { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> + {posterlessVideo(gp) ? ( + + ) : ( + + )} ))} {availableGalleryPhotos.length === 0 && ( @@ -528,13 +553,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, {photos.map((p, idx) => (
1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}> {posterlessVideo(p) ? ( - // No poster to show and falling back to /original would hand - // an the clip itself, so this tile stays a play badge. -
- -
+ ) : ( - { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> + )} {idx === 0 && photos.length > 1 && ( {t('journey.editor.photoFirst')} diff --git a/client/src/components/Journey/JourneyEntryCover.test.tsx b/client/src/components/Journey/JourneyEntryCover.test.tsx index 994b66580..5b746454a 100644 --- a/client/src/components/Journey/JourneyEntryCover.test.tsx +++ b/client/src/components/Journey/JourneyEntryCover.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-COVER-001 to FE-JRN-COVER-008 +// FE-JRN-COVER-001 to FE-JRN-COVER-010 import { describe, it, expect, vi } from 'vitest' import userEvent from '@testing-library/user-event' @@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => { expect(onClick).toHaveBeenCalledOnce() }) + + it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => { + // Its thumbnail route 404s on purpose, so an would have been a broken face + // across the whole card. + const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] }) + const { container } = render( {}} />) + + expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull() + expect(container.querySelector('svg.lucide-play')).toBeInTheDocument() + expect(container.querySelector('svg.lucide-map-pin')).toBeNull() + }) + + it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => { + const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] }) + const { container } = render( {}} />) + + expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail') + expect(container.querySelector('svg.lucide-play')).toBeNull() + }) }) diff --git a/client/src/components/Journey/JourneyEntryCover.tsx b/client/src/components/Journey/JourneyEntryCover.tsx index 8a4069b7c..acdd73a78 100644 --- a/client/src/components/Journey/JourneyEntryCover.tsx +++ b/client/src/components/Journey/JourneyEntryCover.tsx @@ -1,6 +1,7 @@ -import { MapPin, Plus } from 'lucide-react' +import { MapPin, Play, Plus } from 'lucide-react' import { useTranslation } from '../../i18n' import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta' +import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import CountryFlag from '../shared/CountryFlag' import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard' import type { JourneyEntry } from '../../store/journeyStore' @@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore' * which needs no tokens at all. */ +/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */ +export interface CoverPhoto extends CardPhoto { + media_type?: string | null + provider?: string | null + thumbnail_path?: string | null +} + /** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */ export interface CoverEntry { id: number @@ -39,7 +47,7 @@ export interface CoverEntry { entry_time?: string | null mood?: string | null weather?: string | null - photos?: CardPhoto[] + photos?: CoverPhoto[] } interface Props { @@ -69,8 +77,12 @@ export default function JourneyEntryCover({ const { t, locale } = useTranslation() const isSuggestion = entry.type === 'skeleton' - const photoId = cardPhotoId(entry.photos?.[0]) - const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail` + const first = entry.photos?.[0] + const photoId = cardPhotoId(first) + // A clip without a poster has no picture to fill the card with; its thumbnail + // route 404s and an pointed at it would draw the broken glyph (#2341). + const posterless = first != null && posterlessVideo(first) + const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail` const title = cardTitle(entry, t) const place = cardPlace(entry) @@ -112,7 +124,9 @@ export default function JourneyEntryCover({ ) : ( - + {posterless + ? + : } )} diff --git a/client/src/components/Journey/MobileEntryView.test.tsx b/client/src/components/Journey/MobileEntryView.test.tsx index c32288f93..d1eb3a2fc 100644 --- a/client/src/components/Journey/MobileEntryView.test.tsx +++ b/client/src/components/Journey/MobileEntryView.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017 +// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020 import { render, screen, fireEvent } from '../../../tests/helpers/render' import { resetAllStores, seedStore } from '../../../tests/helpers/store' @@ -234,4 +234,25 @@ describe('MobileEntryView', () => { expect(screen.queryByText('Amazing')).not.toBeInTheDocument() expect(screen.queryByText('Sunny')).not.toBeInTheDocument() }) + + it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => { + // Its original is the video file, which an cannot show, so the hero used + // to be a broken image for every clip, poster or not. + const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' } + const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] })) + const images = container.querySelectorAll('img') + expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail') + expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail') + expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2) + }) + + it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => { + const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null } + const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] })) + const images = container.querySelectorAll('img') + // The second photo's strip thumbnail is the only image left. + expect(images).toHaveLength(1) + expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail') + expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2) + }) }) diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index e4ee45705..a6cad6306 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -7,6 +7,7 @@ import { import JournalBody from './JournalBody' import { useTranslation } from '../../i18n' import { formatLocationName } from '../../utils/formatters' +import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' // Labels are translation keys, like the desktop tables in @@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo }`} onClick={() => onPhotoClick(photos, 0)} > - + {/* A clip is drawn from its poster, its original being the video file. + Without a poster the black ground and the play badge are the hero (#2341). */} + {posterlessVideo(photos[0]) ? ( + + ) : ( + + )} {photos[0].media_type === 'video' && (
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo }`} onClick={() => onPhotoClick(photos, i)} > - + {!posterlessVideo(p) && ( + + )} {p.media_type === 'video' && (
diff --git a/client/src/components/Journey/VideoPlayer.test.tsx b/client/src/components/Journey/VideoPlayer.test.tsx index 738f2db4e..00a5fc999 100644 --- a/client/src/components/Journey/VideoPlayer.test.tsx +++ b/client/src/components/Journey/VideoPlayer.test.tsx @@ -3,10 +3,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const plyrCtor = vi.fn() vi.mock('plyr', () => ({ default: class { + el: HTMLElement + original: Node constructor(el: HTMLElement, options: Record) { plyrCtor(el, options) + this.el = el + this.original = el.cloneNode(true) + } + // What the real destroy() does to the document: the element as it was at + // construction goes back in, whatever React wrote to it since. + destroy() { + this.el.replaceWith(this.original) } - destroy = vi.fn() }, })) vi.mock('plyr/dist/plyr.css', () => ({})) @@ -31,7 +39,7 @@ describe('VideoPlayer', () => { it('resets the element to an empty source on teardown rather than a clip on the CDN', () => { // media-src refuses cdn.plyr.io too; an empty source aborts the stream just the - // same, and the element is unmounted in the same tick. + // same, and the element is out of the document by then. render() const [, options] = plyrCtor.mock.calls[0] @@ -45,4 +53,19 @@ describe('VideoPlayer', () => { expect(el).toBe(container.querySelector('video')) expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail') }) + + it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => { + // Rebuilding Plyr on the same