feat(525): SPA previews saved logos, drops stale external-URL copy

This commit is contained in:
2026-07-21 13:13:21 +02:00
parent b54c06b5ec
commit 3fb2145e50
2 changed files with 109 additions and 13 deletions
+107 -6
View File
@@ -552,7 +552,7 @@ describe('ChannelEditScreen', () => {
expect(preview.style.top).toBe('');
});
it('does not render the preview when an external logo URL is set', async () => {
it('does not offer the stale "external URL cannot drive the bug" copy', async () => {
mockApi({
channelOverrides: {
logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' },
@@ -565,15 +565,116 @@ describe('ChannelEditScreen', () => {
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
// Preview renders from the uploaded logo first.
await screen.findByAltText('On-screen bug preview');
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
// #525: a saved external URL is downloaded server-side and comes back as a normal cached
// logo, so the bug preview works fine for it — the old blanket "cannot be used" claim is gone.
expect(screen.queryByText(/cannot be used as the on-screen bug/i)).toBeNull();
});
it('renders the bug preview for a logo that was saved via an external URL', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: {
logo: { path: '', contentType: '' },
watermarkId: 9
},
watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }],
onPut: (body) => puts.push(body),
// The PUT response models what the backend now does (#525): the external URL was
// downloaded and cached server-side, so the saved channel comes back with a normal,
// non-external cached logo rather than the URL itself.
putResponseOverrides: {
logo: { path: 'iptv/logos/downloaded.png', contentType: 'image/png', isExternalUrl: false },
watermarkId: 9
}
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
// An external URL can never resolve to a real bug (see the comment on the WatermarkSelector
// File.Exists behavior), so the preview must disappear once one is set.
await waitFor(() => expect(screen.queryByAltText('On-screen bug preview')).toBeNull());
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect(await screen.findByText('Channel saved')).toBeInTheDocument();
const preview = await screen.findByAltText('On-screen bug preview');
expect(preview).toHaveAttribute('src', 'iptv/logos/downloaded.png');
// The URL field is cleared post-save since the channel's logo is no longer external.
expect(screen.getByPlaceholderText('https://example.com/logo.png')).toHaveValue('');
});
it('shows a 400 save error inline near the URL field and does not navigate away', async () => {
mockApi({
channelOverrides: { logo: { path: '', contentType: '' } }
});
vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/v1/channels/5' && method === 'PUT') {
return Promise.resolve(
json({ status: 400, title: 'Bad Request', detail: 'Could not download logo: host timed out' }, 400)
);
}
if (url === '/api/v1/channels/5') {
return Promise.resolve(json({ ...channel, logo: { path: '', contentType: '' } }));
}
if (url === '/api/v1/ffmpeg/profiles') {
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
}
if (url === '/api/v1/watermarks') {
return Promise.resolve(json([{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]));
}
if (url === '/api/v1/filler-presets') {
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url === '/api/v1/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
if (url === '/api/v1/languages') {
return Promise.resolve(json([{ code: 'eng', englishName: 'English' }]));
}
if (url === '/api/v1/channels/music-video-credits-templates') {
return Promise.resolve(json(['default']));
}
if (url === '/api/v1/channels/stream-selectors') {
return Promise.resolve(json(['selector.py']));
}
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/bad-logo.png' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
expect(await screen.findByText('Could not download logo: host timed out')).toBeInTheDocument();
// Still on the edit screen, with the failed URL still in the field.
expect(screen.getByPlaceholderText('https://example.com/logo.png')).toHaveValue('https://example.com/bad-logo.png');
expect(screen.queryByText('Channel saved')).toBeNull();
});
});
});
+2 -7
View File
@@ -679,9 +679,6 @@ function BrandingPane({
const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null;
const logoBugEnabled = referenced?.imageSource === 'ChannelLogo';
const logoBugTarget = findLogoBugWatermark(data.watermarks);
// An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed,
// which is never true, so no bug renders (#502). Don't promise one in the preview.
const externalUrlLogo = trimmedUrl.length > 0;
// Keyed by the watermark id it was fetched for, so a stale response (or a disabled toggle) is
// filtered out by comparing against the CURRENT draft.watermarkId at render time — no reset
@@ -797,9 +794,7 @@ function BrandingPane({
help={
logoBugTarget == null
? 'No logo-driven watermark preset exists yet.'
: externalUrlLogo
? 'An external logo URL cannot be used as the on-screen bug — upload an image instead.'
: 'Overlays this channels own logo on the stream, using the shared presets position and size.'
: 'Overlays this channels own logo on the stream, using the shared presets position and size.'
}
label="Use logo as on-screen bug"
>
@@ -814,7 +809,7 @@ function BrandingPane({
}
size="sm"
/>
{logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && (
{logoBugEnabled && bugGeometry && previewSrc && (
// bugGeometry is cached keyed by `id` (see fetchedGeometry above); pass the
// geometry fields explicitly rather than spreading so the cache key never leaks
// into BugPreview's props.