diff --git a/apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts b/apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts new file mode 100644 index 000000000..58e52e566 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts @@ -0,0 +1,237 @@ +/** + * Fetch-timeout and renderer progress-reporting coverage for EpgWorkerService. + * Worker lifecycle and deduplication live in `epg-worker.service.spec.ts`. + */ +const mockWorkerInstances: any[] = []; +const mockGetAllWindows = jest.fn((): any[] => []); +const mockResolveWorkerRuntimeBootstrap = jest.fn(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => mockGetAllWindows(), + }, +})); + +jest.mock('worker_threads', () => { + const { EventEmitter } = require('events'); + + class MockWorker extends EventEmitter { + postMessage = jest.fn(); + terminate = jest.fn().mockResolvedValue(0); + } + + return { + Worker: jest.fn().mockImplementation(() => { + const worker = new MockWorker(); + mockWorkerInstances.push(worker); + return worker; + }), + }; +}); + +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + mockResolveWorkerRuntimeBootstrap(...args), +})); + +import { EpgWorkerService } from './epg-worker.service'; + +describe('EpgWorkerService timeout and progress reporting', () => { + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + const url = 'https://example.com/guide.xml'; + + beforeEach(() => { + jest.clearAllMocks(); + mockWorkerInstances.length = 0; + mockGetAllWindows.mockReturnValue([]); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockResolveWorkerRuntimeBootstrap.mockReturnValue({ + workerPath: '/mock/workers/epg-parser.worker.js', + workerPathCandidates: ['/mock/workers/epg-parser.worker.js'], + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } + + function createRendererWindow() { + return { webContents: { send: jest.fn() } }; + } + + describe('fetch timeout', () => { + it('rejects when the fetch times out without progress', async () => { + jest.useFakeTimers(); + const windows = [createRendererWindow()]; + mockGetAllWindows.mockReturnValue(windows); + + const service = new EpgWorkerService('[Test EPG]', 50); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + jest.advanceTimersByTime(50); + + await expect(fetchPromise).rejects.toThrow( + 'EPG fetch timed out after' + ); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(windows[0].webContents.send).toHaveBeenCalledWith( + 'EPG_PROGRESS_UPDATE', + expect.objectContaining({ + url, + status: 'error', + error: expect.stringContaining('timed out'), + }) + ); + }); + + it('ignores a late worker message after the timeout has settled', async () => { + jest.useFakeTimers(); + + const service = new EpgWorkerService('[Test EPG]', 50); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + jest.advanceTimersByTime(50); + await expect(fetchPromise).rejects.toThrow( + 'EPG fetch timed out after' + ); + + // A completion racing in after the timeout must not re-settle the + // promise or terminate the worker a second time. + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + expect(worker.terminate).toHaveBeenCalledTimes(1); + await expect(fetchPromise).rejects.toThrow( + 'EPG fetch timed out after' + ); + }); + + it('reschedules the timeout only when progress moves forward', async () => { + jest.useFakeTimers(); + + const service = new EpgWorkerService('[Test EPG]', 100); + const fetchPromise = service.fetchEpgFromUrl(url); + const fetchOutcome = fetchPromise.then( + () => 'resolved' as const, + (error: Error) => error.message + ); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + + jest.advanceTimersByTime(60); + worker.emit('message', { + type: 'EPG_PROGRESS', + stats: { totalChannels: 10, totalPrograms: 100 }, + }); + + // Moving progress rescheduled the timeout, so 60ms later the + // fetch is still alive even though 120ms passed overall. + jest.advanceTimersByTime(60); + expect(worker.terminate).not.toHaveBeenCalled(); + + // Stalled progress (same stats) must not reschedule the timeout. + worker.emit('message', { + type: 'EPG_PROGRESS', + stats: { totalChannels: 10, totalPrograms: 100 }, + }); + jest.advanceTimersByTime(60); + + await expect(fetchOutcome).resolves.toContain('timed out'); + expect(worker.terminate).toHaveBeenCalledTimes(1); + }); + }); + + describe('progress reporting', () => { + it('broadcasts progress updates to every renderer window', async () => { + const windows = [createRendererWindow(), createRendererWindow()]; + mockGetAllWindows.mockReturnValue(windows); + + const service = new EpgWorkerService('[Test EPG]', 1000); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_PROGRESS', + stats: { totalChannels: 5, totalPrograms: 50 }, + }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 5, totalPrograms: 55 }, + }); + await fetchPromise; + + for (const win of windows) { + const statuses = win.webContents.send.mock.calls.map( + ([channel, payload]: [string, { status: string }]) => { + expect(channel).toBe('EPG_PROGRESS_UPDATE'); + return payload.status; + } + ); + expect(statuses).toEqual(['loading', 'loading', 'complete']); + } + + expect(windows[0].webContents.send).toHaveBeenCalledWith( + 'EPG_PROGRESS_UPDATE', + expect.objectContaining({ + url, + status: 'complete', + stats: { totalChannels: 5, totalPrograms: 55 }, + }) + ); + }); + + it('forwards EPG_ERROR metadata to the renderer and rejects', async () => { + const windows = [createRendererWindow()]; + mockGetAllWindows.mockReturnValue(windows); + + const service = new EpgWorkerService('[Test EPG]', 1000); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_ERROR', + error: 'untrusted host', + errorCode: 'UNTRUSTED_HOST', + errorHost: 'example.com', + }); + + await expect(fetchPromise).rejects.toThrow('untrusted host'); + await flushPromises(); + expect(windows[0].webContents.send).toHaveBeenCalledWith( + 'EPG_PROGRESS_UPDATE', + expect.objectContaining({ + url, + status: 'error', + error: 'untrusted host', + errorCode: 'UNTRUSTED_HOST', + errorHost: 'example.com', + }) + ); + expect(service.hasFetchedUrl(url)).toBe(false); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-worker.service.spec.ts b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts new file mode 100644 index 000000000..b86bfe67a --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts @@ -0,0 +1,221 @@ +/** + * Worker lifecycle and in-flight deduplication coverage for EpgWorkerService. + * Timeout and renderer progress reporting live in + * `epg-worker.service.progress.spec.ts`. + */ +const mockWorkerInstances: any[] = []; +const mockResolveWorkerRuntimeBootstrap = jest.fn(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})); + +jest.mock('worker_threads', () => { + const { EventEmitter } = require('events'); + + class MockWorker extends EventEmitter { + postMessage = jest.fn(); + terminate = jest.fn().mockResolvedValue(0); + } + + return { + Worker: jest.fn().mockImplementation(() => { + const worker = new MockWorker(); + mockWorkerInstances.push(worker); + return worker; + }), + }; +}); + +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + mockResolveWorkerRuntimeBootstrap(...args), +})); + +import { EpgWorkerService } from './epg-worker.service'; + +describe('EpgWorkerService worker lifecycle', () => { + let service: EpgWorkerService; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + const url = 'https://example.com/guide.xml'; + + beforeEach(() => { + jest.clearAllMocks(); + mockWorkerInstances.length = 0; + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockResolveWorkerRuntimeBootstrap.mockReturnValue({ + workerPath: '/mock/workers/epg-parser.worker.js', + workerPathCandidates: ['/mock/workers/epg-parser.worker.js'], + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }); + service = new EpgWorkerService('[Test EPG]', 1000); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('worker lifecycle', () => { + it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => { + const options = { manuallyTrustedHosts: ['example.com'] } as any; + const fetchPromise = service.fetchEpgFromUrl(url, options); + const worker = mockWorkerInstances[0]; + const { Worker } = jest.requireMock('worker_threads'); + + expect(mockResolveWorkerRuntimeBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + isPackaged: false, + workerFilename: 'epg-parser.worker.js', + appPath: '/mock/app.asar', + }) + ); + expect(Worker).toHaveBeenCalledWith(expect.any(URL), { + resourceLimits: { + maxOldGenerationSizeMb: 4096, + maxYoungGenerationSizeMb: 512, + }, + workerData: { + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }, + }); + + worker.emit('message', { type: 'READY' }); + expect(worker.postMessage).toHaveBeenCalledWith({ + type: 'FETCH_EPG', + url, + options, + }); + + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 3, totalPrograms: 42 }, + }); + + await expect(fetchPromise).resolves.toBeUndefined(); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(service.hasFetchedUrl(url)).toBe(true); + }); + + it('rejects when the worker emits an error event', async () => { + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('error', new Error('worker crashed')); + + await expect(fetchPromise).rejects.toThrow('worker crashed'); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(service.hasFetchedUrl(url)).toBe(false); + }); + + it('rejects when the worker exits before settling', async () => { + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('exit', 7); + + await expect(fetchPromise).rejects.toThrow( + 'Worker exited unexpectedly (code 7)' + ); + }); + + it('rejects and clears the in-flight entry when worker construction fails', async () => { + const { Worker } = jest.requireMock('worker_threads'); + Worker.mockImplementationOnce(() => { + throw new Error('cannot spawn worker'); + }); + + await expect(service.fetchEpgFromUrl(url)).rejects.toThrow( + 'cannot spawn worker' + ); + + // The failed fetch must not poison future fetches for the URL. + const retryPromise = service.fetchEpgFromUrl(url); + expect(mockWorkerInstances).toHaveLength(1); + + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + await expect(retryPromise).resolves.toBeUndefined(); + }); + }); + + describe('in-flight deduplication', () => { + it('shares one worker between concurrent fetches of the same URL', async () => { + const firstPromise = service.fetchEpgFromUrl(url); + const secondPromise = service.fetchEpgFromUrl(url); + + expect(mockWorkerInstances).toHaveLength(1); + + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + + await expect(firstPromise).resolves.toBeUndefined(); + await expect(secondPromise).resolves.toBeUndefined(); + expect(mockWorkerInstances).toHaveLength(1); + }); + + it('spawns separate workers for different URLs fetched concurrently', async () => { + const otherUrl = 'https://example.org/other-guide.xml'; + const firstPromise = service.fetchEpgFromUrl(url); + const secondPromise = service.fetchEpgFromUrl(otherUrl); + + expect(mockWorkerInstances).toHaveLength(2); + + for (const worker of mockWorkerInstances) { + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + } + + await expect(firstPromise).resolves.toBeUndefined(); + await expect(secondPromise).resolves.toBeUndefined(); + }); + + it('skips already fetched URLs until they are invalidated', async () => { + const firstPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + await firstPromise; + + await expect(service.fetchEpgFromUrl(url)).resolves.toBeUndefined(); + expect(mockWorkerInstances).toHaveLength(1); + + service.deleteFetchedUrl(url); + const refetchPromise = service.fetchEpgFromUrl(url); + expect(mockWorkerInstances).toHaveLength(2); + + // Settle the refetch so no timeout leaks past the test. + mockWorkerInstances[1].emit('exit', 0); + await expect(refetchPromise).rejects.toThrow( + 'Worker exited unexpectedly' + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index b04fe05b5..efbc14696 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -280,6 +280,11 @@ describe('EpgEvents', () => { releaseFetchTerminate(); await flushPromises(); expect(cleared).toBe(true); + + // Settle the interrupted fetch so its 1s timeout cannot fire (and + // log) after the suite has finished. + fetchWorker.emit('exit', 1); + await flushPromises(); }); it('clears one EPG source through a worker and allows it to be fetched again', async () => { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts new file mode 100644 index 000000000..08fd1e6d9 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts @@ -0,0 +1,110 @@ +import { + canCatchUpProgramme, + epgDialogActionFor, + isWithinArchiveWindow, +} from './epg-archive.util'; + +const DAY_MS = 24 * 60 * 60_000; +const NOW = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); // 28 Jun 2026, 12:00 local + +describe('epg-archive.util', () => { + describe('isWithinArchiveWindow', () => { + it('treats archiveDays = 0 as an unlimited window', () => { + // capability flagged but no explicit window → everything is in range + expect(isWithinArchiveWindow(NOW - 365 * DAY_MS, 0, NOW)).toBe( + true + ); + expect(isWithinArchiveWindow(0, 0, NOW)).toBe(true); + }); + + it('treats negative archiveDays as unlimited too', () => { + expect(isWithinArchiveWindow(NOW - 365 * DAY_MS, -1, NOW)).toBe( + true + ); + }); + + it('rejects everything when archiveDays is undefined (NaN window)', () => { + // undefined is not <= 0, and NaN comparisons are always false + const archiveDays = undefined as unknown as number; + expect(isWithinArchiveWindow(NOW, archiveDays, NOW)).toBe(false); + }); + + it('accepts a start exactly on the window edge (inclusive)', () => { + const edge = NOW - 7 * DAY_MS; + expect(isWithinArchiveWindow(edge, 7, NOW)).toBe(true); + }); + + it('accepts starts inside the window', () => { + expect(isWithinArchiveWindow(NOW - 3 * DAY_MS, 7, NOW)).toBe(true); + expect(isWithinArchiveWindow(NOW - 1, 7, NOW)).toBe(true); + }); + + it('rejects starts just outside the window (expired)', () => { + expect(isWithinArchiveWindow(NOW - 7 * DAY_MS - 1, 7, NOW)).toBe( + false + ); + expect(isWithinArchiveWindow(NOW - 30 * DAY_MS, 7, NOW)).toBe( + false + ); + }); + }); + + describe('canCatchUpProgramme', () => { + const inWindowStart = NOW - 2 * 60 * 60_000; // 2h ago, well inside 7 days + + it('allows catch-up when every gate passes', () => { + expect( + canCatchUpProgramme('past', inWindowStart, true, 7, NOW) + ).toBe(true); + }); + + it('denies when archive playback is unavailable', () => { + expect( + canCatchUpProgramme('past', inWindowStart, false, 7, NOW) + ).toBe(false); + }); + + it('denies when the programme is not in the past', () => { + expect( + canCatchUpProgramme('now', inWindowStart, true, 7, NOW) + ).toBe(false); + expect( + canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW) + ).toBe(false); + }); + + it('denies when the start is outside the archive window', () => { + expect( + canCatchUpProgramme('past', NOW - 8 * DAY_MS, true, 7, NOW) + ).toBe(false); + }); + + it('allows arbitrarily old past programmes with an unlimited window', () => { + expect( + canCatchUpProgramme('past', NOW - 100 * DAY_MS, true, 0, NOW) + ).toBe(true); + }); + }); + + describe('epgDialogActionFor', () => { + it('maps an on-air programme to the live action', () => { + expect(epgDialogActionFor('now', false)).toBe('live'); + }); + + it('prefers live over timeshift for an on-air programme', () => { + expect(epgDialogActionFor('now', true)).toBe('live'); + }); + + it('maps a catch-up-able past programme to timeshift', () => { + expect(epgDialogActionFor('past', true)).toBe('timeshift'); + }); + + it('offers no action for a past programme without catch-up', () => { + expect(epgDialogActionFor('past', false)).toBeNull(); + }); + + it('offers no action for a future programme', () => { + expect(epgDialogActionFor('future', false)).toBeNull(); + }); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts new file mode 100644 index 000000000..3ab036664 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts @@ -0,0 +1,192 @@ +import { + EpgTimelineSummary, + formatClockTime, + summaryHasTimeRange, + summaryHasTitle, + summaryMinutesLeft, + summaryProgress, +} from './epg-summary.util'; + +const HOUR_MS = 60 * 60_000; +const NOW = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); // 28 Jun 2026, 12:00 local +const START = NOW - HOUR_MS; // 11:00 +const STOP = NOW + HOUR_MS; // 13:00 + +function summary(overrides: EpgTimelineSummary = {}): EpgTimelineSummary { + return { title: 'Show', start: START, stop: STOP, ...overrides }; +} + +describe('epg-summary.util', () => { + describe('summaryProgress', () => { + it('returns null for a missing summary', () => { + expect(summaryProgress(null, NOW)).toBeNull(); + expect(summaryProgress(undefined, NOW)).toBeNull(); + }); + + it('computes elapsed percentage between start and stop', () => { + // 11:00 → 13:00, now 12:00 → halfway + expect(summaryProgress(summary(), NOW)).toBe(50); + expect(summaryProgress(summary(), START + HOUR_MS / 2)).toBe(25); + }); + + it('returns 0 at the exact start and 100 at the exact stop', () => { + expect(summaryProgress(summary(), START)).toBe(0); + expect(summaryProgress(summary(), STOP)).toBe(100); + }); + + it('clamps to 0 before the start and 100 after the stop', () => { + expect(summaryProgress(summary(), START - HOUR_MS)).toBe(0); + expect(summaryProgress(summary(), STOP + HOUR_MS)).toBe(100); + }); + + it('returns null when start or stop is missing', () => { + expect(summaryProgress(summary({ start: null }), NOW)).toBeNull(); + expect( + summaryProgress(summary({ stop: undefined }), NOW) + ).toBeNull(); + expect(summaryProgress(summary({ start: '' }), NOW)).toBeNull(); + }); + + it('returns null for unparsable dates', () => { + expect( + summaryProgress(summary({ start: 'not-a-date' }), NOW) + ).toBeNull(); + expect( + summaryProgress(summary({ stop: new Date(NaN) }), NOW) + ).toBeNull(); + }); + + it('returns null for zero or negative duration', () => { + expect( + summaryProgress(summary({ start: NOW, stop: NOW }), NOW) + ).toBeNull(); + expect( + summaryProgress(summary({ start: STOP, stop: START }), NOW) + ).toBeNull(); + }); + + it('accepts ISO strings and Date objects as time inputs', () => { + const iso = summary({ + start: new Date(START).toISOString(), + stop: new Date(STOP), + }); + expect(summaryProgress(iso, NOW)).toBe(50); + }); + + it('prefers an explicit progress value over the time range', () => { + expect(summaryProgress(summary({ progress: 42 }), NOW)).toBe(42); + }); + + it('clamps an explicit progress value into 0–100', () => { + expect(summaryProgress(summary({ progress: 150 }), NOW)).toBe(100); + expect(summaryProgress(summary({ progress: -5 }), NOW)).toBe(0); + }); + + it('coerces an explicit null progress to 0 (Number(null) is finite)', () => { + expect(summaryProgress(summary({ progress: null }), NOW)).toBe(0); + }); + + it('falls back to the time range when progress is undefined', () => { + expect(summaryProgress(summary({ progress: undefined }), NOW)).toBe( + 50 + ); + }); + }); + + describe('summaryMinutesLeft', () => { + it('returns null without a summary or stop time', () => { + expect(summaryMinutesLeft(null, NOW)).toBeNull(); + expect(summaryMinutesLeft(undefined, NOW)).toBeNull(); + expect( + summaryMinutesLeft(summary({ stop: null }), NOW) + ).toBeNull(); + expect( + summaryMinutesLeft(summary({ stop: 'garbage' }), NOW) + ).toBeNull(); + }); + + it('returns whole minutes until the stop', () => { + expect(summaryMinutesLeft(summary(), NOW)).toBe(60); + }); + + it('rounds to the nearest minute', () => { + expect( + summaryMinutesLeft(summary({ stop: NOW + 90_000 }), NOW) + ).toBe(2); // 1.5 min rounds up + expect( + summaryMinutesLeft(summary({ stop: NOW + 89_000 }), NOW) + ).toBe(1); // ~1.48 min rounds down + }); + + it('never goes below zero once the programme ended', () => { + expect( + summaryMinutesLeft(summary({ stop: NOW - HOUR_MS }), NOW) + ).toBe(0); + }); + + it('returns 0 exactly at the stop', () => { + expect(summaryMinutesLeft(summary({ stop: NOW }), NOW)).toBe(0); + }); + }); + + describe('summaryHasTitle', () => { + it('is false for missing summaries and titles', () => { + expect(summaryHasTitle(null)).toBe(false); + expect(summaryHasTitle(undefined)).toBe(false); + expect(summaryHasTitle(summary({ title: null }))).toBe(false); + expect(summaryHasTitle(summary({ title: undefined }))).toBe(false); + }); + + it('is false for blank titles', () => { + expect(summaryHasTitle(summary({ title: '' }))).toBe(false); + expect(summaryHasTitle(summary({ title: ' ' }))).toBe(false); + }); + + it('is true for a non-blank title', () => { + expect(summaryHasTitle(summary({ title: 'News' }))).toBe(true); + }); + }); + + describe('summaryHasTimeRange', () => { + it('is false without a summary or any time', () => { + expect(summaryHasTimeRange(null)).toBe(false); + expect(summaryHasTimeRange(undefined)).toBe(false); + expect( + summaryHasTimeRange({ title: 'x', start: null, stop: null }) + ).toBe(false); + }); + + it('is true when either start or stop is set', () => { + expect( + summaryHasTimeRange({ start: START, stop: null }) + ).toBe(true); + expect(summaryHasTimeRange({ start: null, stop: STOP })).toBe( + true + ); + }); + + it('treats a numeric 0 timestamp as absent (truthiness check)', () => { + expect(summaryHasTimeRange({ start: 0, stop: null })).toBe(false); + }); + }); + + describe('formatClockTime', () => { + it('formats a local HH:MM label', () => { + expect( + formatClockTime(new Date(2026, 5, 28, 12, 34).getTime()) + ).toBe('12:34'); + }); + + it('zero-pads single-digit hours and minutes', () => { + expect( + formatClockTime(new Date(2026, 5, 28, 9, 5).getTime()) + ).toBe('09:05'); + }); + + it('renders midnight as 00:00', () => { + expect( + formatClockTime(new Date(2026, 5, 28, 0, 0).getTime()) + ).toBe('00:00'); + }); + }); +});