diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx index 72e66ccc0..c40831ad9 100644 --- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx +++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx @@ -273,6 +273,45 @@ describe('ChannelPreviewPanel', () => { } }); + // The `state === 'starting'` guard is the hint's ONLY guard, so every path back to `starting` has + // to clear the flag itself. Retry is one: it is a fresh attempt that was never blocked, and a hint + // carried over from the previous attempt would name a cause that did not happen this time. + it('clicking Retry clears the autoplay-blocked hint', async () => { + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('blocked', 'NotAllowedError')); + + try { + await renderPanel( + + ); + + const manifestHandler = getManifestHandler(); + await act(async () => { + manifestHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText(/autoplay was blocked/i)).toBeInTheDocument(); + + // The retried attempt is not blocked. + playSpy.mockResolvedValue(undefined); + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + await flush(); + + // The player really did re-mount, so the hint is absent because the flag was cleared — not + // because the whole `resolvedSrc` block is still unrendered. + expect(hlsMock.loadSource).toHaveBeenCalledTimes(2); + expect(document.querySelector('video')).not.toBeNull(); + expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument(); + // Retry returns to `starting`, so the render guard is not what hid the hint here. + expect(screen.getByText('starting')).toBeInTheDocument(); + } finally { + playSpy.mockRestore(); + } + }); + // A play() interrupted by teardown rejects with AbortError, which is exactly what Retry produces // while the manifest-parsed play() is still pending. The hint names blocked autoplay as the cause, // so it must not appear for a rejection that was not the autoplay policy. @@ -495,6 +534,47 @@ describe('ChannelPreviewPanel', () => { expect(screen.getByText('playing')).toBeInTheDocument(); }); + // The other path back to `starting`, and the other half of the hint's single-guard invariant: a + // switch must not leave the previous channel's hint standing over a new channel that was never + // blocked. The render-phase reset owns this clear — the guard cannot help, since the new channel + // starts in `starting` too. + it('clears the autoplay-blocked hint when switching to a different channel', async () => { + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('blocked', 'NotAllowedError')); + + try { + const { rerender } = await renderPanel( + + ); + + const manifestHandler = getManifestHandler(); + await act(async () => { + manifestHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText(/autoplay was blocked/i)).toBeInTheDocument(); + + // The new channel's attempt is not blocked. + playSpy.mockResolvedValue(undefined); + rerender( + + ); + await flush(); + + // The new channel's player really is mounted, so the hint is absent because the flag was + // cleared — not because the `resolvedSrc` block is still unrendered. + expect(hlsMock.loadSource).toHaveBeenLastCalledWith('/iptv/channel/34.1.m3u8'); + expect(document.querySelector('video')).not.toBeNull(); + expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument(); + expect(screen.getByText('starting')).toBeInTheDocument(); + } finally { + playSpy.mockRestore(); + } + }); + it('does not carry the retry play token over to the next channel', async () => { // Both channels share a manifest URL so the only thing that could re-trigger the attach // effect across the switch is playToken. If the reset failed to zero it out, the token