refactor(epg): route renderer calls through runtime bridge

Add typed EPG runtime bridge, split EPG runtime capabilities, migrate renderer EPG callers away from direct window.electron access, and address Greptile review feedback.
This commit is contained in:
4gray authored and GitHub committed 2026-05-24 13:45:51 +03:00
1 parent 87c1c6240d
commit 697eab6e73
20 files changed
+767 -285

No files matched your search

@@ -1,19 +1,26 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { EpgProgressService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgProgressService,
EpgRuntimeBridgeService,
} from '@iptvnator/epg/data-access';
import { EpgSourceStatusComponent } from './epg-source-status.component';
describe('EpgSourceStatusComponent', () => {
let fixture: ComponentFixture<EpgSourceStatusComponent>;
let component: EpgSourceStatusComponent;
let runtimeCapabilities: { supportsEpg: boolean };
let epgBridge: Partial<EpgRuntimeBridgeService>;
const imports = signal([]);
const originalElectron = window.electron;
beforeEach(async () => {
runtimeCapabilities = { supportsEpg: false };
epgBridge = {
checkFreshness: jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
}),
supportsSourceFreshness: false,
};
imports.set([]);
await TestBed.configureTestingModule({
@@ -24,15 +31,14 @@ describe('EpgSourceStatusComponent', () => {
useValue: { imports },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
],
}).compileComponents();
});
afterEach(() => {
window.electron = originalElectron;
fixture?.destroy();
});
@@ -42,41 +48,25 @@ describe('EpgSourceStatusComponent', () => {
fixture.componentRef.setInput('url', url);
}
it('does not check source freshness when runtime EPG support is disabled', async () => {
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
it('does not check source freshness when the EPG bridge cannot check freshness', async () => {
createComponent();
fixture.detectChanges();
await fixture.whenStable();
expect(checkEpgFreshness).not.toHaveBeenCalled();
expect(epgBridge.checkFreshness).not.toHaveBeenCalled();
expect(component.status()).toBe('unknown');
});
it('loads source freshness when runtime EPG support is enabled', async () => {
it('loads source freshness through the EPG runtime bridge', async () => {
const url = 'https://example.com/epg.xml';
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: [url],
staleUrls: [],
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.supportsSourceFreshness = true;
createComponent(url);
fixture.detectChanges();
await fixture.whenStable();
expect(checkEpgFreshness).toHaveBeenCalledWith([url], 12);
expect(epgBridge.checkFreshness).toHaveBeenCalledWith([url], 12);
expect(component.status()).toBe('fresh');
});
});
@@ -11,8 +11,10 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgProgressService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgProgressService,
EpgRuntimeBridgeService,
} from '@iptvnator/epg/data-access';
type BadgeStatus =
| 'loading'
@@ -38,7 +40,7 @@ export class EpgSourceStatusComponent implements OnInit {
readonly url = input.required<string>();
private readonly epgProgress = inject(EpgProgressService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly freshnessLoaded = signal(false);
private readonly isFresh = signal(false);
@@ -80,14 +82,14 @@ export class EpgSourceStatusComponent implements OnInit {
}
async ngOnInit(): Promise<void> {
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsSourceFreshness) {
return;
}
const url = this.url();
if (!url) return;
const result = await window.electron.checkEpgFreshness([url], 12);
this.isFresh.set(result.freshUrls.includes(url));
const result = await this.epgBridge.checkFreshness([url], 12);
this.isFresh.set(result?.freshUrls.includes(url) ?? false);
this.freshnessLoaded.set(true);
}
}
@@ -3,7 +3,7 @@ import { OverlayRef } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
MultiEpgContainerComponent,
isSelectedEpgDayToday,
@@ -23,11 +23,15 @@ describe('isSelectedEpgDayToday', () => {
describe('MultiEpgContainerComponent runtime gates', () => {
let fixture: ComponentFixture<MultiEpgContainerComponent>;
let component: MultiEpgContainerComponent;
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(async () => {
runtimeCapabilities = { supportsEpg: false };
epgBridge = {
getChannelsByRange: jest.fn().mockResolvedValue([]),
searchPrograms: jest.fn().mockResolvedValue([]),
supportsChannelBrowser: false,
supportsProgramSearch: false,
};
await TestBed.configureTestingModule({
imports: [MultiEpgContainerComponent],
@@ -42,8 +46,8 @@ describe('MultiEpgContainerComponent runtime gates', () => {
useValue: { detach: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: TranslateService,
@@ -65,65 +69,50 @@ describe('MultiEpgContainerComponent runtime gates', () => {
});
afterEach(() => {
window.electron = originalElectron;
fixture.destroy();
jest.restoreAllMocks();
jest.useRealTimers();
});
it('does not request EPG channel ranges when runtime EPG support is disabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
it('does not request EPG channel ranges when the EPG bridge cannot browse channels', async () => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await component.requestPrograms();
expect(getEpgChannelsByRange).not.toHaveBeenCalled();
expect(epgBridge.getChannelsByRange).not.toHaveBeenCalled();
expect(component.isLoading()).toBe(false);
});
it('requests EPG channel ranges when runtime EPG support is enabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([
it('requests EPG channel ranges through the EPG runtime bridge', async () => {
epgBridge.getChannelsByRange = jest.fn().mockResolvedValue([
{
channel_id: 'channel-1',
display_name: 'Channel One',
programs: [],
},
]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.supportsChannelBrowser = true;
await component.requestPrograms();
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(epgBridge.getChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(component.isLoading()).toBe(false);
});
it('does not search EPG programs when runtime EPG support is disabled', () => {
it('does not search EPG programs when the EPG bridge cannot search programs', () => {
jest.useFakeTimers();
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(600);
expect(searchEpgPrograms).not.toHaveBeenCalled();
expect(epgBridge.searchPrograms).not.toHaveBeenCalled();
expect(component.isSearchingPrograms()).toBe(false);
expect(component.programSearchResults()).toEqual([]);
});
it('searches EPG programs when runtime EPG support is enabled', async () => {
it('searches EPG programs through the EPG runtime bridge', async () => {
jest.useFakeTimers();
const results = [
{
@@ -133,12 +122,8 @@ describe('MultiEpgContainerComponent runtime gates', () => {
title: 'News',
},
];
const searchEpgPrograms = jest.fn().mockResolvedValue(results);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.searchPrograms = jest.fn().mockResolvedValue(results);
epgBridge.supportsProgramSearch = true;
component.onProgramSearchInput({
target: { value: 'news' },
@@ -146,7 +131,7 @@ describe('MultiEpgContainerComponent runtime gates', () => {
jest.advanceTimersByTime(500);
await Promise.resolve();
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
expect(epgBridge.searchPrograms).toHaveBeenCalledWith('news', 20);
expect(component.programSearchResults()).toEqual(results);
expect(component.isSearchingPrograms()).toBe(false);
});
@@ -29,7 +29,7 @@ import {
EpgChannelWithPrograms,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
@@ -182,7 +182,7 @@ export class MultiEpgContainerComponent
private readonly dialog = inject(MatDialog);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
ngOnInit() {
// Update current time line every minute
@@ -269,7 +269,7 @@ export class MultiEpgContainerComponent
}
async requestPrograms(): Promise<void> {
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsChannelBrowser) {
console.warn('Multi-EPG not available in this runtime');
return;
}
@@ -281,7 +281,7 @@ export class MultiEpgContainerComponent
this.isLoading.set(true);
try {
const response = await window.electron.getEpgChannelsByRange(
const response = await this.epgBridge.getChannelsByRange(
this.channelsLowerRange,
this.visibleChannels
);
@@ -462,7 +462,7 @@ export class MultiEpgContainerComponent
return;
}
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsProgramSearch) {
this.programSearchResults.set([]);
this.isSearchingPrograms.set(false);
return;
@@ -472,7 +472,7 @@ export class MultiEpgContainerComponent
this.searchDebounceTimer = setTimeout(async () => {
this.isSearchingPrograms.set(true);
try {
const results = await window.electron.searchEpgPrograms(
const results = await this.epgBridge.searchPrograms(
query,
20
);