Files
iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts
T
4grayandClaude Opus 5 c723093890 test(playback): drive embedded MPV controller specs on a fake clock (#1434)
* test(playback): drive embedded MPV controller specs on a fake clock

The `waitFor` helper in the three embedded-mpv-session-controller specs
polled real timers against a `Date.now() + 1s` deadline. Under parallel
Jest workers the controller's async startup chain (support load ->
waitForStartupPaint -> session create -> update subscription) did not
always settle inside that budget, so a different pair of tests failed on
each run.

Bound the drain loop by rounds on a fake clock instead of elapsed wall
time, so machine load can no longer change the outcome. Each round
advances 1ms rather than 0ms: `waitForStartupPaint` nests rAF inside rAF,
and a zero-delay timer scheduled from inside a timer callback is clamped
to the next millisecond, so a 0ms advance fires the outer hop and strands
the inner one forever.

Assertions and behavior under test are unchanged. Fake timers also stop
the stalled tracker's 30s timeout leaking into the real timer queue,
which removes the "worker process failed to exit gracefully" warning.

Verified with 5x `nx test ui-playback --skip-nx-cache` and 5x the same
suite at --maxWorkers=32 (the condition that reproduced the flake):
975/975 tests passing each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(playback): scope the fake-clock claim in the MPV controller spec

The comment said machine load "no longer changes the outcome", which
overclaims. Removing the spec's own `Date.now()` deadline means the spec
imposes no budget of its own, but Jest's per-test timeout still measures
wall time — at extreme worker oversubscription these specs can still hit
it, as every spec can.

Comment only; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:30:09 +02:00

226 lines
7.4 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
* Moving the window to a display with a different scale (or changing the
* page zoom) rescales the CSS→native mapping the backend applies to bounds,
* without necessarily resizing the host element. The controller watches
* devicePixelRatio through a re-armed matchMedia query and re-syncs bounds
* when it changes (#1145).
*/
describe('EmbeddedMpvSessionController devicePixelRatio watch', () => {
class FakeMediaQueryList {
private readonly listeners = new Set<() => void>();
constructor(readonly media: string) {}
addEventListener(_type: 'change', listener: () => void): void {
this.listeners.add(listener);
}
removeEventListener(_type: 'change', listener: () => void): void {
this.listeners.delete(listener);
}
fire(): void {
for (const listener of [...this.listeners]) {
listener();
}
}
get listenerCount(): number {
return this.listeners.size;
}
}
let electron: {
platform: string;
getEmbeddedMpvSupport: jest.Mock;
prepareEmbeddedMpv: jest.Mock;
createEmbeddedMpvSession: jest.Mock;
loadEmbeddedMpvPlayback: jest.Mock;
disposeEmbeddedMpvSession: jest.Mock;
setEmbeddedMpvBounds: jest.Mock;
onEmbeddedMpvSessionUpdate: jest.Mock;
};
let mediaQueries: FakeMediaQueryList[];
beforeEach(() => {
// See `waitFor`: the startup chain is drained on a virtual clock so a
// loaded machine cannot change the outcome.
jest.useFakeTimers();
electron = {
platform: 'win32',
getEmbeddedMpvSupport: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'win32' }),
prepareEmbeddedMpv: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'win32' }),
createEmbeddedMpvSession: jest
.fn()
.mockResolvedValue(createSession()),
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: electron,
});
mediaQueries = [];
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (media: string) => {
const query = new FakeMediaQueryList(media);
mediaQueries.push(query);
return query;
},
});
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class MockResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
},
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: (handle: number) => window.clearTimeout(handle),
});
TestBed.configureTestingModule({
providers: [EmbeddedMpvSessionController],
});
});
afterEach(() => {
TestBed.resetTestingModule();
delete (window as unknown as { electron?: unknown }).electron;
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
jest.useRealTimers();
jest.restoreAllMocks();
});
it('re-syncs bounds and re-arms the query when devicePixelRatio changes', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
expect(mediaQueries.length).toBe(1);
expect(mediaQueries[0].media).toBe('(resolution: 1dppx)');
electron.setEmbeddedMpvBounds.mockClear();
// Simulate a move to a 150%-scaled display.
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1.5,
});
mediaQueries[0].fire();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after dPR change'
);
// The stale query is released and a new one tracks the new ratio.
expect(mediaQueries[0].listenerCount).toBe(0);
expect(mediaQueries.length).toBe(2);
expect(mediaQueries[1].media).toBe('(resolution: 1.5dppx)');
// A second display change must fire through the re-armed query.
electron.setEmbeddedMpvBounds.mockClear();
mediaQueries[1].fire();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after second dPR change'
);
teardown();
expect(mediaQueries[mediaQueries.length - 1].listenerCount).toBe(0);
});
});
function createHost(): HTMLElement {
return {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 640,
height: 360,
}),
} as HTMLElement;
}
function createPlayback(): ResolvedPortalPlayback {
return {
streamUrl: 'https://example.com/movie.mp4',
title: 'Example Movie',
};
}
function createSession(): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 0,
durationSeconds: null,
volume: 0.5,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-07-19T00:00:00.000Z',
updatedAt: '2026-07-19T00:00:01.000Z',
};
}
/**
* Settle the controller's async startup chain on the fake clock, bounded by
* drain rounds rather than wall-clock time — under parallel Jest workers a
* real-timer deadline expired before the chain settled and failed at random.
*
* One millisecond per round, never zero: `waitForStartupPaint` nests rAF
* inside rAF, and a zero-delay timer scheduled from inside a timer callback is
* clamped to the next millisecond, so a 0ms advance strands the inner hop.
*/
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
for (let round = 0; round < 100; round += 1) {
if (condition()) {
return;
}
await Promise.resolve();
await jest.advanceTimersByTimeAsync(1);
}
throw new Error(`Timed out waiting for ${description}`);
}