mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Télémétrie (session olfp14) : le panneau n'avait envoyé que ~18 s d'avance au démarrage ; la réserve est restée à 13-20 s et un silence du panneau a fini en coupure de 5,5 s. Le lecteur ne fabriquait pas de réserve : il gardait seulement celle offerte par le panneau. Régulateur de vitesse unique pour le direct MPEG-TS, chaque seconde : sous 20 s d'avance, x0,97 (hauteur du son préservée) jusqu'à 25 s ; au-delà de 35 s (rafale de rattrapage), x1,1 jusqu'à 28 s. Seuils d'entrée/sortie distincts : pas d'oscillation. liveSync de mpegts.js désactivé : il ne sait qu'accélérer et remettait la vitesse à 1 sous sa cible, ce qui annulerait le ralenti. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
// Tests du moteur de lecture web (web/xf-player-core.js).
|
|
//
|
|
// Lancement : node --test "test/web/*_test.mjs"
|
|
//
|
|
// Le moteur est un script navigateur (IIFE sur `window`) : on l'exécute dans
|
|
// un contexte vm avec un DOM minimal et un faux mpegts.js qui capture la
|
|
// configuration reçue.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
const source = readFileSync(
|
|
new URL('../../web/xf-player-core.js', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
function fakeEventTarget() {
|
|
return { addEventListener() {}, removeEventListener() {} };
|
|
}
|
|
|
|
/** Charge le moteur et renvoie la config passée à mpegts.createPlayer. */
|
|
function liveMpegtsConfig({ profile } = {}) {
|
|
const created = [];
|
|
const video = {
|
|
...fakeEventTarget(),
|
|
canPlayType: () => '',
|
|
play: () => Promise.resolve(),
|
|
buffered: { length: 0 },
|
|
seekable: { length: 0 },
|
|
};
|
|
const window = {
|
|
...fakeEventTarget(),
|
|
console: { log() {} },
|
|
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
|
parent: { postMessage() {} },
|
|
mpegts: {
|
|
isSupported: () => true,
|
|
Events: { MEDIA_INFO: 'media_info', ERROR: 'error' },
|
|
createPlayer(mediaDataSource, config) {
|
|
created.push({ mediaDataSource, config });
|
|
return { on() {}, attachMediaElement() {}, load() {} };
|
|
},
|
|
},
|
|
};
|
|
const context = vm.createContext({
|
|
window,
|
|
location: window.location,
|
|
document: fakeEventTarget(),
|
|
URLSearchParams,
|
|
setInterval: () => 0,
|
|
clearInterval() {},
|
|
setTimeout,
|
|
});
|
|
vm.runInContext(source, context);
|
|
|
|
const XFPlayer = window.XFPlayer;
|
|
const player = new XFPlayer({
|
|
video,
|
|
url: '/api/live/1/turbo.ts',
|
|
type: 'live',
|
|
});
|
|
if (profile) player.profile = window.XFPlayerProfiles[profile];
|
|
player._createMpegts(true);
|
|
assert.equal(created.length, 1);
|
|
return created[0].config;
|
|
}
|
|
|
|
// Les panneaux Xtream relaient souvent une source HLS : le flux arrive par
|
|
// rafales d'environ 6 s séparées de silences complets (mesuré en prod).
|
|
const UPSTREAM_BURST_GAP_S = 7;
|
|
|
|
for (const profile of ['fast', 'balanced', 'safe']) {
|
|
test(`live (${profile}) : la marge gardée après rattrapage couvre un silence amont`, () => {
|
|
const c = liveMpegtsConfig({ profile });
|
|
if (!c.liveBufferLatencyChasing) return; // pas de saut = rien à vérifier
|
|
assert.ok(
|
|
c.liveBufferLatencyMinRemain >= UPSTREAM_BURST_GAP_S,
|
|
`MinRemain ${c.liveBufferLatencyMinRemain}s < silence amont ${UPSTREAM_BURST_GAP_S}s : ` +
|
|
'chaque rafale déclenche un saut puis une coupure',
|
|
);
|
|
});
|
|
|
|
test(`live (${profile}) : une rafale normale ne déclenche pas de saut`, () => {
|
|
const c = liveMpegtsConfig({ profile });
|
|
if (!c.liveBufferLatencyChasing) return;
|
|
// Après une rafale, le buffer vaut la marge + une rafale entière.
|
|
assert.ok(
|
|
c.liveBufferLatencyMaxLatency >=
|
|
c.liveBufferLatencyMinRemain + 2 * UPSTREAM_BURST_GAP_S,
|
|
'seuil de rattrapage trop proche de la marge : sauts à chaque rafale',
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Réglages du régulateur de vitesse (exposés par le moteur). */
|
|
function liveTuning() {
|
|
const window = {
|
|
addEventListener() {}, removeEventListener() {},
|
|
console: { log() {} },
|
|
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
|
parent: { postMessage() {} },
|
|
};
|
|
vm.runInContext(source, vm.createContext({
|
|
window, location: window.location,
|
|
document: { addEventListener() {}, removeEventListener() {} },
|
|
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout,
|
|
}));
|
|
return window.XFPlayerLiveTuning;
|
|
}
|
|
|
|
test('live : un seul régulateur de vitesse (liveSync de mpegts.js désactivé)', () => {
|
|
// liveSync ne sait qu'accélérer et remet la vitesse à 1 sous sa cible :
|
|
// il annulerait le ralenti qui reconstitue la réserve.
|
|
const c = liveMpegtsConfig();
|
|
assert.notEqual(c.liveSync, true);
|
|
const t = liveTuning();
|
|
assert.ok(t.slowRate < 1 && t.slowRate >= 0.95, 'ralenti audible au-delà');
|
|
assert.ok(t.fastRate > 1 && t.fastRate <= 1.15);
|
|
assert.ok(t.lowAhead < t.targetAhead && t.targetAhead < t.highAhead);
|
|
assert.ok(t.highAhead < c.liveBufferLatencyMaxLatency);
|
|
});
|
|
|
|
test('télémétrie : une coupure (waiting → playing) est remontée avec sa durée', async () => {
|
|
const sent = [];
|
|
const listeners = {};
|
|
const video = {
|
|
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
|
removeEventListener() {},
|
|
canPlayType: () => '',
|
|
play: () => Promise.resolve(),
|
|
buffered: { length: 0 },
|
|
seekable: { length: 0 },
|
|
currentTime: 12,
|
|
playbackRate: 1,
|
|
};
|
|
const fire = (type) => (listeners[type] || []).forEach((fn) => fn());
|
|
const window = {
|
|
addEventListener() {}, removeEventListener() {},
|
|
console: { log() {} },
|
|
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
|
parent: { postMessage() {} },
|
|
localStorage: { getItem: () => 'tok' },
|
|
};
|
|
let now = 1000;
|
|
const context = vm.createContext({
|
|
window,
|
|
location: window.location,
|
|
document: { addEventListener() {}, removeEventListener() {} },
|
|
URLSearchParams,
|
|
setInterval: () => 0,
|
|
clearInterval() {},
|
|
setTimeout,
|
|
Date: { now: () => now },
|
|
fetch: (url, opts) => { sent.push({ url, body: JSON.parse(opts.body) }); return Promise.resolve(); },
|
|
});
|
|
vm.runInContext(source, context);
|
|
const player = new window.XFPlayer({ video, url: '/api/live/554034/turbo.ts', type: 'live' });
|
|
player._wireVideoEvents();
|
|
|
|
fire('playing'); // premier affichage
|
|
now += 1000; fire('waiting');
|
|
now += 4200; fire('playing'); // reprise 4,2 s plus tard
|
|
|
|
const stall = sent.map((s) => s.body).find((b) => b.event === 'stall');
|
|
assert.ok(stall, 'aucun événement stall envoyé');
|
|
assert.equal(stall.ms, 4200);
|
|
assert.equal(stall.ch, '554034');
|
|
assert.equal(sent[0].url, '/api/player-log');
|
|
});
|
|
|
|
// Freeze mesuré en prod (télémétrie) : le panneau s'est tu ~15-20 s sur une
|
|
// connexion restée ouverte, alors que le lecteur n'avait gardé que 11-16 s.
|
|
const PROVIDER_OUTAGE_S = 18;
|
|
|
|
test('live : la réserve visée couvre un silence du panneau de ~18 s', () => {
|
|
const c = liveMpegtsConfig();
|
|
const t = liveTuning();
|
|
// Le régulateur ramène l'avance vers la cible : c'est la réserve en
|
|
// régime établi. Elle doit dépasser le silence mesuré — et le ralenti
|
|
// doit se déclencher AVANT d'être passé sous ce seuil de sécurité.
|
|
assert.ok(t.targetAhead > PROVIDER_OUTAGE_S,
|
|
`cible ${t.targetAhead}s < silence mesuré ${PROVIDER_OUTAGE_S}s`);
|
|
assert.ok(t.lowAhead > PROVIDER_OUTAGE_S);
|
|
assert.ok(c.liveBufferLatencyMinRemain > PROVIDER_OUTAGE_S);
|
|
});
|
|
|
|
test('régulateur : ralentit sous la réserve, accélère au-dessus, sans osciller', () => {
|
|
const listeners = {};
|
|
let ahead = 0;
|
|
const video = {
|
|
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
|
removeEventListener() {},
|
|
canPlayType: () => '',
|
|
play: () => Promise.resolve(),
|
|
pause() {},
|
|
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
|
|
seekable: { length: 0 },
|
|
currentTime: 100,
|
|
playbackRate: 1,
|
|
paused: false,
|
|
seeking: false,
|
|
};
|
|
const window = {
|
|
addEventListener() {}, removeEventListener() {},
|
|
console: { log() {} },
|
|
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
|
parent: { postMessage() {} },
|
|
};
|
|
vm.runInContext(source, vm.createContext({
|
|
window, location: window.location,
|
|
document: { addEventListener() {}, removeEventListener() {} },
|
|
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
|
|
}));
|
|
const t = window.XFPlayerLiveTuning;
|
|
const p = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
|
|
p.mpegts = {};
|
|
p._started = true;
|
|
|
|
ahead = 13; p._tuneRate(); // session réelle : 13 s
|
|
assert.equal(video.playbackRate, t.slowRate);
|
|
ahead = t.lowAhead + 2; p._tuneRate(); // remonte, pas encore la cible
|
|
assert.equal(video.playbackRate, t.slowRate, 'repasse à 1 trop tôt : oscille');
|
|
ahead = t.targetAhead; p._tuneRate();
|
|
assert.equal(video.playbackRate, 1);
|
|
ahead = t.highAhead + 5; p._tuneRate(); // rafale de rattrapage
|
|
assert.equal(video.playbackRate, t.fastRate);
|
|
ahead = t.highAhead - 2; p._tuneRate();
|
|
assert.equal(video.playbackRate, t.fastRate, 'freine trop tôt : oscille');
|
|
ahead = t.targetAhead + 2; p._tuneRate();
|
|
assert.equal(video.playbackRate, 1);
|
|
});
|
|
|
|
test('live : la rafale de rattrapage après un silence ne fait pas sauter le programme', () => {
|
|
// Mesuré en prod : après un silence, le panneau renvoie d'un coup tout le
|
|
// retard ; l'avance a dépassé 45 s et le lecteur a sauté 2 fois de 20 s
|
|
// (programme perdu). Le retard doit se résorber en douceur (liveSync).
|
|
const observedPeakAhead = 45;
|
|
const c = liveMpegtsConfig();
|
|
assert.ok(c.liveBufferLatencyMaxLatency >= 2 * observedPeakAhead - 10,
|
|
`saut dès ${c.liveBufferLatencyMaxLatency}s d'avance : la rafale de rattrapage le déclenche`);
|
|
assert.ok(liveTuning().highAhead < observedPeakAhead,
|
|
'le régulateur doit déjà accélérer avant ce niveau');
|
|
});
|
|
|
|
test('live : la vidéo déjà vue est purgée plus tôt, l\'avance étant plus grande', () => {
|
|
const c = liveMpegtsConfig();
|
|
assert.ok(c.autoCleanupMaxBackwardDuration <= 30);
|
|
});
|
|
|
|
test('reprise après coupure : attendre une vraie réserve avant de relire', () => {
|
|
const calls = [];
|
|
const listeners = {};
|
|
let ahead = 0;
|
|
const video = {
|
|
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
|
removeEventListener() {},
|
|
canPlayType: () => '',
|
|
play: () => { calls.push('play'); return Promise.resolve(); },
|
|
pause: () => { calls.push('pause'); },
|
|
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
|
|
seekable: { length: 0 },
|
|
currentTime: 100,
|
|
playbackRate: 1,
|
|
seeking: false,
|
|
};
|
|
const fire = (type) => (listeners[type] || []).forEach((fn) => fn());
|
|
const window = {
|
|
addEventListener() {}, removeEventListener() {},
|
|
console: { log() {} },
|
|
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
|
parent: { postMessage() {} },
|
|
};
|
|
const context = vm.createContext({
|
|
window, location: window.location,
|
|
document: { addEventListener() {}, removeEventListener() {} },
|
|
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
|
|
});
|
|
vm.runInContext(source, context);
|
|
const player = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
|
|
player.mpegts = {}; // flux MPEG-TS en cours
|
|
player._wireVideoEvents();
|
|
|
|
fire('playing');
|
|
fire('waiting'); // le panneau se tait
|
|
assert.deepEqual(calls.slice(-1), ['pause'], 'le lecteur doit se mettre en attente');
|
|
|
|
ahead = 1; // 1 s arrive : trop peu, ne pas repartir
|
|
player._checkHold();
|
|
assert.ok(!calls.slice(1).includes('play'), 'reparti avec 1 s : recoupera aussitôt');
|
|
|
|
ahead = 5; // vraie réserve reconstituée
|
|
player._checkHold();
|
|
assert.equal(calls.at(-1), 'play');
|
|
});
|
|
|
|
test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => {
|
|
const c = liveMpegtsConfig();
|
|
assert.equal(c.autoCleanupSourceBuffer, true);
|
|
assert.ok(
|
|
c.autoCleanupMinBackwardDuration < c.autoCleanupMaxBackwardDuration,
|
|
);
|
|
});
|