Files
iptvnator/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts
T
4grayandClaude Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): add programme guide implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add window-scoped guide programme queries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): separate coverage chunk size in the guide plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): guide search hits carry a row id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add a Guide button to the timeline toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): adapt the playlist channel list to the guide contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): document the programme guide and its release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* i18n(epg): translate the programme guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(epg): tidy guide docs, palette gating and the unbound output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): describe guide row ids as scope-local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00

220 lines
8.1 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
describe('EpgRuntimeBridgeService', () => {
let service: EpgRuntimeBridgeService;
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
const originalElectron = window.electron;
beforeEach(() => {
runtimeCapabilities = {
supportsEpgImport: false,
supportsEpgProgress: false,
supportsEpgProgramLookup: false,
supportsEpgCurrentProgramBatch: false,
supportsEpgChannelMetadata: false,
supportsEpgSourceFreshness: false,
supportsEpgDataManagement: false,
supportsEpgGuide: false,
supportsEpgProgramSearch: false,
};
TestBed.configureTestingModule({
providers: [
EpgRuntimeBridgeService,
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
],
});
service = TestBed.inject(EpgRuntimeBridgeService);
});
afterEach(() => {
window.electron = originalElectron;
TestBed.resetTestingModule();
jest.restoreAllMocks();
});
it('does not call Electron EPG methods when the matching capability is unavailable', async () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: [],
});
window.electron = {
...window.electron,
fetchEpg,
checkEpgFreshness,
} as unknown as typeof window.electron;
await expect(
service.fetchEpg(['https://example.com/epg.xml'])
).resolves.toBeNull();
await expect(
service.checkFreshness(['https://example.com/epg.xml'], 12)
).resolves.toBeNull();
expect(fetchEpg).not.toHaveBeenCalled();
expect(checkEpgFreshness).not.toHaveBeenCalled();
});
it('delegates import and data-management calls through the typed Electron bridge', async () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const forceFetchEpg = jest.fn().mockResolvedValue({ success: true });
const clearEpgData = jest.fn().mockResolvedValue({ success: true });
const clearEpgDataForSource = jest
.fn()
.mockResolvedValue({ success: true });
window.electron = {
...window.electron,
fetchEpg,
forceFetchEpg,
clearEpgData,
clearEpgDataForSource,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgImport = true;
runtimeCapabilities.supportsEpgDataManagement = true;
await expect(
service.fetchEpg(['https://example.com/epg.xml'])
).resolves.toEqual({ success: true });
await expect(
service.forceFetchEpg('https://example.com/epg.xml')
).resolves.toEqual({ success: true });
await expect(service.clearEpgData()).resolves.toEqual({
success: true,
});
await expect(
service.clearEpgDataForSource(
' https://playlist.example.com/guide.xml '
)
).resolves.toEqual({
success: true,
});
expect(fetchEpg).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
undefined
);
expect(forceFetchEpg).toHaveBeenCalledWith(
'https://example.com/epg.xml',
undefined
);
expect(clearEpgData).toHaveBeenCalledTimes(1);
expect(clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/guide.xml'
);
});
it('delegates read-side EPG calls through the typed Electron bridge', async () => {
const getChannelPrograms = jest.fn().mockResolvedValue([]);
const getCurrentProgramsBatch = jest.fn().mockResolvedValue({
'channel-1': null,
});
const getEpgChannelMetadata = jest.fn().mockResolvedValue({
'channel-1': null,
});
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
});
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
getChannelPrograms,
getCurrentProgramsBatch,
getEpgChannelMetadata,
checkEpgFreshness,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgProgramLookup = true;
runtimeCapabilities.supportsEpgCurrentProgramBatch = true;
runtimeCapabilities.supportsEpgChannelMetadata = true;
runtimeCapabilities.supportsEpgSourceFreshness = true;
runtimeCapabilities.supportsEpgProgramSearch = true;
await service.getChannelPrograms('channel-1');
await service.getCurrentProgramsBatch(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.getChannelMetadata(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.checkFreshness(['https://example.com/epg.xml'], 12);
await service.searchPrograms('news', 20);
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
expect(checkEpgFreshness).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
12
);
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
});
it('forwards guide reads when the runtime supports them', async () => {
const getEpgProgramsForChannels = jest
.fn()
.mockResolvedValue({ a: [] });
const getEpgProgramCoverage = jest.fn().mockResolvedValue(['a']);
window.electron = {
...window.electron,
getEpgProgramsForChannels,
getEpgProgramCoverage,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgGuide = true;
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(guideWindow);
expect(getEpgProgramCoverage).toHaveBeenCalledWith(guideWindow);
});
it('answers null for guide reads without runtime support or channels', async () => {
runtimeCapabilities.supportsEpgGuide = false;
await expect(
service.getProgramsForChannels({
channelIds: ['a'],
fromMs: 1,
toMs: 2,
})
).resolves.toBeNull();
runtimeCapabilities.supportsEpgGuide = true;
await expect(
service.getProgramCoverage({ channelIds: [], fromMs: 1, toMs: 2 })
).resolves.toBeNull();
});
it('subscribes to EPG progress only when progress events are supported', () => {
const onEpgProgress = jest.fn();
window.electron = {
...window.electron,
onEpgProgress,
} as unknown as typeof window.electron;
service.onProgress(jest.fn());
expect(onEpgProgress).not.toHaveBeenCalled();
runtimeCapabilities.supportsEpgProgress = true;
const callback = jest.fn();
service.onProgress(callback);
expect(onEpgProgress).toHaveBeenCalledWith(callback);
});
});