test_serve_app_js_lazy_players.py
Rama main · réplica de lectura del repositorio original.
from __future__ import annotations
import json
import re
import shutil
import subprocess
import unittest
from pathlib import Path
class TestsLecteursClipsLazy(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.node = shutil.which("node")
if cls.node is None:
raise unittest.SkipTest("node introuvable")
source = Path(__file__).with_name("serve_app.js").read_text(encoding="utf-8")
match = re.search(
r"^function preparerLecteursClips\(\) \{.*?^\}",
source,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError("fonction introuvable : preparerLecteursClips")
cls.fonction = match.group(0)
def executer(self, actions: str) -> dict:
script = r"""
(async () => {
const events = [];
const allVideos = [];
const observers = [];
class FakeVideo {
constructor() {
this.id = `video-${allVideos.length + 1}`;
this.listeners = new Map();
this.attrs = {};
this.parent = null;
this.paused = true;
this.readyState = 0;
this.currentTime = 0;
this.loop = false;
this.volume = 1;
this.muted = false;
this.playbackRate = 1;
this.focusCount = 0;
this.loadCount = 0;
this.removed = false;
this.srcHistory = [];
this._src = '';
Object.defineProperty(this, 'src', {
get: () => this._src,
set: (value) => {
this._src = value;
this.currentTime = 0;
this.readyState = 0;
this.srcHistory.push(value);
},
});
allVideos.push(this);
}
addEventListener(name, callback, options = {}) {
if (options.signal?.aborted) return;
const callbacks = this.listeners.get(name) || [];
const entry = { callback, once: Boolean(options.once), active: true, signal: options.signal };
entry.remove = () => {
if (!entry.active) return;
entry.active = false;
const current = this.listeners.get(name) || [];
const index = current.indexOf(entry);
if (index >= 0) current.splice(index, 1);
entry.signal?.removeEventListener('abort', entry.remove);
};
if (entry.signal) entry.signal.addEventListener('abort', entry.remove, { once: true });
callbacks.push(entry);
this.listeners.set(name, callbacks);
}
removeEventListener(name, callback) {
for (const entry of [...(this.listeners.get(name) || [])]) {
if (entry.callback === callback) entry.remove();
}
}
dispatch(name) {
const callbacks = [...(this.listeners.get(name) || [])];
for (const entry of callbacks) {
if (!entry.active) continue;
entry.callback({ type: name, target: this });
if (entry.once) entry.remove();
}
}
setAttribute(name, value) { this.attrs[name] = String(value); }
removeAttribute(name) {
if (name === 'src') this._src = '';
if (name === 'poster') this.poster = '';
delete this.attrs[name];
events.push(`removeAttribute:${this.id}:${name}`);
}
pause() {
this.paused = true;
events.push(`pause:${this.id}`);
this.dispatch('pause');
}
load() {
this.loadCount += 1;
events.push(`load:${this.id}`);
}
remove() {
this.removed = true;
if (this.parent) this.parent.removeChild(this);
events.push(`remove:${this.id}`);
}
focus(options) {
this.focusCount += 1;
this.focusOptions = options;
document.activeElement = this;
events.push(`focus:${this.id}`);
}
closest(selector) {
return this.parent ? this.parent.closest(selector) : null;
}
}
class FakeZone {
constructor(id) {
this.id = id;
this.className = 'clip-player';
this.tabIndex = 0;
this.dataset = { poster: `/thumb/${id}`, src: `/media/${id}` };
this.attrs = { 'aria-label': `clip ${id}` };
this.child = null;
}
append(video) {
this.child = video;
video.parent = this;
video.removed = false;
}
removeChild(video) {
if (this.child === video) this.child = null;
video.parent = null;
}
contains(node) {
return node === this || node === this.child;
}
closest(selector) {
return selector === '.clip-player' ? this : null;
}
getAttribute(name) { return this.attrs[name] ?? null; }
}
class FakeList {
constructor(zones) {
this.zones = zones;
this.listeners = new Map();
}
querySelectorAll(selector) {
return selector === '.clip-player' ? this.zones : [];
}
addEventListener(name, callback) {
const callbacks = this.listeners.get(name) || [];
callbacks.push(callback);
this.listeners.set(name, callbacks);
}
removeEventListener(name, callback) {
const callbacks = this.listeners.get(name) || [];
const index = callbacks.indexOf(callback);
if (index >= 0) callbacks.splice(index, 1);
}
dispatch(name, target) {
for (const callback of [...(this.listeners.get(name) || [])]) {
callback({ type: name, target });
}
}
listenerCount(name) { return (this.listeners.get(name) || []).length; }
}
class FakeObserver {
constructor(callback, options) {
this.callback = callback;
this.options = options;
this.observed = [];
this.pending = [];
this.takenRecords = 0;
this.disconnected = false;
this.disconnectCount = 0;
observers.push(this);
}
observe(zone) { this.observed.push(zone); }
queue(zone, isIntersecting) {
this.pending.push({ target: zone, isIntersecting });
}
disconnect() {
this.disconnected = true;
this.disconnectCount += 1;
}
emit(zone, isIntersecting) {
if (!this.disconnected) this.callback([{ target: zone, isIntersecting }]);
}
flush() {
if (this.pending.length) this.callback(this.pending.splice(0));
}
takeRecords() {
const records = this.pending.splice(0);
this.takenRecords += records.length;
return records;
}
}
const document = {
activeElement: null,
pictureInPictureElement: null,
fullscreenElement: null,
createElement(tag) {
if (tag !== 'video') throw Error(`unexpected element: ${tag}`);
return new FakeVideo();
},
};
globalThis.document = document;
globalThis.IntersectionObserver = FakeObserver;
const zones = [new FakeZone('a'), new FakeZone('b')];
const zoneA = zones[0];
const zoneB = zones[1];
const list = new FakeList(zones);
const $ = (id) => id === 'list' ? list : null;
let nettoyerLecteursClips = () => {};
FONCTION
preparerLecteursClips();
const observer = observers[0];
const checkpoints = [];
const description = (zone) => {
const video = zone.child;
return {
child: video ? video.id : null,
tabIndex: zone.tabIndex,
poster: video ? video.poster : null,
paused: video ? video.paused : null,
readyState: video ? video.readyState : null,
currentTime: video ? video.currentTime : null,
volume: video ? video.volume : null,
muted: video ? video.muted : null,
playbackRate: video ? video.playbackRate : null,
loop: video ? video.loop : null,
};
};
const mark = (name, zone = zoneA) => checkpoints.push({ name, state: description(zone) });
ACTIONS
await Promise.resolve();
await Promise.resolve();
process.stdout.write(JSON.stringify({
checkpoints,
events,
observer: {
rootMargin: observer.options.rootMargin,
observed: observer.observed.map((zone) => zone.id),
disconnected: observer.disconnected,
disconnectCount: observer.disconnectCount,
pending: observer.pending.length,
takenRecords: observer.takenRecords,
},
listeners: {
focusin: list.listenerCount('focusin'),
focusout: list.listenerCount('focusout'),
},
videos: allVideos.map((video) => ({
id: video.id,
srcHistory: video.srcHistory,
poster: video.poster,
preload: video.preload,
controls: video.controls,
playsInline: video.playsInline,
loop: video.loop,
ariaLabel: video.attrs['aria-label'] || null,
focusCount: video.focusCount,
loadCount: video.loadCount,
removed: video.removed,
})),
}));
})().catch((error) => {
console.error(error && error.stack ? error.stack : String(error));
process.exitCode = 1;
});
""".replace("FONCTION", self.fonction).replace("ACTIONS", actions)
resultat = subprocess.run(
[self.node, "-e", script],
capture_output=True,
text=True,
encoding="utf-8",
timeout=20,
)
if resultat.returncode != 0:
raise AssertionError(resultat.stderr or resultat.stdout)
return json.loads(resultat.stdout)
@staticmethod
def checkpoint(sortie: dict, name: str) -> dict:
for checkpoint in sortie["checkpoints"]:
if checkpoint["name"] == name:
return checkpoint["state"]
raise AssertionError(f"checkpoint absent : {name}")
def test_entree_et_sortie_creent_puis_liberent_un_joueur_en_pause(self):
sortie = self.executer("""
observer.emit(zoneA, true);
mark('entered');
observer.emit(zoneA, false);
mark('exited');
""")
self.assertEqual(self.checkpoint(sortie, "entered")["child"], "video-1")
self.assertIsNone(self.checkpoint(sortie, "exited")["child"])
self.assertEqual(self.checkpoint(sortie, "exited")["tabIndex"], 0)
self.assertEqual(sortie["observer"]["rootMargin"], "600px 0px")
self.assertEqual(sortie["observer"]["observed"], ["a", "b"])
self.assertEqual(sortie["videos"][0]["srcHistory"], ["/media/a"])
self.assertEqual(self.checkpoint(sortie, "entered")["poster"], "/thumb/a")
self.assertEqual(sortie["videos"][0]["poster"], "")
self.assertEqual(sortie["videos"][0]["loadCount"], 1)
self.assertTrue(sortie["videos"][0]["removed"])
def test_lecture_active_protegee_jusqua_pause_puis_liberee(self):
sortie = self.executer("""
observer.emit(zoneA, true);
const video = zoneA.child;
video.paused = false;
observer.emit(zoneA, false);
mark('while-playing');
video.pause();
mark('after-pause');
""")
self.assertEqual(self.checkpoint(sortie, "while-playing")["child"], "video-1")
self.assertIsNone(self.checkpoint(sortie, "after-pause")["child"])
self.assertIn("pause:video-1", sortie["events"])
self.assertIn("remove:video-1", sortie["events"])
def test_focus_protege_le_joueur_puis_focusout_le_libere(self):
sortie = self.executer("""
observer.emit(zoneA, true);
list.dispatch('focusin', zoneA);
const video = zoneA.child;
observer.emit(zoneA, false);
mark('focused-away');
document.activeElement = null;
list.dispatch('focusout', video);
await Promise.resolve();
mark('after-focusout');
""")
self.assertEqual(self.checkpoint(sortie, "focused-away")["child"], "video-1")
self.assertIsNone(self.checkpoint(sortie, "after-focusout")["child"])
self.assertEqual(sortie["videos"][0]["focusCount"], 1)
def test_position_et_reglages_sur_remontage_avant_metadata(self):
sortie = self.executer("""
observer.emit(zoneA, true);
const first = zoneA.child;
first.readyState = 1;
first.currentTime = 42.25;
first.volume = 0.35;
first.muted = true;
first.playbackRate = 1.5;
observer.emit(zoneA, false);
observer.emit(zoneA, true);
mark('first-remount-before-metadata');
observer.emit(zoneA, false);
observer.emit(zoneA, true);
const second = zoneA.child;
mark('second-remount-before-metadata');
second.dispatch('loadedmetadata');
mark('second-remount-after-metadata');
""")
first_remount = self.checkpoint(sortie, "first-remount-before-metadata")
self.assertEqual(first_remount["child"], "video-1")
self.assertEqual(first_remount["currentTime"], 0)
self.assertEqual(first_remount["volume"], 0.35)
self.assertTrue(first_remount["muted"])
self.assertEqual(first_remount["playbackRate"], 1.5)
second_remount = self.checkpoint(sortie, "second-remount-before-metadata")
self.assertEqual(second_remount["child"], "video-1")
self.assertEqual(second_remount["currentTime"], 0)
after_metadata = self.checkpoint(sortie, "second-remount-after-metadata")
self.assertEqual(after_metadata["currentTime"], 42.25)
self.assertEqual(after_metadata["volume"], 0.35)
self.assertTrue(after_metadata["muted"])
self.assertEqual(after_metadata["playbackRate"], 1.5)
def test_plein_ecran_et_image_dans_image_proteges_jusqua_sortie(self):
for propriete, evenement in (("fullscreenElement", "fullscreenchange"),
("pictureInPictureElement", "leavepictureinpicture")):
with self.subTest(propriete=propriete):
sortie = self.executer(f"""
observer.emit(zoneA, true);
const video = zoneA.child;
document.{propriete} = video;
observer.emit(zoneA, false);
mark('protected');
document.{propriete} = null;
video.dispatch('{evenement}');
mark('released');
""")
self.assertEqual(self.checkpoint(sortie, "protected")["child"], "video-1")
self.assertIsNone(self.checkpoint(sortie, "released")["child"])
def test_reutilisation_reinitialise_les_reglages_et_abort_le_metadata_ancien(self):
sortie = self.executer("""
observer.emit(zoneA, true);
const first = zoneA.child;
first.readyState = 1;
first.currentTime = 19.5;
first.volume = 0.25;
first.muted = true;
first.playbackRate = 1.75;
first.loop = true;
observer.emit(zoneA, false);
observer.emit(zoneA, true);
const second = zoneA.child;
second.readyState = 1;
second.dispatch('loadedmetadata');
mark('reused-with-state');
observer.emit(zoneA, false);
observer.emit(zoneA, true);
observer.emit(zoneA, false);
observer.emit(zoneB, true);
const third = zoneB.child;
third.dispatch('loadedmetadata');
mark('reused-for-new-clip', zoneB);
""")
reused = self.checkpoint(sortie, "reused-with-state")
self.assertEqual(reused["child"], "video-1")
self.assertEqual(reused["currentTime"], 19.5)
self.assertEqual(reused["volume"], 0.25)
self.assertTrue(reused["muted"])
self.assertEqual(reused["playbackRate"], 1.75)
self.assertTrue(reused["loop"])
new_clip = self.checkpoint(sortie, "reused-for-new-clip")
self.assertEqual(new_clip["child"], "video-1")
self.assertEqual(new_clip["currentTime"], 0)
self.assertEqual(new_clip["volume"], 1)
self.assertFalse(new_clip["muted"])
self.assertEqual(new_clip["playbackRate"], 1)
self.assertFalse(new_clip["loop"])
self.assertEqual(len(sortie["videos"]), 1)
self.assertEqual(sortie["videos"][0]["srcHistory"], ["/media/a", "/media/a", "/media/a", "/media/b"])
def test_nettoyage_deconnecte_observer_et_force_la_liberation(self):
sortie = self.executer("""
observer.emit(zoneA, true);
observer.emit(zoneB, true);
zoneA.child.paused = false;
observer.queue(zoneA, true);
nettoyerLecteursClips();
mark('after-cleanup', zoneA);
mark('after-cleanup-b', zoneB);
observer.flush();
observer.emit(zoneA, true);
mark('after-stale-observer', zoneA);
""")
self.assertIsNone(self.checkpoint(sortie, "after-cleanup")["child"])
self.assertIsNone(self.checkpoint(sortie, "after-cleanup-b")["child"])
self.assertIsNone(self.checkpoint(sortie, "after-stale-observer")["child"])
self.assertTrue(sortie["observer"]["disconnected"])
self.assertEqual(sortie["observer"]["disconnectCount"], 1)
self.assertEqual(sortie["observer"]["pending"], 0)
self.assertEqual(sortie["observer"]["takenRecords"], 1)
self.assertEqual(sortie["listeners"], {"focusin": 0, "focusout": 0})
self.assertGreaterEqual(sortie["events"].count("remove:video-1"), 1)
self.assertGreaterEqual(sortie["events"].count("remove:video-2"), 1)
if __name__ == "__main__":
unittest.main()