diff --git a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts new file mode 100644 index 000000000..75676c784 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts @@ -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 +>(); + +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 + ) => { + ipcHandlers.set(channel, handler); + } + ); + + EpgEvents.bootstrapEpgEvents(); + }); + + function invoke(channel: string, args: unknown): Promise { + 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; + + 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']); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 531792cd1..f2e2c17f7 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -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, + 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> { - 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 = {}; + 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 { - 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(); + 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 { diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts index f53736621..0f1c6c18e 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -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 () => { diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 4a4573af1..f66c1e585 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -208,25 +208,25 @@ export class EpgRuntimeBridgeService { } getProgramsForChannels( - window: ElectronBridgeEpgGuideWindow + guideWindow: ElectronBridgeEpgGuideWindow ): Promise | 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 { - 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( diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index a984ba846..159f47b20 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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;