test(554): pin both clears the hint's single guard depends on

Dropping the onPlaying clear made the `state === 'starting'` render guard the
hint's only guard, which moves the burden onto the two paths back to
`starting`: each has to clear `autoplayBlocked` itself. Both were assertable
but unasserted — either `setAutoplayBlocked(false)` could be deleted with the
whole panel suite green, so the invariant the code comment and
docs/spa-conventions.md §5b both state was unpinned in both of its named paths.

Add one test per path (Retry; a channel switch), each asserting the hint is
gone while the panel is back at `starting` — so the render guard cannot be
what hid it. Each also asserts the player really re-mounted (loadSource count
/ last URL, plus a non-null <video>), so the hint cannot be absent merely
because the `resolvedSrc` block is unrendered.

Measured on this tree, each mutation caught by exactly one test:
deleting the onRetry clear reds only 'clicking Retry clears the
autoplay-blocked hint' (Tests 1 failed | 25 passed); deleting the
render-phase reset clear reds only 'clears the autoplay-blocked hint when
switching to a different channel' (1 failed | 25 passed); replacing
`autoplayBlocked && state === 'starting' &&` with `autoplayBlocked &&` reds
one test too. Unmutated: 26 passed.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
This commit is contained in:
2026-09-05 02:08:36 +02:00
co-authored by Claude Fable 5.1
parent 4e042fb7de
commit b6b4fb6619
@@ -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(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
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(
<ChannelPreviewPanel channel={channel(available, undefined, 1)} nowPlaying={null} onClose={vi.fn()} open />
);
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(
<ChannelPreviewPanel channel={channel(available2, undefined, 2)} nowPlaying={null} onClose={vi.fn()} open />
);
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