mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot see Angular's exports-only secondary entry points, and dropped global.d.ts, so tsc reported thousands of resolution errors and no window.electron typing. Switch them to module: preserve with bundler resolution (ts-jest still forces CommonJS emit outside ESM mode), add global.d.ts to every spec program, type jest.unstable_mockModule for the ESM workspace, include the ui-epg and ui-playback specs that jest.web-esm.workspace.ts runs under the web spec config, and drop the snack-bar stub that shadowed the real Material types. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(test): gate spec type-checking with typecheck:spec Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into the unit-and-typecheck job after typecheck:ci, and document the gate and the spec tsconfig conventions in the validation map. Also bring the non-Tier-A spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to the same conventions so the gate covers the whole workspace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: fix the spec type errors surfaced by typecheck:spec With the spec programs resolving modules and ambient typings correctly, tsc reported 432 genuine errors across the Tier A projects: read-only capability flags assigned on Partial<> doubles, signal-store values used as types, fixtures missing required fields, index-signature property access, partial bridge doubles cast through incompatible shapes, and deferred resolvers narrowed to never. Type the doubles instead of casting to any: writable mapped types for capability flags, InstanceType<typeof StalkerStore>, typed jest.fn signatures, protectedState: false on test signal stores, and completed fixtures. Production changes are limited to bracket access for index-signature properties under the libs' noPropertyAccessFromIndexSignature setting and two narrowing guards in the global favorites loader. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(playback): use the ESM setup's jest global in the controls fixtures The fixture imported jest from @jest/globals, which is not a direct dependency. Jest provides that module at runtime, so tests passed, but on a clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI. The ESM test setup already installs import.meta.jest as the global, typed by @types/jest, as the other ESM specs use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: type the parental lock doubles merged since the gate was written The parental lock feature (#1601) and the Stalker actor route landed on master with spec doubles declared as zero-argument jest.fn()s that the tests then drive with the real arguments, plus a copy of the ResizableDirective override imported from a library that does not export it. Give the doubles the lock service's real signatures, drop the dead override as in the sibling layout specs, use bracket access for the actor route's personId param, and keep the Stalker layout spec within the 1200-line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
158 files changed
+1517
-632
No files matched your search
@@ -7,8 +7,11 @@ import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
|
||||
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
|
||||
import { EpgProgressService } from './epg-progress.service';
|
||||
|
||||
/** Spec-only double: capability flags are read-only getters on the real class. */
|
||||
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
|
||||
|
||||
describe('EpgProgressService', () => {
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
|
||||
let sources: { waitForReconciliation: jest.Mock };
|
||||
let settingsStore: {
|
||||
getSettings: jest.Mock;
|
||||
|
||||
@@ -2,9 +2,12 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
|
||||
/** Spec-only double: capability flags are read-only getters on the real class. */
|
||||
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
|
||||
|
||||
describe('EpgRuntimeBridgeService', () => {
|
||||
let service: EpgRuntimeBridgeService;
|
||||
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
|
||||
let runtimeCapabilities: WritablePartial<RuntimeCapabilitiesService>;
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -7,12 +7,16 @@ import {
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
/** Spec-only double: capability flags are read-only getters on the real class. */
|
||||
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
|
||||
|
||||
describe('EpgService', () => {
|
||||
let service: EpgService;
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
|
||||
let snackBar: { open: jest.Mock };
|
||||
let settingsStore: {
|
||||
loadSettings: jest.Mock;
|
||||
@@ -164,7 +168,7 @@ describe('EpgService', () => {
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
} as typeof window.electron;
|
||||
} as Partial<typeof window.electron> as typeof window.electron;
|
||||
try {
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
const reconciliation = sources.synchronize([
|
||||
@@ -457,16 +461,7 @@ describe('EpgService', () => {
|
||||
|
||||
it('deduplicates concurrent scoped current program lookups for the same source scope', async () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
let resolvePrograms:
|
||||
| ((
|
||||
programs: {
|
||||
channel: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
title: string;
|
||||
}[]
|
||||
) => void)
|
||||
| undefined;
|
||||
let resolvePrograms: ((programs: EpgProgram[]) => void) | undefined;
|
||||
epgBridge.getChannelPrograms = jest.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -495,6 +490,8 @@ describe('EpgService', () => {
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Guide Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -928,7 +925,7 @@ describe('EpgService', () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
epgBridge.supportsCurrentProgramBatch = true;
|
||||
const batchResolvers: Array<
|
||||
(programs: Record<string, unknown>) => void
|
||||
(programs: Record<string, EpgProgram | null>) => void
|
||||
> = [];
|
||||
epgBridge.getCurrentProgramsBatch = jest.fn(
|
||||
() =>
|
||||
@@ -957,6 +954,8 @@ describe('EpgService', () => {
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Guide Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -981,7 +980,7 @@ describe('EpgService', () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
epgBridge.supportsCurrentProgramBatch = true;
|
||||
let resolveBatch:
|
||||
((programs: Record<string, unknown>) => void) | undefined;
|
||||
((programs: Record<string, EpgProgram | null>) => void) | undefined;
|
||||
epgBridge.getCurrentProgramsBatch = jest.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -1010,12 +1009,16 @@ describe('EpgService', () => {
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Guide Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
'guide-sports': {
|
||||
channel: 'guide-sports',
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Sports Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('playlistReducers', () => {
|
||||
stalkerTimeslot: 3,
|
||||
stalkerAccountInfo: {
|
||||
login: 'subscriber',
|
||||
status: 'active',
|
||||
status: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -153,7 +153,7 @@ describe('playlistReducers', () => {
|
||||
stalkerTimeslot: 3,
|
||||
stalkerAccountInfo: {
|
||||
login: 'subscriber',
|
||||
status: 'active',
|
||||
status: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('HLS playback evidence', () => {
|
||||
networkDetails: {
|
||||
responseURL: `https://provider.example/xhr?token=${secret}`,
|
||||
responseText: secret,
|
||||
},
|
||||
} satisfies Partial<XMLHttpRequest> as XMLHttpRequest,
|
||||
})
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
+35
-29
@@ -12,6 +12,14 @@ import {
|
||||
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
|
||||
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
|
||||
|
||||
type ChildSignalKey =
|
||||
| 'urlUpload'
|
||||
| 'fileUpload'
|
||||
| 'textImport'
|
||||
| 'xtreamImport'
|
||||
| 'stalkerImport'
|
||||
| 'autoImport';
|
||||
|
||||
describe('AddPlaylistDialogComponent', () => {
|
||||
let component: AddPlaylistDialogComponent;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
@@ -67,8 +75,20 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Replaces a read-only `viewChild` signal with a stub that resolves to a
|
||||
* minimal structural double of the child component, or to `undefined`
|
||||
* while the child is not rendered yet.
|
||||
*/
|
||||
function stubChild(key: ChildSignalKey, child: object | undefined): void {
|
||||
Object.defineProperty(component, key, {
|
||||
value: jest.fn(() => child),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('sends a trimmed custom title for URL playlists', () => {
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
stubChild('urlUpload', {
|
||||
form: {
|
||||
getRawValue: () => ({
|
||||
playlistName: ' My Playlist ',
|
||||
@@ -76,7 +96,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
playlistUrl: ' https://example.com/list.m3u ',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
component.submitUrlPlaylist();
|
||||
|
||||
@@ -92,7 +112,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
});
|
||||
|
||||
it('omits the title when the optional name is blank', () => {
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
stubChild('urlUpload', {
|
||||
form: {
|
||||
getRawValue: () => ({
|
||||
playlistName: ' ',
|
||||
@@ -100,7 +120,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
playlistUrl: 'https://example.com/list.m3u',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
component.submitUrlPlaylist();
|
||||
|
||||
@@ -167,10 +187,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
'clears the current $type import surface',
|
||||
({ type, childAccessor, clearMethod }) => {
|
||||
const clear = jest.fn();
|
||||
(component as unknown as Record<string, jest.Mock>)[childAccessor] =
|
||||
jest.fn(() => ({
|
||||
[clearMethod]: clear,
|
||||
}));
|
||||
stubChild(childAccessor, { [clearMethod]: clear });
|
||||
selectType(type);
|
||||
|
||||
component.clearCurrentForm();
|
||||
@@ -180,10 +197,10 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
);
|
||||
|
||||
it('disables clear when a file upload has no selection', () => {
|
||||
(component as { fileUpload: jest.Mock }).fileUpload = jest.fn(() => ({
|
||||
stubChild('fileUpload', {
|
||||
isImporting: () => false,
|
||||
selectedFile: () => null,
|
||||
}));
|
||||
});
|
||||
selectType('file');
|
||||
|
||||
expect(component.isClearDisabled()).toBeTruthy();
|
||||
@@ -260,9 +277,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the xtream form once the child exists and clears the pending candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
stubChild('xtreamImport', { form: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'xtream',
|
||||
@@ -288,9 +303,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('drops the candidate when the user switches to another method first', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
stubChild('xtreamImport', { form: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'xtream',
|
||||
@@ -309,9 +322,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
});
|
||||
|
||||
it('keeps the candidate pending while the target form does not exist yet', () => {
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => undefined
|
||||
);
|
||||
stubChild('xtreamImport', undefined);
|
||||
const patchValue = jest.fn();
|
||||
|
||||
component.onCandidateSelected({
|
||||
@@ -322,9 +333,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
applyPrefill();
|
||||
|
||||
// Child appears on a later change-detection pass.
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
stubChild('xtreamImport', { form: { patchValue } });
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith(
|
||||
@@ -334,8 +343,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the stalker form including identity fields', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { stalkerImport: jest.Mock }).stalkerImport =
|
||||
jest.fn(() => ({ form: { patchValue } }));
|
||||
stubChild('stalkerImport', { form: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'stalker',
|
||||
@@ -369,9 +377,9 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the URL form for an m3u-url candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
stubChild('urlUpload', {
|
||||
form: { patchValue },
|
||||
}));
|
||||
});
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'm3u-url',
|
||||
@@ -389,9 +397,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the raw-text form for an m3u-text candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { textImport: jest.Mock }).textImport = jest.fn(
|
||||
() => ({ textForm: { patchValue } })
|
||||
);
|
||||
stubChild('textImport', { textForm: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'm3u-text',
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
+4
-1
@@ -11,7 +11,10 @@ import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service';
|
||||
|
||||
function makeChannel(
|
||||
id: string,
|
||||
overrides: Partial<Channel> & { tvgId?: string; group?: string } = {}
|
||||
overrides: Omit<Partial<Channel>, 'group'> & {
|
||||
tvgId?: string;
|
||||
group?: string;
|
||||
} = {}
|
||||
): Channel {
|
||||
return {
|
||||
id,
|
||||
|
||||
+12
-8
@@ -162,13 +162,16 @@ describe('VideoPlayerComponent', () => {
|
||||
// Mirrors the real capability check: every
|
||||
// remote-control bridge method must be present.
|
||||
get supportsRemoteControl() {
|
||||
const bridge = window.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return [
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
].every(
|
||||
const bridge:
|
||||
Partial<typeof window.electron> | undefined =
|
||||
window.electron;
|
||||
return (
|
||||
[
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
] as const
|
||||
).every(
|
||||
(method) =>
|
||||
typeof bridge?.[method] === 'function'
|
||||
);
|
||||
@@ -582,7 +585,7 @@ describe('VideoPlayerComponent', () => {
|
||||
const updateRemoteControlStatus = jest.fn();
|
||||
window.electron = {
|
||||
updateRemoteControlStatus,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
@@ -687,6 +690,7 @@ describe('VideoPlayerComponent', () => {
|
||||
streamUrl: sampleChannel.url,
|
||||
startedAt: '2026-08-08T00:00:00.000Z',
|
||||
updatedAt: '2026-08-08T00:00:00.000Z',
|
||||
canClose: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -451,7 +451,11 @@ describe('PlaylistRefreshActionService', () => {
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (key: string, value: string) {
|
||||
.mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
filename: overrides.filename,
|
||||
count: overrides.count ?? 0,
|
||||
|
||||
+6
-1
@@ -73,6 +73,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
autoRefresh: false,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
} as Playlist & { id: string };
|
||||
const metaBase = { count: 1, importDate: '2026-04-01', autoRefresh: false };
|
||||
|
||||
beforeEach(async () => {
|
||||
playlistsService = {
|
||||
@@ -213,6 +214,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
createComponent();
|
||||
|
||||
const updatedPlaylist = {
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl: 'http://new.example:8080',
|
||||
@@ -251,6 +253,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
createComponent();
|
||||
|
||||
await component.saveChanges({
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl:
|
||||
@@ -262,6 +265,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl: 'http://new.example:8080/live',
|
||||
@@ -289,6 +293,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
createComponent();
|
||||
|
||||
await component.saveChanges({
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl:
|
||||
@@ -314,7 +319,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
window.electron = {
|
||||
saveFileDialog: jest.fn().mockResolvedValue('/tmp/export.m3u8'),
|
||||
writeFile: jest.fn().mockResolvedValue({ success: true }),
|
||||
} as typeof window.electron;
|
||||
} as Partial<typeof window.electron> as typeof window.electron;
|
||||
createComponent();
|
||||
|
||||
await component.exportPlaylist();
|
||||
|
||||
@@ -491,7 +491,11 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (key: string, value: string) {
|
||||
.mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -22,11 +22,11 @@ function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
overrides.importDate ??
|
||||
new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
@@ -392,24 +392,25 @@ describe('PlaylistContextFacade', () => {
|
||||
playlistId: xtreamA._id,
|
||||
section: 'recently-added',
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}'))
|
||||
.toEqual({
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'favorites',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
[xtreamA._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'recently-added',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')
|
||||
).toEqual({
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'favorites',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
providers: {
|
||||
playlists: 'favorites',
|
||||
xtreams: 'recently-added',
|
||||
[xtreamA._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'recently-added',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
});
|
||||
},
|
||||
providers: {
|
||||
playlists: 'favorites',
|
||||
xtreams: 'recently-added',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -381,10 +381,10 @@ describe('DownloadLibraryNavigationService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
it.each<readonly [string, Partial<StalkerPortalItem>]>([
|
||||
['is_series flag', { is_series: '1' }],
|
||||
['embedded episode list', { series: [1, 2] }],
|
||||
] as const)(
|
||||
])(
|
||||
'opens a recovered Stalker episode with an %s through the VOD-series detail route',
|
||||
async (_label, vodSeriesMetadata) => {
|
||||
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
|
||||
|
||||
@@ -510,9 +510,9 @@ describe('DownloadQueueComponent', () => {
|
||||
expect(row(3).querySelector('.download-queue__progress')).toBeNull();
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll<HTMLElement>(
|
||||
'.download-queue__progress'
|
||||
)
|
||||
(
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelectorAll<HTMLElement>('.download-queue__progress')
|
||||
).map((bar) => bar.getAttribute('aria-label'))
|
||||
).toEqual([
|
||||
'Download progress for Download 1',
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
PORTAL_SHELL_ACTIONS,
|
||||
PortalCollectionContextService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { BehaviorSubject, type Observable, Subject } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
|
||||
@@ -47,6 +47,8 @@ interface ConfirmConfig {
|
||||
}
|
||||
|
||||
interface ExpectedDownloadsComponent {
|
||||
readonly activeCount: () => number;
|
||||
readonly categories: () => readonly XtreamCategory[];
|
||||
readonly model: () => {
|
||||
readonly active: readonly { readonly item: DownloadItem }[];
|
||||
readonly activeCount: number;
|
||||
@@ -780,7 +782,9 @@ describe('DownloadsComponent', () => {
|
||||
downloads.set([item]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const attention = fixture.nativeElement.querySelector<HTMLElement>(
|
||||
const attention = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLElement>(
|
||||
'[data-test-id="downloads-attention-section"]'
|
||||
);
|
||||
const recover = attention?.querySelector<HTMLButtonElement>(
|
||||
|
||||
+8
-2
@@ -12,7 +12,10 @@ import {
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type { DownloadActionResult } from '../download-actions';
|
||||
import type {
|
||||
DownloadActionResult,
|
||||
DownloadItemAction,
|
||||
} from '../download-actions';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import { DownloadOfflineDetailComponent } from './download-offline-detail.component';
|
||||
@@ -67,7 +70,10 @@ interface DownloadsFake {
|
||||
|
||||
interface ActionsFake {
|
||||
readonly pendingIds: ReturnType<typeof signal<ReadonlySet<number>>>;
|
||||
readonly run: jest.Mock<Promise<DownloadActionResult>, [unknown]>;
|
||||
readonly run: jest.Mock<
|
||||
Promise<DownloadActionResult>,
|
||||
[DownloadItemAction]
|
||||
>;
|
||||
readonly showActionError: jest.Mock<void, []>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
+18
-3
@@ -135,10 +135,15 @@ describe('StreamResolverService EPG display offset', () => {
|
||||
TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() =>
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Portal',
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
portalUrl: 'https://stalker.example.com',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
} satisfies Playlist)
|
||||
);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const entry = (id: string, name: string, startOffsetMin: number) => ({
|
||||
@@ -192,10 +197,15 @@ describe('StreamResolverService EPG display offset', () => {
|
||||
TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() =>
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Portal',
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
portalUrl: 'https://stalker.example.com',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
} satisfies Playlist)
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const entry = (id: string, name: string, startOffsetMin: number) => ({
|
||||
@@ -322,10 +332,15 @@ describe('StreamResolverService EPG display offset', () => {
|
||||
epgOffsetMinutes = 0;
|
||||
return of({
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} satisfies Partial<Playlist>);
|
||||
} satisfies Playlist);
|
||||
});
|
||||
xtreamApi.getFullEpg.mockResolvedValue([
|
||||
listing('Really on air', -75, 60),
|
||||
|
||||
@@ -33,7 +33,11 @@ describe('StreamResolverService', () => {
|
||||
ensureToken: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: {
|
||||
-readonly [
|
||||
K in keyof EpgRuntimeBridgeService
|
||||
]?: EpgRuntimeBridgeService[K];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
playlistsService = {
|
||||
|
||||
@@ -710,7 +710,7 @@ export class StreamResolverService {
|
||||
// epg_channel_id column but is not declared on the TS interface.
|
||||
const epgKey = (
|
||||
item as unknown as Record<string, string | undefined | null>
|
||||
).epgChannelId?.trim();
|
||||
)['epgChannelId']?.trim();
|
||||
if (this.supportsProgramLookup && epgKey) {
|
||||
const uploaded = await this.epgBridge
|
||||
.getChannelPrograms(epgKey)
|
||||
@@ -995,7 +995,7 @@ export class StreamResolverService {
|
||||
string,
|
||||
string | undefined | null
|
||||
>
|
||||
).epgChannelId?.trim();
|
||||
)['epgChannelId']?.trim();
|
||||
if (this.supportsProgramLookup && epgChannelKey) {
|
||||
currentItem = await this.findCurrentInXmltv(
|
||||
epgChannelKey,
|
||||
|
||||
+7
-1
@@ -126,7 +126,7 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
dbRemoveRecentItemsBatch: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: electronApi as Window['electron'],
|
||||
value: electronApi as unknown as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
@@ -177,11 +177,17 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U List',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: ['https://example.com/2.m3u8', 'channel-1'],
|
||||
},
|
||||
{
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker List',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
favorites: stalkerFavorites,
|
||||
},
|
||||
|
||||
+4
-1
@@ -45,6 +45,9 @@ describe('UnifiedRecentDataService', () => {
|
||||
const playlistMeta = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U List',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
@@ -225,7 +228,7 @@ describe('UnifiedRecentDataService', () => {
|
||||
|
||||
expect(playlistsService.addM3uRecentlyViewed).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
expect.objectContaining<M3uRecentlyViewedItem>({
|
||||
expect.objectContaining<Partial<M3uRecentlyViewedItem>>({
|
||||
source: 'm3u',
|
||||
url: 'https://example.com/1.m3u8',
|
||||
channel_id: 'channel-1',
|
||||
|
||||
@@ -13,6 +13,10 @@ const active: SourceHealthResult = {
|
||||
};
|
||||
const playlist = (id: string, host = id): PlaylistMeta => ({
|
||||
_id: id,
|
||||
title: id,
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
url: `https://${host}.test/list`,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -49,7 +49,8 @@ export class CategoryViewComponent {
|
||||
container.querySelectorAll('[data-category-id]')
|
||||
) as HTMLElement[];
|
||||
const selected = candidates.find(
|
||||
(el) => el.dataset.categoryId === String(selectedCategory)
|
||||
(el) =>
|
||||
el.dataset['categoryId'] === String(selectedCategory)
|
||||
);
|
||||
if (!selected) {
|
||||
return;
|
||||
|
||||
+2
-2
@@ -208,7 +208,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
params: {},
|
||||
},
|
||||
paramMap: workspaceParamMap$.asObservable(),
|
||||
} as ActivatedRoute;
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
route = {
|
||||
snapshot: {
|
||||
@@ -222,7 +222,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
paramMap: routeParamMap$.asObservable(),
|
||||
queryParamMap: routeQueryParamMap$.asObservable(),
|
||||
pathFromRoot: [],
|
||||
} as ActivatedRoute & {
|
||||
} as unknown as ActivatedRoute & {
|
||||
snapshot: {
|
||||
paramMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
|
||||
+1
@@ -596,6 +596,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
code: 'network-error',
|
||||
player: 'videojs',
|
||||
source: 'hls',
|
||||
sourceUrl: 'https://example.com/xtream.m3u8',
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('LivePanelsController', () => {
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
TestBed.flushEffects();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -4,7 +4,15 @@ import {
|
||||
runInInjectionContext,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
ActivatedRouteSnapshot,
|
||||
convertToParamMap,
|
||||
} from '@angular/router';
|
||||
|
||||
type WritableSnapshot = {
|
||||
-readonly [K in keyof ActivatedRouteSnapshot]: ActivatedRouteSnapshot[K];
|
||||
};
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
extractPortalPlaylistId,
|
||||
@@ -36,7 +44,7 @@ describe('portal-route.utils', () => {
|
||||
layout: 'workspace',
|
||||
},
|
||||
},
|
||||
} as ActivatedRoute;
|
||||
} as unknown as ActivatedRoute;
|
||||
const childRoute = {
|
||||
snapshot: {
|
||||
data: {},
|
||||
@@ -257,10 +265,11 @@ describe('portal-route.utils', () => {
|
||||
|
||||
expect(playlistId()).toBe('playlist-1');
|
||||
|
||||
route.snapshot.paramMap = convertToParamMap({
|
||||
const snapshot = route.snapshot as WritableSnapshot;
|
||||
snapshot.paramMap = convertToParamMap({
|
||||
id: 'playlist-2',
|
||||
});
|
||||
route.snapshot.params = {
|
||||
snapshot.params = {
|
||||
id: 'playlist-2',
|
||||
};
|
||||
paramMap$.next(
|
||||
@@ -296,7 +305,7 @@ describe('portal-route.utils', () => {
|
||||
},
|
||||
},
|
||||
paramMap: parentParamMap$.asObservable(),
|
||||
} as ActivatedRoute;
|
||||
} as unknown as ActivatedRoute;
|
||||
const route = {
|
||||
snapshot: {
|
||||
data: {},
|
||||
@@ -316,10 +325,11 @@ describe('portal-route.utils', () => {
|
||||
|
||||
expect(playlistId()).toBe('playlist-1');
|
||||
|
||||
(parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap =
|
||||
convertToParamMap({
|
||||
(parentRoute.snapshot as WritableSnapshot).paramMap = convertToParamMap(
|
||||
{
|
||||
id: 'playlist-2',
|
||||
});
|
||||
}
|
||||
);
|
||||
(parentRoute.snapshot as ActivatedRoute['snapshot']).params = {
|
||||
id: 'playlist-2',
|
||||
};
|
||||
|
||||
@@ -105,7 +105,9 @@ describe('createVodWatchedToggle', () => {
|
||||
applyPosition: (next) => position.set(next),
|
||||
playingNow,
|
||||
notify: (kind) => feedback.push(kind),
|
||||
onPersisted: (playlistId) => persisted.push(playlistId),
|
||||
onPersisted: (playlistId) => {
|
||||
persisted.push(playlistId);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const target = (stillCurrent = () => true) => ({
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
PlaylistUpdateState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_MAG_USER_AGENT,
|
||||
STALKER_STREAM_USER_AGENT,
|
||||
@@ -16,8 +19,8 @@ function createPlaylist(overrides: Partial<PlaylistMeta> = {}): PlaylistMeta {
|
||||
url: '',
|
||||
importDate: '',
|
||||
filePath: '',
|
||||
updateDate: '',
|
||||
updateState: '',
|
||||
updateDate: 0,
|
||||
updateState: PlaylistUpdateState.NOT_UPDATED,
|
||||
position: 0,
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
|
||||
@@ -25,7 +25,7 @@ const MISCLASSIFIED = {
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
} as PlaylistMeta & { portalUrl: string };
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
|
||||
@@ -470,9 +470,9 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
expect(payload.params).not.toHaveProperty('device_id2');
|
||||
expect(payload.params).not.toHaveProperty('signature');
|
||||
expect(payload.params).not.toHaveProperty('signature2');
|
||||
expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty(
|
||||
'sn'
|
||||
);
|
||||
expect(
|
||||
JSON.parse(String(payload.params['metrics']))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => {
|
||||
@@ -505,7 +505,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
signature2: 'SIGNATURE-2',
|
||||
})
|
||||
);
|
||||
expect(JSON.parse(String(payload.params.metrics))).toEqual(
|
||||
expect(JSON.parse(String(payload.params['metrics']))).toEqual(
|
||||
expect.objectContaining({
|
||||
sn: 'CUSTOMSN123',
|
||||
})
|
||||
|
||||
@@ -112,7 +112,9 @@ describe('stalker-vod.utils regressions', () => {
|
||||
cmd: '/media/file_42.mpg',
|
||||
})
|
||||
);
|
||||
expect(state.vodDetailsItem?.data.id).toBe('42');
|
||||
expect(state.vodDetailsItem?.data).toEqual(
|
||||
expect.objectContaining({ id: '42' })
|
||||
);
|
||||
});
|
||||
|
||||
it('favorite toggle uses completion callback path without delayed state update', () => {
|
||||
|
||||
@@ -8,8 +8,10 @@ import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
|
||||
type StalkerStoreMember = keyof InstanceType<typeof StalkerStore>;
|
||||
|
||||
describe('StalkerStore API compatibility smoke', () => {
|
||||
let store: StalkerStore;
|
||||
let store: InstanceType<typeof StalkerStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -25,7 +27,6 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
ensureToken: jest.fn(),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({
|
||||
token: null,
|
||||
@@ -75,7 +76,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
});
|
||||
|
||||
it('exposes compatibility state signals', () => {
|
||||
const expectedSignals = [
|
||||
const expectedSignals: StalkerStoreMember[] = [
|
||||
'selectedContentType',
|
||||
'selectedCategoryId',
|
||||
'selectedVodId',
|
||||
@@ -102,7 +103,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
});
|
||||
|
||||
it('exposes compatibility computed selectors', () => {
|
||||
const expectedComputed = [
|
||||
const expectedComputed: StalkerStoreMember[] = [
|
||||
// getTotalPages was removed with catalog pagination — the grid
|
||||
// appends portal pages and pages have no UI representation left.
|
||||
'hasMoreContent',
|
||||
@@ -126,7 +127,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
});
|
||||
|
||||
it('exposes compatibility methods and internal resources', () => {
|
||||
const expectedMethods = [
|
||||
const expectedMethods: StalkerStoreMember[] = [
|
||||
'setSelectedContentType',
|
||||
'setSelectedCategory',
|
||||
'setSelectedSerialId',
|
||||
|
||||
+35
-28
@@ -34,6 +34,7 @@ const PLAYLIST = {
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestContentStore = signalStore(
|
||||
{ protectedState: false },
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
selectedContentType: 'vod' as 'vod' | 'series' | 'itv' | 'radio',
|
||||
@@ -58,6 +59,11 @@ const TestContentStore = signalStore(
|
||||
withStalkerContent()
|
||||
);
|
||||
|
||||
/** Request payload the content feature sends through `sendIpcEvent`. */
|
||||
interface StalkerIpcRequest {
|
||||
params?: { action?: string; p?: number; search?: string };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -136,7 +142,10 @@ function createItvCacheMock(
|
||||
describe('withStalkerContent failure states', () => {
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
sendIpcEvent: jest.Mock<
|
||||
Promise<unknown>,
|
||||
[event: string, request: StalkerIpcRequest]
|
||||
>;
|
||||
};
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
@@ -151,7 +160,9 @@ describe('withStalkerContent failure states', () => {
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
version: signal(0),
|
||||
lockedStalkerIds: jest.fn(() => []),
|
||||
lockedStalkerIds: jest.fn(
|
||||
(_playlistId: string, _type: string): string[] => []
|
||||
),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -187,11 +198,10 @@ describe('withStalkerContent failure states', () => {
|
||||
it('keeps ITV pages unfiltered while independent local searches change', async () => {
|
||||
const pending =
|
||||
createDeferred<ReturnType<typeof createContentResponse>>();
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event, request: { params: { action: string } }) =>
|
||||
request.params.action === 'get_genres'
|
||||
? Promise.resolve({ js: [] })
|
||||
: pending.promise
|
||||
dataService.sendIpcEvent.mockImplementation((_event, request) =>
|
||||
request.params?.action === 'get_genres'
|
||||
? Promise.resolve({ js: [] })
|
||||
: pending.promise
|
||||
);
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCategories('itv', [
|
||||
@@ -203,15 +213,10 @@ describe('withStalkerContent failure states', () => {
|
||||
await flushResources();
|
||||
const orderedCalls = () =>
|
||||
dataService.sendIpcEvent.mock.calls.filter(
|
||||
(call) =>
|
||||
(call[1] as { params: { action: string } }).params
|
||||
.action === 'get_ordered_list'
|
||||
(call) => call[1].params?.action === 'get_ordered_list'
|
||||
);
|
||||
await waitForCondition(() => orderedCalls().length > 0);
|
||||
expect(
|
||||
(orderedCalls()[0][1] as { params: { search?: string } }).params
|
||||
.search
|
||||
).toBeUndefined();
|
||||
expect(orderedCalls()[0][1].params?.search).toBeUndefined();
|
||||
patchState(store, { searchPhrase: 'changed' });
|
||||
await flushResources();
|
||||
pending.resolve(createContentResponse('Panel match'));
|
||||
@@ -964,7 +969,10 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
sendIpcEvent: jest.Mock<
|
||||
Promise<unknown>,
|
||||
[event: string, request: StalkerIpcRequest]
|
||||
>;
|
||||
};
|
||||
let itvCache: ReturnType<typeof createItvCacheMock>;
|
||||
|
||||
@@ -1195,19 +1203,18 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
|
||||
it('keeps censored pagination after a delayed cache replays the current page', async () => {
|
||||
setup(null);
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event, payload: { params: { p: number } }) =>
|
||||
Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: String(payload.params.p),
|
||||
name: `Hidden ${payload.params.p}`,
|
||||
},
|
||||
],
|
||||
total_items: 3,
|
||||
},
|
||||
})
|
||||
dataService.sendIpcEvent.mockImplementation((_event, payload) =>
|
||||
Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: String(payload.params?.p),
|
||||
name: `Hidden ${payload.params?.p}`,
|
||||
},
|
||||
],
|
||||
total_items: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
enterItvCategory('1099');
|
||||
await waitForCondition(() => store.itvChannels().length === 1);
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ describe('withStalkerFavorites', () => {
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
favorites: [],
|
||||
} as PlaylistMeta,
|
||||
} as Partial<PlaylistMeta> as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore } from '@ngrx/signals';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { ElectronBridgeApi, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerPortal } from './with-stalker-portal.feature';
|
||||
@@ -48,7 +48,7 @@ describe('withStalkerPortal', () => {
|
||||
value: {
|
||||
dbCreatePlaylist,
|
||||
dbGetPlaylist,
|
||||
} as Window['electron'],
|
||||
} satisfies Partial<ElectronBridgeApi>,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
@@ -138,5 +138,4 @@ describe('withStalkerPortal', () => {
|
||||
expect(dbGetPlaylist).not.toHaveBeenCalled();
|
||||
expect(dbCreatePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
+1
-1
@@ -174,7 +174,7 @@ describe('withStalkerRecent', () => {
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
recentlyViewed: [],
|
||||
} as PlaylistMeta,
|
||||
} as Partial<PlaylistMeta> as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ const PLAYLIST = {
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestSeriesStore = signalStore(
|
||||
{ protectedState: false },
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
}),
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -61,7 +61,7 @@ export class StalkerActorRouteComponent {
|
||||
initialValue: this.route.snapshot.params,
|
||||
});
|
||||
private readonly personId = computed(() =>
|
||||
Number(this.routeParams().personId)
|
||||
Number(this.routeParams()['personId'])
|
||||
);
|
||||
|
||||
readonly profile = signal<ActorProfile | null>(null);
|
||||
|
||||
@@ -411,16 +411,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: {
|
||||
name: 'Collection Movie',
|
||||
movie_image: 'movie.jpg',
|
||||
},
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://streams.example.test/movie.mp4',
|
||||
title: 'Collection Movie',
|
||||
@@ -488,7 +479,10 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
|
||||
fixture.componentRef.setInput('item', itemB);
|
||||
await settleDetail(fixture);
|
||||
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||
pendingA.resolve({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
title: 'Movie A',
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
@@ -504,11 +498,15 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
fixture.componentInstance.onVodPlay(
|
||||
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
|
||||
);
|
||||
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
|
||||
pendingB.resolve({
|
||||
streamUrl: 'https://streams.test/b.mp4',
|
||||
title: 'Movie B',
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||
streamUrl: 'https://streams.test/b.mp4',
|
||||
title: 'Movie B',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -570,23 +568,21 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
stalkerItem: { ...(item.stalkerItem as object) },
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
title: 'Movie A',
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
title: 'Movie A',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a collection movie watched under its owning playlist', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
@@ -627,13 +623,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('blocks the watched toggle while a collection Play is still resolving', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
let resolve!: (value: ResolvedPortalPlayback) => void;
|
||||
stalkerStore.resolveVodPlayback.mockReturnValueOnce(
|
||||
new Promise<ResolvedPortalPlayback>((resolvePromise) => {
|
||||
@@ -675,13 +665,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('does not carry a pending collection start over to the next item', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
let resolve!: (value: ResolvedPortalPlayback) => void;
|
||||
stalkerStore.resolveVodPlayback.mockReturnValueOnce(
|
||||
new Promise<ResolvedPortalPlayback>((resolvePromise) => {
|
||||
@@ -729,13 +713,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('mirrors an external player position into the collection row', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
playbackPositions.getPlaybackPosition.mockResolvedValueOnce({
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: 1701,
|
||||
@@ -787,13 +765,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
@@ -834,9 +806,6 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('does not load VOD playback position when the playlist id is missing', async () => {
|
||||
const playlistsService = TestBed.inject(PlaylistsService) as {
|
||||
getPlaylistById: jest.Mock;
|
||||
};
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...playlist,
|
||||
@@ -947,6 +916,27 @@ function buildVodSource(id: string, title: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Regular (non-series) Stalker VOD item as the collection grid hands it over. */
|
||||
function createSourceItem() {
|
||||
return {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: {
|
||||
name: 'Collection Movie',
|
||||
movie_image: 'movie.jpg',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
+15
-1
@@ -51,7 +51,21 @@ class StubStalkerSeriesViewComponent {
|
||||
}
|
||||
|
||||
const VOD_ITEM = createStalkerVodItem(
|
||||
{ id: '42', cmd: '/media/42', info: { name: 'Movie' } },
|
||||
{
|
||||
id: '42',
|
||||
cmd: '/media/42',
|
||||
info: {
|
||||
name: 'Movie',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
'stalker-1'
|
||||
);
|
||||
|
||||
|
||||
+6
-6
@@ -18,21 +18,21 @@ const channel = (
|
||||
describe('StalkerLiveAutoOpen', () => {
|
||||
let autoOpen: StalkerLiveAutoOpen;
|
||||
let events: Subject<unknown>;
|
||||
const selectedCategoryId = signal<string | null>(null);
|
||||
const searchPhrase = signal('');
|
||||
const store = {
|
||||
currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }),
|
||||
selectedContentType: signal<string | null>('itv'),
|
||||
selectedCategoryId: signal<string | null>(null),
|
||||
selectedCategoryId,
|
||||
itvChannelsCategory: signal<string | null>(null),
|
||||
searchPhrase: signal(''),
|
||||
searchPhrase,
|
||||
itvFullChannelList: signal<StalkerItvChannel[]>([]),
|
||||
itvFullListActive: signal(false),
|
||||
itvFullListUnsupported: signal(false),
|
||||
preloadItvChannels: jest.fn(() => Promise.resolve()),
|
||||
setSearchPhrase: jest.fn((phrase: string) =>
|
||||
store.searchPhrase.set(phrase)
|
||||
),
|
||||
setSearchPhrase: jest.fn((phrase: string) => searchPhrase.set(phrase)),
|
||||
setSelectedCategory: jest.fn((category: string | null) =>
|
||||
store.selectedCategoryId.set(category)
|
||||
selectedCategoryId.set(category)
|
||||
),
|
||||
setPage: jest.fn(),
|
||||
};
|
||||
|
||||
+9
-8
@@ -22,25 +22,26 @@ describe('StalkerLiveNavigation', () => {
|
||||
const third = channel('3', 'sports');
|
||||
const rows = signal([first, second]);
|
||||
const loading = signal(false);
|
||||
const selectedCategoryId = signal<string | null>('news');
|
||||
const searchPhrase = signal('');
|
||||
const page = signal(0);
|
||||
const store = {
|
||||
currentPlaylist: signal({ _id: 'source-a' }),
|
||||
selectedContentType: signal('itv'),
|
||||
selectedCategoryId: signal<string | null>('news'),
|
||||
searchPhrase: signal(''),
|
||||
selectedCategoryId,
|
||||
searchPhrase,
|
||||
selectedItem: signal<StalkerItvChannel | null>(null),
|
||||
page: signal(0),
|
||||
page,
|
||||
hasMoreChannels: signal(false),
|
||||
getCategoryResource: signal([
|
||||
{ category_id: 'news' },
|
||||
{ category_id: 'sports' },
|
||||
]),
|
||||
setSearchPhrase: jest.fn((query: string) =>
|
||||
store.searchPhrase.set(query)
|
||||
),
|
||||
setSearchPhrase: jest.fn((query: string) => searchPhrase.set(query)),
|
||||
setSelectedCategory: jest.fn((category: string) =>
|
||||
store.selectedCategoryId.set(category)
|
||||
selectedCategoryId.set(category)
|
||||
),
|
||||
setPage: jest.fn((page: number) => store.page.set(page)),
|
||||
setPage: jest.fn((next: number) => page.set(next)),
|
||||
};
|
||||
const router = {
|
||||
url: '/workspace/stalker/source-a/itv?q=sport&keep=1',
|
||||
|
||||
+17
-12
@@ -4,10 +4,10 @@ import { By } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
|
||||
import {
|
||||
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
ACTIVE_EPG_FALLBACK_SIZE,
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
ChannelListItemComponent,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ElectronBridgeApi,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -43,6 +47,11 @@ import {
|
||||
StubWebPlayerViewComponent,
|
||||
} from './stalker-live-stream-layout.spec-stubs';
|
||||
|
||||
/** Installs an intentionally partial Electron bridge double. */
|
||||
function installElectronBridge(bridge: Partial<ElectronBridgeApi>): void {
|
||||
window.electron = bridge as ElectronBridgeApi;
|
||||
}
|
||||
|
||||
describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
let component: StalkerLiveStreamLayoutComponent;
|
||||
@@ -159,7 +168,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
fetchChannelEpg: jest.fn(),
|
||||
ensureBulkItvEpg: jest.fn(),
|
||||
applyMappedItvEpg: jest.fn().mockResolvedValue(undefined),
|
||||
hasItvEpgMappingOverride: jest.fn(() => false),
|
||||
hasItvEpgMappingOverride: jest.fn((_id: string | number) => false),
|
||||
clearBulkItvEpgCache: jest.fn(() => {
|
||||
bulkItvEpgByChannel.set({});
|
||||
bulkItvEpgLoaded.set(false);
|
||||
@@ -191,13 +200,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
// 'list' into siblings.
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
|
||||
window.electron = {
|
||||
installElectronBridge({
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
} as typeof window.electron;
|
||||
});
|
||||
|
||||
fetchChannelEpg = stalkerStore.fetchChannelEpg;
|
||||
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
|
||||
@@ -444,11 +453,6 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
title: 'Alpha TV',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 10001,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
const request: PlaybackFallbackRequest = {
|
||||
player: 'vlc',
|
||||
@@ -458,6 +462,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
code: 'network-error',
|
||||
player: 'html5',
|
||||
source: 'hls',
|
||||
sourceUrl: playback.streamUrl,
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
@@ -609,9 +614,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
it('does not publish remote-control status when the bridge is incomplete', () => {
|
||||
fixture.destroy();
|
||||
const updateRemoteControlStatus = jest.fn();
|
||||
window.electron = {
|
||||
installElectronBridge({
|
||||
updateRemoteControlStatus,
|
||||
} as typeof window.electron;
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
+21
-5
@@ -197,9 +197,13 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
expect(selectedItem()).toBe(channels[0]);
|
||||
const currentPlayback = {
|
||||
streamUrl: 'https://two.example/live.m3u8',
|
||||
title: 'Channel two',
|
||||
};
|
||||
current.resolve(currentPlayback);
|
||||
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
stale.resolve({
|
||||
streamUrl: 'https://stale.example/live.m3u8',
|
||||
title: 'Channel one',
|
||||
});
|
||||
await Promise.all([staleSelection, currentSelection]);
|
||||
expect(selectedItem()).toBe(channels[1]);
|
||||
expect(component.isSelectedChannel(channels[1])).toBe(true);
|
||||
@@ -224,10 +228,16 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
const requestA = component.playChannel(shared);
|
||||
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
|
||||
const requestB = component.playChannel(shared);
|
||||
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
|
||||
const playbackB = {
|
||||
streamUrl: 'https://b.example/shared.m3u8',
|
||||
title: 'Shared channel (Portal B)',
|
||||
};
|
||||
|
||||
sourceB.resolve(playbackB);
|
||||
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
|
||||
sourceA.resolve({
|
||||
streamUrl: 'https://a.example/shared.m3u8',
|
||||
title: 'Shared channel (Portal A)',
|
||||
});
|
||||
await Promise.all([requestA, requestB]);
|
||||
|
||||
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
|
||||
@@ -274,7 +284,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
const request = component.playChannel(channels[0]);
|
||||
|
||||
selectedContentType.set('radio');
|
||||
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/itv.m3u8',
|
||||
title: 'Channel one',
|
||||
});
|
||||
await request;
|
||||
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
@@ -312,7 +325,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
resolveItvPlayback.mockReturnValueOnce(pending.promise);
|
||||
const request = component.playChannel(channels[0]);
|
||||
store.setSelectedItem(channels[1]);
|
||||
pending.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/live.m3u8',
|
||||
title: 'Channel one',
|
||||
});
|
||||
await request;
|
||||
expect(selectedItem()).toBe(channels[1]);
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
|
||||
+18
-10
@@ -11,7 +11,7 @@ import {
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { ElectronBridgeApi, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
|
||||
@@ -22,6 +22,11 @@ import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.c
|
||||
* which sits at the max-lines test budget; the template is overridden to
|
||||
* empty because these behaviors live entirely in constructor effects.
|
||||
*/
|
||||
/** Installs an intentionally partial Electron bridge double. */
|
||||
function installElectronBridge(bridge: Partial<ElectronBridgeApi>): void {
|
||||
window.electron = bridge as ElectronBridgeApi;
|
||||
}
|
||||
|
||||
describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
const originalElectron = window.electron;
|
||||
@@ -147,13 +152,13 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
itvChannels.set(originalItvChannels);
|
||||
stalkerStore.selectedCategoryId.set('1001');
|
||||
stalkerStore.setSelectedItem.mockReset();
|
||||
window.electron = {
|
||||
installElectronBridge({
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus,
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
} as typeof window.electron;
|
||||
});
|
||||
|
||||
selectedContentType.set('itv');
|
||||
selectedItem.set(itvChannels()[0]);
|
||||
@@ -179,13 +184,16 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
// Mirrors the real capability check: every
|
||||
// remote-control bridge method must be present.
|
||||
get supportsRemoteControl() {
|
||||
const bridge = window.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return [
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
].every(
|
||||
const bridge:
|
||||
Partial<ElectronBridgeApi> | undefined =
|
||||
window.electron;
|
||||
return (
|
||||
[
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
] as const
|
||||
).every(
|
||||
(method) =>
|
||||
typeof bridge?.[method] === 'function'
|
||||
);
|
||||
|
||||
+7
-1
@@ -117,8 +117,14 @@ export class StubPortalEmptyStateComponent {
|
||||
readonly message = input('');
|
||||
}
|
||||
|
||||
/** Accepts the layout's resizable bindings without touching storage or the DOM. */
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
export class StubResizableDirective {}
|
||||
export class StubResizableDirective {
|
||||
readonly minWidth = input<number>(200);
|
||||
readonly maxWidth = input<number>(600);
|
||||
readonly defaultWidth = input<number>(400);
|
||||
readonly storageKey = input<string>('');
|
||||
}
|
||||
@@ -348,7 +348,9 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
version: signal(0),
|
||||
lockedStalkerIds: jest.fn(() => []),
|
||||
lockedStalkerIds: jest.fn(
|
||||
(_playlistId: string, _type: string): string[] => []
|
||||
),
|
||||
};
|
||||
stalkerStoreMock = {
|
||||
selectedItem: signal(null),
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ const ITEM = {
|
||||
rating_imdb: '8.4',
|
||||
rating_kinopoisk: '8.0',
|
||||
tmdb_id: 88001,
|
||||
tmdb_status: 'returning-series',
|
||||
tmdb_status: 'returning',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('createStalkerSeriesDownloadAdapter', () => {
|
||||
year: 2025,
|
||||
genres: ['Drama', 'Mystery'],
|
||||
rating: 8.4,
|
||||
status: 'returning-series',
|
||||
status: 'returning',
|
||||
posterUrl: SERIES_POSTER_URL,
|
||||
tmdbId: 88001,
|
||||
providerCategoryId: '18',
|
||||
|
||||
+5
-3
@@ -12,10 +12,12 @@ import {
|
||||
} from './stalker-series-resume';
|
||||
|
||||
function episode(
|
||||
overrides: Partial<StalkerMappedEpisode> & { id: number }
|
||||
overrides: Partial<Omit<StalkerMappedEpisode, 'id'>> & { id: number }
|
||||
): StalkerMappedEpisode {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
title: `Episode ${overrides.episode_num ?? 1}`,
|
||||
id: String(id),
|
||||
title: `Episode ${rest.episode_num ?? 1}`,
|
||||
container_extension: 'mpg',
|
||||
custom_sid: 'vod-series',
|
||||
added: '',
|
||||
@@ -23,7 +25,7 @@ function episode(
|
||||
episode_num: 1,
|
||||
direct_source: '',
|
||||
info: {},
|
||||
...overrides,
|
||||
...rest,
|
||||
} as StalkerMappedEpisode;
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -330,9 +330,10 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/old-command.mpg',
|
||||
title: 'Stale command',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: episodeA.id,
|
||||
contentXtreamId: Number(episodeA.id),
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
@@ -676,9 +677,10 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
setLazySeries('replacement-parent', 'replacement-provider-episode');
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/old-episode.mpg',
|
||||
title: 'Stale episode',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: episode.id,
|
||||
contentXtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
@@ -709,18 +711,20 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
fixture.componentInstance.onEpisodeClicked(newEpisode);
|
||||
const newestPlayback = {
|
||||
streamUrl: 'https://new.example/episode.mpg',
|
||||
title: 'Newest episode',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: newEpisode.id,
|
||||
contentXtreamId: Number(newEpisode.id),
|
||||
contentType: 'episode' as const,
|
||||
},
|
||||
};
|
||||
newRequest.resolve(newestPlayback);
|
||||
oldRequest.resolve({
|
||||
streamUrl: 'https://old.example/episode.mpg',
|
||||
title: 'Old episode',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: oldEpisode.id,
|
||||
contentXtreamId: Number(oldEpisode.id),
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
PortalPlaybackPositions,
|
||||
PortalPlayer,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
|
||||
|
||||
interface Deferred<T> {
|
||||
@@ -119,11 +122,14 @@ describe('StalkerVodPlaybackController', () => {
|
||||
|
||||
it('does not mount playback that resolves after the detail closes', async () => {
|
||||
const { controller, inlinePlayback } = createController();
|
||||
const pending = createDeferred<{ streamUrl: string }>();
|
||||
const pending = createDeferred<ResolvedPortalPlayback>();
|
||||
const playback = controller.startVodPlayback(() => pending.promise);
|
||||
|
||||
controller.closeInlinePlayer();
|
||||
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/movie.mpg',
|
||||
title: 'Stale movie',
|
||||
});
|
||||
await playback;
|
||||
|
||||
expect(inlinePlayback()).toBeNull();
|
||||
@@ -131,14 +137,20 @@ describe('StalkerVodPlaybackController', () => {
|
||||
|
||||
it('keeps the newest VOD request when resolutions finish out of order', async () => {
|
||||
const { controller, inlinePlayback } = createController();
|
||||
const older = createDeferred<{ streamUrl: string }>();
|
||||
const newer = createDeferred<{ streamUrl: string }>();
|
||||
const older = createDeferred<ResolvedPortalPlayback>();
|
||||
const newer = createDeferred<ResolvedPortalPlayback>();
|
||||
const olderRequest = controller.startVodPlayback(() => older.promise);
|
||||
const newerRequest = controller.startVodPlayback(() => newer.promise);
|
||||
const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' };
|
||||
const newestPlayback = {
|
||||
streamUrl: 'https://new.example/movie.mpg',
|
||||
title: 'Newest movie',
|
||||
};
|
||||
|
||||
newer.resolve(newestPlayback);
|
||||
older.resolve({ streamUrl: 'https://old.example/movie.mpg' });
|
||||
older.resolve({
|
||||
streamUrl: 'https://old.example/movie.mpg',
|
||||
title: 'Old movie',
|
||||
});
|
||||
await Promise.all([olderRequest, newerRequest]);
|
||||
|
||||
expect(inlinePlayback()).toBe(newestPlayback);
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
|
||||
import type { XtreamContentItem } from './xtream-data-source.interface';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
@@ -41,7 +42,9 @@ describe('PwaXtreamDataSource', () => {
|
||||
};
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
lockedXtreamIds: jest.fn(() => []),
|
||||
lockedXtreamIds: jest.fn(
|
||||
(_playlistId: string, _type: string): number[] => []
|
||||
),
|
||||
withholdsEverything: jest.fn(() => false),
|
||||
};
|
||||
|
||||
@@ -363,17 +366,17 @@ describe('PwaXtreamDataSource', () => {
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'live'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
const vod = (await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
const series = (await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'series'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
|
||||
expect(live[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -481,7 +484,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
|
||||
expect(content).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -606,7 +609,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
|
||||
expect(content[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('EpgQueueService invalidation', () => {
|
||||
|
||||
type ServicePrivates = {
|
||||
fetchEpg: (
|
||||
credentials: typeof credentials,
|
||||
portalCredentials: typeof credentials,
|
||||
streamId: number
|
||||
) => Promise<void>;
|
||||
shouldFetch: (streamId: number) => boolean;
|
||||
@@ -42,7 +42,9 @@ describe('EpgQueueService invalidation', () => {
|
||||
provide: XtreamXmltvFallbackService,
|
||||
useValue: {
|
||||
getProgramsForChannel: jest.fn().mockResolvedValue([]),
|
||||
getCurrentProgramsBatch: jest.fn().mockResolvedValue({}),
|
||||
getCurrentProgramsBatch: jest
|
||||
.fn()
|
||||
.mockResolvedValue({}),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('EpgQueueService', () => {
|
||||
|
||||
type ServicePrivates = {
|
||||
fetchEpg: (
|
||||
credentials: typeof credentials,
|
||||
portalCredentials: typeof credentials,
|
||||
streamId: number
|
||||
) => Promise<void>;
|
||||
shouldFetch: (streamId: number) => boolean;
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('XtreamUrlService', () => {
|
||||
.mockResolvedValueOnce({ status: 206 });
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const firstUrl = await service.resolveCatchupUrl(
|
||||
'playlist-1',
|
||||
@@ -263,7 +263,7 @@ describe('XtreamUrlService', () => {
|
||||
}));
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const initialUrl = await service.resolveCatchupUrl(
|
||||
'playlist-format-refresh',
|
||||
@@ -306,7 +306,7 @@ describe('XtreamUrlService', () => {
|
||||
}));
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const catchupUrl = await service.resolveCatchupUrl(
|
||||
'playlist-hls',
|
||||
@@ -345,7 +345,7 @@ describe('XtreamUrlService', () => {
|
||||
}));
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const catchupUrl = await service.resolveCatchupUrl(
|
||||
'playlist-hls-only',
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ describe('XtreamXmltvFallbackService', () => {
|
||||
getChannelPrograms: jest.fn(),
|
||||
getCurrentProgramsBatch: jest.fn(),
|
||||
};
|
||||
(window as { electron?: Bridge }).electron = bridge;
|
||||
(window as { electron?: unknown }).electron = bridge;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { RendererPerformancePhaseEvent } from '@iptvnator/shared/logging';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import {
|
||||
createAbortError,
|
||||
@@ -250,7 +251,10 @@ describe('withContent import state', () => {
|
||||
type: ContentType,
|
||||
onProgress?: (count: number) => void,
|
||||
onTotal?: (total: number) => void,
|
||||
options?: { onEvent?: (event: any) => void }
|
||||
options?: {
|
||||
onEvent?: (event: any) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
optionsByType.set(type, options);
|
||||
onTotal?.(totals[type]);
|
||||
@@ -1279,7 +1283,10 @@ describe('withContent import state', () => {
|
||||
type: ContentType,
|
||||
onProgress?: (count: number) => void,
|
||||
onTotal?: (total: number) => void,
|
||||
options?: { onEvent?: (event: any) => void }
|
||||
options?: {
|
||||
onEvent?: (event: any) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
optionsByType.set(type, options);
|
||||
onTotal?.(5);
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
XtreamPlaylistData,
|
||||
} from '../data-sources/xtream-data-source.interface';
|
||||
import { XtreamApiService } from '../services/xtream-api.service';
|
||||
import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source';
|
||||
import {
|
||||
resolveXtreamVodPlaybackSource,
|
||||
type XtreamVodPlaybackItem,
|
||||
} from '../services/xtream-vod-playback-source';
|
||||
import { XtreamUrlService } from '../services/xtream-url.service';
|
||||
import { XtreamXmltvFallbackService } from '../services/xtream-xmltv-fallback.service';
|
||||
import { XtreamStore } from './xtream.store';
|
||||
@@ -135,7 +138,11 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
container_extension: null,
|
||||
})
|
||||
);
|
||||
expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toBeNull();
|
||||
expect(
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)
|
||||
).toBeNull();
|
||||
expect(store.isLoadingDetails()).toBe(false);
|
||||
|
||||
const publishedSelection = store.selectedItem();
|
||||
@@ -152,11 +159,16 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
} as XtreamVodStream);
|
||||
await waitForCondition(
|
||||
() =>
|
||||
resolveXtreamVodPlaybackSource(store.selectedItem())
|
||||
?.containerExtension === 'mkv'
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)?.containerExtension === 'mkv'
|
||||
);
|
||||
|
||||
expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({
|
||||
expect(
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)
|
||||
).toEqual({
|
||||
streamId: 42,
|
||||
containerExtension: 'mkv',
|
||||
});
|
||||
@@ -197,8 +209,9 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
});
|
||||
await waitForCondition(
|
||||
() =>
|
||||
resolveXtreamVodPlaybackSource(store.selectedItem())
|
||||
?.streamId === 99
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)?.streamId === 99
|
||||
);
|
||||
|
||||
firstCatalogItem.resolve({
|
||||
@@ -208,7 +221,11 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({
|
||||
expect(
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)
|
||||
).toEqual({
|
||||
streamId: 99,
|
||||
containerExtension: 'mp4',
|
||||
});
|
||||
@@ -303,8 +320,9 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
});
|
||||
await waitForCondition(
|
||||
() =>
|
||||
resolveXtreamVodPlaybackSource(store.selectedItem())
|
||||
?.streamId === 99
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)?.streamId === 99
|
||||
);
|
||||
|
||||
firstDetails.resolve({
|
||||
@@ -315,7 +333,11 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({
|
||||
expect(
|
||||
resolveXtreamVodPlaybackSource(
|
||||
store.selectedItem() as XtreamVodPlaybackItem | null
|
||||
)
|
||||
).toEqual({
|
||||
streamId: 99,
|
||||
containerExtension: 'mp4',
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const TestFavoritesStore = signalStore(withFavorites());
|
||||
const TestFavoritesStore = signalStore(
|
||||
{ protectedState: false },
|
||||
withFavorites()
|
||||
);
|
||||
|
||||
describe('withFavorites', () => {
|
||||
const originalElectron = window.electron;
|
||||
@@ -196,9 +199,9 @@ describe('withFavorites', () => {
|
||||
await expect(
|
||||
store.toggleFavorite('not-a-number', 'playlist-1', 'movie')
|
||||
).resolves.toBe(false);
|
||||
await expect(store.toggleFavorite(0, 'playlist-1', 'movie')).resolves.toBe(
|
||||
false
|
||||
);
|
||||
await expect(
|
||||
store.toggleFavorite(0, 'playlist-1', 'movie')
|
||||
).resolves.toBe(false);
|
||||
await expect(store.toggleFavorite(290, '', 'movie')).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -140,13 +140,24 @@ describe('AccountInfoComponent', () => {
|
||||
user_info: {
|
||||
active_cons: '0',
|
||||
allowed_output_formats: [],
|
||||
auth: 1,
|
||||
created_at: '1700000000',
|
||||
exp_date: '0',
|
||||
is_trial: '0',
|
||||
max_connections: '0',
|
||||
message: '',
|
||||
password: 'dialog-pass',
|
||||
status: 'active',
|
||||
username: 'dialog-user',
|
||||
},
|
||||
server_info: {
|
||||
https_port: '443',
|
||||
port: '80',
|
||||
rtmp_port: '1935',
|
||||
server_protocol: 'http',
|
||||
time_now: '2024-01-01 00:00:00',
|
||||
timestamp_now: 1704067200,
|
||||
timezone: 'UTC',
|
||||
url: 'dialog.example.test',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ export class XtreamActorRouteComponent {
|
||||
initialValue: this.route.snapshot.params,
|
||||
});
|
||||
private readonly personId = computed(() =>
|
||||
Number(this.routeParams().personId)
|
||||
Number(this.routeParams()['personId'])
|
||||
);
|
||||
|
||||
readonly profile = signal<ActorProfile | null>(null);
|
||||
|
||||
-7
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Directive,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
@@ -101,12 +100,6 @@ export class StubEpgTimelineComponent {
|
||||
readonly collapsedChange = output<boolean>();
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
export class StubResizableDirective {}
|
||||
|
||||
export const sampleChannel = {
|
||||
xtream_id: 101,
|
||||
name: 'Channel 101',
|
||||
|
||||
+8
-13
@@ -16,7 +16,6 @@ import {
|
||||
liveSidebarStateStorageKey,
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
FavoritesService,
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
StubEpgTimelineComponent,
|
||||
StubGridListComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
StubWebPlayerViewComponent,
|
||||
} from './live-stream-layout-stubs.spec-data';
|
||||
|
||||
@@ -94,7 +92,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
currentPlaylist,
|
||||
liveStreams,
|
||||
isContentInitialized,
|
||||
selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]),
|
||||
selectItemsFromSelectedCategory: jest.fn<unknown[], []>(() => [
|
||||
sampleChannel,
|
||||
]),
|
||||
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
|
||||
openPlayer: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
@@ -141,7 +141,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
routerEvents = new Subject();
|
||||
router = { events: routerEvents, navigate: jest.fn() };
|
||||
@@ -253,7 +253,6 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
@@ -263,7 +262,6 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
StubEpgTimelineComponent,
|
||||
StubGridListComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
@@ -571,17 +569,14 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
title: 'Channel 101',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 101,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
// Partial double: the diagnostic that triggered the fallback is not
|
||||
// needed to verify that the playback and player are forwarded as-is.
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
trackLaunch: jest.fn(),
|
||||
} as PlaybackFallbackRequest);
|
||||
} as unknown as PlaybackFallbackRequest);
|
||||
|
||||
const [forwardedPlayback, forwardedPlayer] =
|
||||
portalPlayer.openExternalPlayback.mock.calls[0];
|
||||
@@ -718,7 +713,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const updateRemoteControlStatus = jest.fn();
|
||||
window.electron = {
|
||||
updateRemoteControlStatus,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
+3
-5
@@ -8,7 +8,6 @@ import { Subject, of } from 'rxjs';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
FavoritesService,
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
StubEpgTimelineComponent,
|
||||
StubGridListComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
StubWebPlayerViewComponent,
|
||||
} from './live-stream-layout-stubs.spec-data';
|
||||
|
||||
@@ -53,7 +51,9 @@ describe('LiveStreamLayoutComponent parental lock', () => {
|
||||
const parentalLock = {
|
||||
version: signal(0),
|
||||
active: signal(false),
|
||||
isXtreamCategoryLocked: jest.fn(() => false),
|
||||
isXtreamCategoryLocked: jest.fn(
|
||||
(_playlistId: string, _type: string, _xtreamId: number) => false
|
||||
),
|
||||
};
|
||||
const databaseService = {
|
||||
getAllXtreamCategories: jest.fn(async () => [] as unknown[]),
|
||||
@@ -164,7 +164,6 @@ describe('LiveStreamLayoutComponent parental lock', () => {
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
@@ -174,7 +173,6 @@ describe('LiveStreamLayoutComponent parental lock', () => {
|
||||
StubEpgTimelineComponent,
|
||||
StubGridListComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
|
||||
+5
-12
@@ -2,7 +2,6 @@ import { EpgArchiveDownloadService } from '@iptvnator/ui/epg';
|
||||
import { EpgArchiveCopyService } from '@iptvnator/ui/epg';
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
LiveLayoutSidebarStateService,
|
||||
liveSidebarStateStorageKey,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
ChannelListHiddenStateComponent,
|
||||
@@ -94,9 +92,6 @@ class StubGridListComponent {
|
||||
})
|
||||
class StubPassiveComponent {}
|
||||
|
||||
@Directive({ selector: '[appResizable]', standalone: true })
|
||||
class StubResizableDirective {}
|
||||
|
||||
describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
|
||||
let service: LiveLayoutSidebarStateService;
|
||||
@@ -222,7 +217,6 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
@@ -233,7 +227,6 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
StubGridListComponent,
|
||||
StubPassiveComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
MockPipe(TranslatePipe, (value: string) => value),
|
||||
],
|
||||
},
|
||||
@@ -325,7 +318,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="live-show-categories"]')
|
||||
@@ -341,7 +334,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
|
||||
expect(document.activeElement).toBe(sort);
|
||||
});
|
||||
@@ -353,13 +346,13 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
|
||||
service.collapse('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
expect(document.activeElement).toBe(query('.sidebar-restore'));
|
||||
|
||||
// The handle is removed with the expand, so focus is lost again.
|
||||
service.expand('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="live-show-categories"]')
|
||||
);
|
||||
@@ -374,7 +367,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
// The category button the user activated is inert now: focus lost.
|
||||
selectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="live-show-categories"]')
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ export class XtreamLiveChannelNavigationService {
|
||||
this.store
|
||||
.getCategoriesBySelectedType()
|
||||
.filter(
|
||||
(category) => !('hidden' in category && category.hidden)
|
||||
(category) => !('hidden' in category && category['hidden'])
|
||||
)
|
||||
.map((category) => Number(category.category_id ?? category.id))
|
||||
);
|
||||
|
||||
+23
-20
@@ -1,5 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService } from '@iptvnator/services';
|
||||
@@ -16,6 +17,10 @@ jest.mock('@iptvnator/portal/shared/ui', () => ({
|
||||
SearchLayoutComponent: class {},
|
||||
}));
|
||||
|
||||
/** The component is opened as a route, not a dialog: `@Optional()` resolves nothing. */
|
||||
const NO_DIALOG_REF =
|
||||
undefined as unknown as MatDialogRef<SearchResultsComponent>;
|
||||
|
||||
const DEFAULT_SEARCH_FILTERS: SearchFilters = {
|
||||
live: true,
|
||||
movie: true,
|
||||
@@ -122,7 +127,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
isGlobalSearch: true,
|
||||
initialQuery: 'matrix',
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
|
||||
@@ -138,7 +143,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
|
||||
@@ -151,7 +156,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
store.searchTerm.set('matrix');
|
||||
|
||||
const component = TestBed.runInInjectionContext(
|
||||
() => new SearchResultsComponent(null, undefined)
|
||||
() => new SearchResultsComponent(null, NO_DIALOG_REF)
|
||||
);
|
||||
TestBed.flushEffects();
|
||||
|
||||
@@ -166,7 +171,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
|
||||
@@ -191,7 +196,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
const channel = {
|
||||
@@ -249,7 +254,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
const freshResults = [
|
||||
createSearchItem({ xtream_id: 777, title: 'Fresh result' }),
|
||||
];
|
||||
const databaseService = TestBed.inject(DatabaseService) as {
|
||||
const databaseService = TestBed.inject(DatabaseService) as unknown as {
|
||||
globalSearchContent: jest.Mock;
|
||||
};
|
||||
databaseService.globalSearchContent
|
||||
@@ -262,7 +267,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
@@ -296,7 +301,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
xtream_id: 102,
|
||||
}),
|
||||
];
|
||||
const databaseService = TestBed.inject(DatabaseService) as {
|
||||
const databaseService = TestBed.inject(DatabaseService) as unknown as {
|
||||
globalSearchContent: jest.Mock;
|
||||
};
|
||||
databaseService.globalSearchContent
|
||||
@@ -309,7 +314,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
@@ -349,7 +354,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
it('clears the load-more indicator when a new global search supersedes pagination', async () => {
|
||||
const appendSearch = createDeferred<XtreamContentItem[]>();
|
||||
const freshSearch = createDeferred<XtreamContentItem[]>();
|
||||
const databaseService = TestBed.inject(DatabaseService) as {
|
||||
const databaseService = TestBed.inject(DatabaseService) as unknown as {
|
||||
globalSearchContent: jest.Mock;
|
||||
};
|
||||
databaseService.globalSearchContent
|
||||
@@ -362,7 +367,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
@@ -398,7 +403,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
xtream_id: 2,
|
||||
}),
|
||||
];
|
||||
const databaseService = TestBed.inject(DatabaseService) as {
|
||||
const databaseService = TestBed.inject(DatabaseService) as unknown as {
|
||||
globalSearchContent: jest.Mock;
|
||||
};
|
||||
databaseService.globalSearchContent.mockResolvedValueOnce(nextPage);
|
||||
@@ -409,7 +414,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
@@ -454,7 +459,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
xtream_id: index + 1,
|
||||
})
|
||||
);
|
||||
const databaseService = TestBed.inject(DatabaseService) as {
|
||||
const databaseService = TestBed.inject(DatabaseService) as unknown as {
|
||||
globalSearchContent: jest.Mock;
|
||||
};
|
||||
databaseService.globalSearchContent.mockResolvedValueOnce(firstPage);
|
||||
@@ -465,7 +470,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
{
|
||||
isGlobalSearch: true,
|
||||
},
|
||||
undefined
|
||||
NO_DIALOG_REF
|
||||
)
|
||||
);
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
@@ -519,7 +524,7 @@ describe('SearchResultsComponent in-portal result window', () => {
|
||||
it('windows the full result set, reveals further chunks, and resets on a new result set', () => {
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
const component = TestBed.runInInjectionContext(
|
||||
() => new SearchResultsComponent(null, undefined)
|
||||
() => new SearchResultsComponent(null, NO_DIALOG_REF)
|
||||
);
|
||||
const items = Array.from({ length: 130 }, (_, index) =>
|
||||
createSearchItem({
|
||||
@@ -558,7 +563,7 @@ describe('SearchResultsComponent in-portal result window', () => {
|
||||
it('changes the reset identity on filter-only transitions', () => {
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
const component = TestBed.runInInjectionContext(
|
||||
() => new SearchResultsComponent(null, undefined)
|
||||
() => new SearchResultsComponent(null, NO_DIALOG_REF)
|
||||
);
|
||||
|
||||
const initialIdentity = component.searchResetIdentity();
|
||||
@@ -571,9 +576,7 @@ describe('SearchResultsComponent in-portal result window', () => {
|
||||
const filteredIdentity = component.searchResetIdentity();
|
||||
try {
|
||||
component.toggleExcludeHidden(true);
|
||||
expect(component.searchResetIdentity()).not.toBe(
|
||||
filteredIdentity
|
||||
);
|
||||
expect(component.searchResetIdentity()).not.toBe(filteredIdentity);
|
||||
} finally {
|
||||
// toggleExcludeHidden persists; do not leak into other tests.
|
||||
localStorage.removeItem('xtream-search-exclude-hidden');
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ export class SerialDetailsPlaybackService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
|
||||
this.addToRecentlyViewed(this.route.snapshot.params['serialId']);
|
||||
|
||||
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
|
||||
const contentInfo: PlayerContentInfo = {
|
||||
|
||||
@@ -173,7 +173,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
readonly playbackSessionKey = computed(() =>
|
||||
createSerialPlaybackSessionKey(
|
||||
this.xtreamStore.currentPlaylist()?.id,
|
||||
this.routeParams().serialId,
|
||||
this.routeParams()['serialId'],
|
||||
this.playback.inlinePlaybackSessionEpisodeState()
|
||||
)
|
||||
);
|
||||
@@ -389,7 +389,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.xtreamStore.toggleFavorite(
|
||||
this.route.snapshot.params.serialId,
|
||||
this.route.snapshot.params['serialId'],
|
||||
playlist.id,
|
||||
'series',
|
||||
this.selectedItem()?.info?.backdrop_path?.[0]
|
||||
|
||||
@@ -109,6 +109,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
|
||||
vodId: routeVodId,
|
||||
vodInfo: signal(null),
|
||||
activeSource,
|
||||
supersedePendingSwitch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
component.multiSource,
|
||||
'markRouteSourceActive'
|
||||
);
|
||||
const beginPlayback = jest.spyOn(component.msUi, 'beginPlayback');
|
||||
const beginPlayback = jest.spyOn(component['msUi'], 'beginPlayback');
|
||||
|
||||
await component.playVod({
|
||||
movie_data: {
|
||||
@@ -325,7 +325,9 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
|
||||
it('guards Restart and provider actions before external IPC settles', async () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
stubs.openResolvedPlayback.mockReturnValue(new Promise(() => undefined));
|
||||
stubs.openResolvedPlayback.mockReturnValue(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
const component = fixture.componentInstance;
|
||||
const item = {
|
||||
movie_data: {
|
||||
|
||||
@@ -171,7 +171,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
readonly isFavorite = this.xtreamStore.isFavorite;
|
||||
readonly isWatched = this.watched.isWatched;
|
||||
readonly canToggleWatched = this.watched.canToggle;
|
||||
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
|
||||
readonly selectedVodId = computed(() =>
|
||||
Number(this.routeParams()['vodId'])
|
||||
);
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const contentId = this.selectedVodId();
|
||||
@@ -206,7 +208,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
: [];
|
||||
});
|
||||
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
|
||||
const categoryId = this.routeParams().categoryId;
|
||||
const categoryId = this.routeParams()['categoryId'];
|
||||
if (!categoryId) {
|
||||
return null;
|
||||
}
|
||||
@@ -648,7 +650,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.xtreamStore.toggleFavorite(
|
||||
this.route.snapshot.params.vodId,
|
||||
this.route.snapshot.params['vodId'],
|
||||
playlist.id,
|
||||
'movie',
|
||||
this.selectedVodInfo()?.backdrop_path?.[0]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
ALT_THREE,
|
||||
ALT_TWO,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
ALT_THREE,
|
||||
ALT_TWO,
|
||||
|
||||
+8
-2
@@ -437,7 +437,10 @@ describe('VodMultiSourceHostService', () => {
|
||||
const alts = [2, 3, 4, 5, 6].map(alternative);
|
||||
await loadMovie(alts);
|
||||
|
||||
const gates = new Map<string, ReturnType<typeof createDeferred>>();
|
||||
const gates = new Map<
|
||||
string,
|
||||
ReturnType<typeof createDeferred<null>>
|
||||
>();
|
||||
resolver.resolve.mockImplementation((candidate) => {
|
||||
const gate = createDeferred<null>();
|
||||
gates.set((candidate as VodSourceCandidate).id, gate);
|
||||
@@ -468,7 +471,10 @@ describe('VodMultiSourceHostService', () => {
|
||||
const alts = [2, 3, 4, 5, 6, 7].map(alternative);
|
||||
await loadMovie(alts);
|
||||
|
||||
const gates = new Map<string, ReturnType<typeof createDeferred>>();
|
||||
const gates = new Map<
|
||||
string,
|
||||
ReturnType<typeof createDeferred<null>>
|
||||
>();
|
||||
resolver.resolve.mockImplementation((candidate) => {
|
||||
const gate = createDeferred<null>();
|
||||
gates.set((candidate as VodSourceCandidate).id, gate);
|
||||
|
||||
@@ -41,7 +41,7 @@ const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = {
|
||||
origin: null,
|
||||
referrer: null,
|
||||
userAgent: null,
|
||||
} as XtreamPlaylistData;
|
||||
} as unknown as XtreamPlaylistData;
|
||||
type CachedScope = 'live' | 'vod' | 'series' | 'search' | 'recently-added';
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
@@ -51,9 +51,7 @@ async function flushEffects(): Promise<void> {
|
||||
}
|
||||
|
||||
function getXtreamSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:[/?]|$)/
|
||||
);
|
||||
const match = url.match(/^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:[/?]|$)/);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -81,14 +79,18 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
const selectedCategoryId = signal<number | null>(null);
|
||||
const isContentInitialized = signal(false);
|
||||
const contentLoadStateByType = signal<
|
||||
Record<'live' | 'vod' | 'series', 'idle' | 'loading' | 'ready' | 'error'>
|
||||
Record<
|
||||
'live' | 'vod' | 'series',
|
||||
'idle' | 'loading' | 'ready' | 'error'
|
||||
>
|
||||
>({
|
||||
live: 'idle',
|
||||
vod: 'idle',
|
||||
series: 'idle',
|
||||
});
|
||||
const contentInitBlockReason =
|
||||
signal<XtreamContentInitBlockReason | null>(null);
|
||||
const contentInitBlockReason = signal<XtreamContentInitBlockReason | null>(
|
||||
null
|
||||
);
|
||||
let hasUsableOfflineCache = false;
|
||||
|
||||
const playlistContext = {
|
||||
@@ -147,8 +149,9 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
series: 'ready',
|
||||
});
|
||||
}),
|
||||
hydrateCachedContent: jest.fn().mockImplementation(
|
||||
async (scope?: CachedScope | null) => {
|
||||
hydrateCachedContent: jest
|
||||
.fn()
|
||||
.mockImplementation(async (scope?: CachedScope | null) => {
|
||||
isContentInitialized.set(true);
|
||||
contentInitBlockReason.set(null);
|
||||
if (scope === 'live' || scope === 'vod' || scope === 'series') {
|
||||
@@ -163,30 +166,25 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
series: 'ready',
|
||||
});
|
||||
}
|
||||
}),
|
||||
prepareContentLoading: jest.fn((scope?: CachedScope | null) => {
|
||||
isContentInitialized.set(false);
|
||||
if (scope === 'live' || scope === 'vod' || scope === 'series') {
|
||||
contentLoadStateByType.update((state) => ({
|
||||
...state,
|
||||
[scope]: 'loading',
|
||||
}));
|
||||
} else {
|
||||
contentLoadStateByType.set({
|
||||
live: 'loading',
|
||||
vod: 'loading',
|
||||
series: 'loading',
|
||||
});
|
||||
}
|
||||
),
|
||||
prepareContentLoading: jest.fn(
|
||||
(scope?: CachedScope | null) => {
|
||||
isContentInitialized.set(false);
|
||||
if (scope === 'live' || scope === 'vod' || scope === 'series') {
|
||||
contentLoadStateByType.update((state) => ({
|
||||
...state,
|
||||
[scope]: 'loading',
|
||||
}));
|
||||
} else {
|
||||
contentLoadStateByType.set({
|
||||
live: 'loading',
|
||||
vod: 'loading',
|
||||
series: 'loading',
|
||||
});
|
||||
}
|
||||
}
|
||||
),
|
||||
setSelectedContentType: jest.fn(
|
||||
(type: 'live' | 'vod' | 'series') => {
|
||||
selectedContentType.set(type);
|
||||
}
|
||||
),
|
||||
}),
|
||||
setSelectedContentType: jest.fn((type: 'live' | 'vod' | 'series') => {
|
||||
selectedContentType.set(type);
|
||||
}),
|
||||
setSelectedCategory: jest.fn((categoryId: number | null) => {
|
||||
selectedCategoryId.set(categoryId);
|
||||
}),
|
||||
@@ -278,7 +276,9 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
|
||||
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
|
||||
null
|
||||
);
|
||||
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
|
||||
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('vod');
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
@@ -450,9 +450,7 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/202`;
|
||||
routerEvents.next(
|
||||
new NavigationEnd(1, router.url, router.url)
|
||||
);
|
||||
routerEvents.next(new NavigationEnd(1, router.url, router.url));
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
|
||||
@@ -531,9 +529,7 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
|
||||
'series'
|
||||
);
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
|
||||
'series'
|
||||
);
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith('series');
|
||||
expect(contentLoadStateByType().series).toBe('ready');
|
||||
|
||||
xtreamStore.hasUsableOfflineCache.mockClear();
|
||||
@@ -635,9 +631,7 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
|
||||
null
|
||||
);
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
|
||||
'search'
|
||||
);
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith('search');
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
declare module '@angular/material/snack-bar' {
|
||||
export class MatSnackBar {
|
||||
open(
|
||||
message: string,
|
||||
action?: string,
|
||||
config?: { duration?: number }
|
||||
): void;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -21,10 +21,10 @@ describe('EpgGuideKeyboardController', () => {
|
||||
beforeEach(() => {
|
||||
host = {
|
||||
rowCount: jest.fn(() => 5),
|
||||
blockCount: jest.fn(() => 3),
|
||||
blockCount: jest.fn((_row: number) => 3),
|
||||
activeRow: jest.fn(() => 2),
|
||||
isBlocked: jest.fn(() => false),
|
||||
isOwnedTarget: jest.fn(() => true),
|
||||
isOwnedTarget: jest.fn((_target: EventTarget | null) => true),
|
||||
play: jest.fn(),
|
||||
details: jest.fn(),
|
||||
jumpNow: jest.fn(),
|
||||
|
||||
@@ -5,7 +5,11 @@ import { By } from '@angular/platform-browser';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import type {
|
||||
EpgItemDialogAction,
|
||||
EpgItemDialogData,
|
||||
} from '../epg-item-description/epg-item-description.component';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { EpgGuideComponent } from './epg-guide.component';
|
||||
import {
|
||||
@@ -65,7 +69,11 @@ describe('EpgGuideComponent', () => {
|
||||
const searchHits = signal<EpgGuideSearchHit[]>([]);
|
||||
const activate = jest.fn();
|
||||
const setScope = jest.fn();
|
||||
const dialogOpen = jest.fn(() => of(undefined));
|
||||
const dialogOpen = jest.fn(
|
||||
(
|
||||
_data: EpgItemDialogData
|
||||
): Observable<EpgItemDialogAction | undefined> => of(undefined)
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { EpgSourceStatusComponent } from './epg-source-status.component';
|
||||
|
||||
/** Writable double for the bridge's read-only capability getters. */
|
||||
type EpgBridgeStub = {
|
||||
-readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K];
|
||||
};
|
||||
|
||||
describe('EpgSourceStatusComponent', () => {
|
||||
let fixture: ComponentFixture<EpgSourceStatusComponent>;
|
||||
let component: EpgSourceStatusComponent;
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: EpgBridgeStub;
|
||||
const imports = signal([]);
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -63,7 +63,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', ()
|
||||
const { session, player, video } = startHls(() => false);
|
||||
video.dispatchEvent(new Event('playing'));
|
||||
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/second.m3u8',
|
||||
player
|
||||
@@ -90,7 +91,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', ()
|
||||
showCaptions,
|
||||
});
|
||||
session.attach(player);
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
|
||||
@@ -40,7 +40,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['mpd']?.(
|
||||
session.customType['mpd']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.mpd',
|
||||
player
|
||||
@@ -68,13 +69,15 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['mpd']?.(
|
||||
session.customType['mpd']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.mpd',
|
||||
player
|
||||
);
|
||||
await flushShakaMicrotasks();
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -115,7 +118,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['mpd']?.(
|
||||
session.customType['mpd']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/failing.mpd',
|
||||
player
|
||||
|
||||
@@ -29,7 +29,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
session.attach(player);
|
||||
detach.mockClear();
|
||||
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -77,7 +78,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
const attach = jest.spyOn(adapter, 'attach');
|
||||
session.attach(player);
|
||||
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -105,7 +107,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
emitPlaybackIssue: (issue) => emitted.push(issue),
|
||||
});
|
||||
session.attach(player);
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -145,7 +148,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
emitPlaybackIssue: (issue) => emitted.push(issue),
|
||||
});
|
||||
session.attach(player);
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -187,7 +191,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['ts']?.(
|
||||
session.customType['ts']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/movie.ts',
|
||||
player
|
||||
@@ -218,7 +223,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['ts']?.(
|
||||
session.customType['ts']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/movie.ts',
|
||||
player
|
||||
@@ -239,7 +245,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -254,7 +261,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
error: new Error('unsupported codec'),
|
||||
});
|
||||
|
||||
session.customType['ts']?.(
|
||||
session.customType['ts']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.ts',
|
||||
player
|
||||
@@ -300,7 +308,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
});
|
||||
const secret = 'art-hls-secret-sentinel';
|
||||
session.attach(player);
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
@@ -354,12 +363,14 @@ describe('ArtPlayerSourceSession', () => {
|
||||
session.attach(player);
|
||||
session.destroy();
|
||||
|
||||
session.customType['m3u8']?.(
|
||||
session.customType['m3u8']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/stale.m3u8',
|
||||
player
|
||||
);
|
||||
session.customType['ts']?.(
|
||||
session.customType['ts']?.call(
|
||||
player,
|
||||
video,
|
||||
'https://example.test/stale.ts',
|
||||
player
|
||||
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user