test(epg): cover archive/summary utils and the EPG worker service

Add specs for the pure catch-up window and summary-progress helpers shared
by the EPG panels, and for epg-worker.service: in-flight dedup by URL,
double-settle guard, progress-aware timeouts, worker lifecycle and error
broadcasting. Also settle an interrupted fetch in epg.events.spec that
caused "Cannot log after tests are done" in longer runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 11:12:35 +02:00
1 parent 463ddd077f
commit 1761fdffb8
5 files changed
+765

No files matched your search

@@ -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<void> {
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);
});
});
});
@@ -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'
);
});
});
});
@@ -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 () => {
@@ -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();
});
});
});
@@ -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');
});
});
});