fix(epg): key guide IPC answers by trimmed, present keys only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:21:30 +02:00
1 parent 59f27d1549
commit 53df760cd8
5 files changed
+251 -27

No files matched your search

@@ -0,0 +1,175 @@
import type EpgEventsType from './epg.events';
const getProgramsForChannels = jest.fn();
const getProgramCoverage = jest.fn();
const resolveChannelIds = jest.fn();
const ipcHandlers = new Map<
string,
(event: unknown, args: unknown) => Promise<unknown>
>();
jest.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/mock/app.asar',
},
BrowserWindow: {
getAllWindows: () => [],
},
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('./epg-guide-query.service', () => ({
epgGuideQueryService: {
getProgramsForChannels: (...args: unknown[]) =>
getProgramsForChannels(...args),
getProgramCoverage: (...args: unknown[]) =>
getProgramCoverage(...args),
},
}));
jest.mock('./epg-mapping.service', () => ({
resolveChannelIds: (...args: unknown[]) => resolveChannelIds(...args),
queryByResolvedChannelIds: jest.fn(),
handleGetEpgMapping: jest.fn(),
handleGetEpgMappingsBatch: jest.fn(),
handleSetEpgMapping: jest.fn(),
handleDeleteEpgMapping: jest.fn(),
handleSearchEpgChannels: jest.fn(),
}));
/**
* `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` apply manual
* mappings (`resolveChannelIds`) before delegating to `epgGuideQueryService`,
* then key the answer back by the verbatim requested id. The service keys
* its own answer by the TRIMMED, deduplicated form of the ids it was given
* (`normalizeGuideWindow`), so the handler must resolve the same trimmed key
* to find that answer — these tests pin the trim mismatch, the "absent
* means not queried" contract, and the coverage de-duplication.
*/
describe('EPG guide IPC handlers', () => {
let EpgEvents: typeof EpgEventsType;
const program = {
channelId: 'xmltv.id',
title: 'News',
start: '2026-01-01T00:00:00.000Z',
stop: '2026-01-01T01:00:00.000Z',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
};
beforeEach(async () => {
jest.resetModules();
ipcHandlers.clear();
[getProgramsForChannels, getProgramCoverage, resolveChannelIds].forEach(
(mock) => mock.mockReset()
);
resolveChannelIds.mockResolvedValue(new Map());
({ default: EpgEvents } = await import('./epg.events'));
const { ipcMain } = jest.requireMock('electron');
(ipcMain.handle as jest.Mock).mockReset();
(ipcMain.handle as jest.Mock).mockImplementation(
(
channel: string,
handler: (event: unknown, args: unknown) => Promise<unknown>
) => {
ipcHandlers.set(channel, handler);
}
);
EpgEvents.bootstrapEpgEvents();
});
function invoke(channel: string, args: unknown): Promise<unknown> {
const handler = ipcHandlers.get(channel);
if (!handler) {
throw new Error(`No IPC handler registered for "${channel}"`);
}
return handler({}, args);
}
it('applies a manual mapping before querying and keys the answer back by the requested key', async () => {
resolveChannelIds.mockResolvedValue(
new Map([['m3u-key', 'xmltv.id']])
);
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
channelIds: ['m3u-key'],
fromMs: 0,
toMs: 1,
});
expect(getProgramsForChannels).toHaveBeenCalledWith(
expect.objectContaining({ channelIds: ['xmltv.id'] })
);
expect(result).toEqual({ 'm3u-key': [program] });
});
it('gives two requested keys resolving to one target their own array reference', async () => {
resolveChannelIds.mockResolvedValue(
new Map([
['a', 'xmltv.id'],
['b', 'xmltv.id'],
])
);
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
const result = (await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
channelIds: ['a', 'b'],
fromMs: 0,
toMs: 1,
})) as Record<string, unknown[]>;
expect(result['a']).toEqual([program]);
expect(result['b']).toEqual([program]);
expect(result['a']).not.toBe(result['b']);
});
it('trims a padded requested id to find the service answer keyed by the trimmed form', async () => {
getProgramsForChannels.mockResolvedValue({ CNN: [program] });
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
channelIds: [' CNN '],
fromMs: 0,
toMs: 1,
});
expect(getProgramsForChannels).toHaveBeenCalledWith(
expect.objectContaining({ channelIds: [' CNN '] })
);
expect(result).toEqual({ ' CNN ': [program] });
});
it('omits a key the service did not answer for instead of filling it with []', async () => {
getProgramsForChannels.mockResolvedValue({});
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
channelIds: ['dropped'],
fromMs: 0,
toMs: 1,
});
expect(result).toEqual({});
});
it('de-duplicates requested coverage keys and keeps only the covered ones', async () => {
getProgramCoverage.mockResolvedValue(['a']);
const result = await invoke('EPG_GET_PROGRAM_COVERAGE', {
channelIds: ['a', 'a', 'b'],
fromMs: 0,
toMs: 1,
});
expect(result).toEqual(['a']);
});
});
@@ -259,30 +259,58 @@ export default class EpgEvents {
);
}
/**
* The guide query service keys its answer by the TRIMMED, deduplicated,
* cap-respecting form of the channel ids it was given
* (`normalizeGuideWindow`). Handlers must resolve the same trimmed key
* to look up that answer, or a padded request id (`" CNN "`) would never
* find its entry even though the service queried it successfully.
*/
private static resolvedGuideKey(
mapping: Map<string, string>,
id: unknown
): string {
return typeof id === 'string' ? (mapping.get(id) ?? id).trim() : '';
}
/**
* Guide reads take playlist channel keys. Manual mappings are applied
* here, before the query, and the answer is keyed back by the requested
* key so the renderer never sees a mapped id.
* key so the renderer never sees a mapped id. A key the service did not
* answer for (cut by its per-request cap) stays absent from the result
* rather than being filled with `[]`, so callers can tell "queried,
* nothing found" apart from "not queried at all".
*/
private static async handleGetGuidePrograms(
args: ElectronBridgeEpgGuideWindow
): Promise<Record<string, EpgProgram[]>> {
const requested = Array.isArray(args?.channelIds) ? args.channelIds : [];
const requested = Array.isArray(args?.channelIds)
? args.channelIds
: [];
const mapping = await resolveChannelIds(requested);
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
const programs = await epgGuideQueryService.getProgramsForChannels({
...args,
channelIds: resolvedIds,
});
return Object.fromEntries(
requested.map((id) => [id, programs[mapping.get(id) ?? id] ?? []])
);
const answer: Record<string, EpgProgram[]> = {};
for (const id of requested) {
const key = this.resolvedGuideKey(mapping, id);
if (Object.prototype.hasOwnProperty.call(programs, key)) {
// Copy so two requested ids resolving to one target never
// share an array reference.
answer[String(id)] = [...programs[key]];
}
}
return answer;
}
private static async handleGetGuideCoverage(
args: ElectronBridgeEpgGuideWindow
): Promise<string[]> {
const requested = Array.isArray(args?.channelIds) ? args.channelIds : [];
const requested = Array.isArray(args?.channelIds)
? args.channelIds
: [];
const mapping = await resolveChannelIds(requested);
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
const covered = new Set(
@@ -291,7 +319,18 @@ export default class EpgEvents {
channelIds: resolvedIds,
})
);
return requested.filter((id) => covered.has(mapping.get(id) ?? id));
const seen = new Set<string>();
const answer: string[] = [];
for (const id of requested) {
if (seen.has(id)) {
continue;
}
seen.add(id);
if (covered.has(this.resolvedGuideKey(mapping, id))) {
answer.push(id);
}
}
return answer;
}
static async clearEpgData(): Promise<void> {
@@ -173,15 +173,15 @@ describe('EpgRuntimeBridgeService', () => {
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgGuide = true;
const window_ = { channelIds: ['a'], fromMs: 1, toMs: 2 };
await expect(service.getProgramsForChannels(window_)).resolves.toEqual(
{ a: [] }
);
await expect(service.getProgramCoverage(window_)).resolves.toEqual([
const guideWindow = { channelIds: ['a'], fromMs: 1, toMs: 2 };
await expect(
service.getProgramsForChannels(guideWindow)
).resolves.toEqual({ a: [] });
await expect(service.getProgramCoverage(guideWindow)).resolves.toEqual([
'a',
]);
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(window_);
expect(getEpgProgramCoverage).toHaveBeenCalledWith(window_);
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(guideWindow);
expect(getEpgProgramCoverage).toHaveBeenCalledWith(guideWindow);
});
it('answers null for guide reads without runtime support or channels', async () => {
@@ -208,25 +208,25 @@ export class EpgRuntimeBridgeService {
}
getProgramsForChannels(
window: ElectronBridgeEpgGuideWindow
guideWindow: ElectronBridgeEpgGuideWindow
): Promise<Record<string, EpgProgram[]> | null> {
if (!this.supportsGuide || window.channelIds.length === 0) {
if (!this.supportsGuide || guideWindow.channelIds.length === 0) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgProgramsForChannels?.(window) ??
this.bridge?.getEpgProgramsForChannels?.(guideWindow) ??
Promise.resolve(null)
);
}
getProgramCoverage(
window: ElectronBridgeEpgGuideWindow
guideWindow: ElectronBridgeEpgGuideWindow
): Promise<string[] | null> {
if (!this.supportsGuide || window.channelIds.length === 0) {
if (!this.supportsGuide || guideWindow.channelIds.length === 0) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgProgramCoverage?.(window) ??
this.bridge?.getEpgProgramCoverage?.(guideWindow) ??
Promise.resolve(null)
);
}
@@ -252,7 +252,9 @@ export class EpgRuntimeBridgeService {
return Promise.resolve(null);
}
return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null);
return (
this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null)
);
}
getEpgMappingsBatch(
@@ -278,8 +280,11 @@ export class EpgRuntimeBridgeService {
}
return (
this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ??
Promise.resolve(null)
this.bridge?.setEpgMapping?.(
channelKey,
epgChannelId,
playlistId
) ?? Promise.resolve(null)
);
}
@@ -288,7 +293,9 @@ export class EpgRuntimeBridgeService {
return Promise.resolve(null);
}
return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null);
return (
this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null)
);
}
searchEpgChannels(
@@ -379,9 +379,12 @@ export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgL
nowMs?: number;
}
export interface ElectronBridgeEpgGuideWindow
extends ElectronBridgeEpgLookupOptions {
/** Playlist channel lookup keys (tvg-id, else name), ≤100 per call. */
export interface ElectronBridgeEpgGuideWindow extends ElectronBridgeEpgLookupOptions {
/**
* Playlist channel lookup keys (tvg-id, else name), ≤100 keys for
* programmes, ≤2000 for coverage; larger batches are truncated and the
* cut keys are absent from the answer.
*/
channelIds: string[];
/** Provider-clock window bounds in epoch ms. */
fromMs: number;