test_serve_app_js_ronde.py
Rama main · réplica de lectura del repositorio original.
"""Issue #40, demande de MCas86 : la ronde des directs, une caméra après
l'autre en plein écran. Le vrai code de serve_app.js, exécuté par node avec
un faux DOM qui sait déplacer un nœud, une fausse horloge et de faux
watchLive()/stopWatch() ; les contrôles de structure portent sur serve.py et
le CSS."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import unittest
from pathlib import Path
RACINE = Path(__file__).parent
SOURCE = (RACINE / "serve_app.js").read_text(encoding="utf-8")
PAGE = (RACINE / "serve.py").read_text(encoding="utf-8")
CSS = (RACINE / "serve_style.css").read_text(encoding="utf-8")
def extraire(motif: str) -> str:
trouve = re.search(motif, SOURCE, re.DOTALL | re.MULTILINE)
if trouve is None:
raise AssertionError(f"introuvable dans serve_app.js : {motif}")
return trouve.group(0)
FONCTIONS = [
"normaliserReglagesRonde", "lireReglagesRonde", "enregistrerReglagesRonde",
"dansPlageRonde", "camerasGrille", "camerasRonde", "afficherMessageRonde",
"arreterDecompteRonde", "peindreTitreRonde", "demarrerDecompteRonde",
"armerTourRonde", "imageEnCoursRonde", "rendreCaseRonde", "tourDeRonde",
"surImageRonde", "demarrerRonde", "arreterRonde", "surPleinEcranRonde",
"comparerCamerasDirect", "memoriserRondeEnCours", "rondeEtaitEnCours",
]
CODE = "\n".join(
[extraire(r"^const cssId = .*?;$"), extraire(r"^const CLE_RONDE = .*?;$")]
+ re.findall(r"^const RONDE_[A-Z_]+ = .*?;$", SOURCE, re.MULTILINE)
+ [extraire(r"^const ronde = \{.*?^\};"), extraire(r"^let reglagesRonde = .*?;$")]
+ [extraire(r"^const CLE_RONDE_EN_COURS = .*?;$"), extraire(r"^let rondeAReprendre = .*?;$")]
+ [extraire(rf"^function {nom}\(.*?^\}}") for nom in FONCTIONS]
)
# Faux DOM : juste ce que la ronde touche. Une case de direct par caméra, dans
# sa carte de la grille ; #ronde, sa scène, son titre et son message.
MONTAGE = r"""
class Noeud {
constructor(nom) { this.nom = nom; this.parent = null; this.enfants = []; this.hidden = false;
this.textContent = ""; this.video = null; }
get isConnected() { let n = this; while (n.parent) n = n.parent; return n === racine; }
appendChild(e) { if (e.parent) e.parent.retirer(e); e.parent = this; this.enfants.push(e); return e; }
retirer(e) { this.enfants.splice(this.enfants.indexOf(e), 1); e.parent = null; }
replaceWith(e) {
const p = this.parent;
if (e.parent) e.parent.retirer(e);
p.enfants[p.enfants.indexOf(this)] = e;
e.parent = p;
this.parent = null;
}
remove() { if (this.parent) this.parent.retirer(this); }
contains(e) { for (let n = e; n; n = n.parent) if (n === this) return true; return false; }
querySelector(selecteur) { return selecteur === "video" ? this.video : null; }
}
const racine = new Noeud("document");
const liste = racine.appendChild(new Noeud("list"));
const elements = { view: { value: "live" } };
for (const nom of ["ronde", "rondeTitre", "rondeMessage", "rondeScene"]) elements[nom] = new Noeud(nom);
racine.appendChild(elements.ronde);
elements.ronde.hidden = true;
for (const nom of ["rondeTitre", "rondeMessage", "rondeScene"]) elements.ronde.appendChild(elements[nom]);
const cartes = {};
for (const cle of ["cam-a", "cam-b", "cam-c", "cam-d"]) {
cartes[cle] = liste.appendChild(new Noeud("carte-" + cle));
elements["live-" + cle] = cartes[cle].appendChild(new Noeud("live-" + cle));
}
// Comme getElementById : un nœud détaché du document est introuvable.
function $(id) {
const e = elements[id];
return e && (!(e instanceof Noeud) || e.isConnected) ? e : null;
}
const journal = [];
globalThis.document = {
fullscreenElement: null,
createComment: () => new Noeud("ancre"),
exitFullscreen() { journal.push("exitFullscreen"); this.fullscreenElement = null; return Promise.resolve(); },
};
elements.ronde.requestFullscreen = () => { document.fullscreenElement = elements.ronde; return Promise.resolve(); };
const magasin = STOCKAGE;
globalThis.localStorage = {
getItem: (c) => (c in magasin ? magasin[c] : null),
setItem: (c, v) => { magasin[c] = String(v); },
removeItem: (c) => { delete magasin[c]; },
};
let minuteries = [];
let numero = 0;
globalThis.setTimeout = (f, d) => { numero += 1; minuteries.push({ id: numero, f, d }); return numero; };
globalThis.clearTimeout = (id) => { minuteries = minuteries.filter((m) => m.id !== id); };
function echeance() { return minuteries.length ? minuteries[0].d : null; }
function declencher() { const m = minuteries.shift(); m.f(); }
const vider = () => new Promise((fin) => setImmediate(fin));
let maintenant = "2026-10-17T12:00:00";
const VraieDate = Date;
globalThis.Date = class extends VraieDate {
constructor(...a) { super(...(a.length ? a : [maintenant])); }
};
const promesses = {};
function watchLive(cle) {
journal.push("watch:" + cle);
return new Promise((fin) => { promesses[cle] = fin; });
}
function stopWatch(cle) { journal.push("stop:" + cle); }
function renderLive() { journal.push("renderLive"); }
const masquees = new Set(MASQUEES);
const estMasquee = (nom) => masquees.has(nom);
const comparerNoms = (a, b) => a.localeCompare(b);
const t = (cle) => cle;
const tf = (cle, p) => cle + JSON.stringify(p);
let system = SYSTEME;
"""
SYSTEME = {"systems": [
{"name": "Dehors", "cameras": [
{"name": "B", "key": "cam-b"}, {"name": "A", "key": "cam-a"},
{"name": "C", "key": "cam-c", "offline": True}]},
{"name": "Dedans", "cameras": [{"name": "D", "key": "cam-d"}]},
]}
def executer(scenario: str, stockage: dict | None = None, systeme: dict | None = None,
masquees: tuple = ()) -> dict:
node = shutil.which("node")
if node is None:
raise unittest.SkipTest("node introuvable")
montage = (MONTAGE.replace("STOCKAGE", json.dumps(stockage or {}))
.replace("SYSTEME", json.dumps(SYSTEME if systeme is None else systeme))
.replace("MASQUEES", json.dumps(list(masquees))))
script = (montage + CODE + "\nconst sortie = {};\n(async () => {\n" + scenario
+ "\nconsole.log(JSON.stringify(sortie));\n})();\n")
resultat = subprocess.run([node, "-"], input=script, capture_output=True, text=True,
encoding="utf-8", timeout=60)
assert resultat.returncode == 0, resultat.stderr
return json.loads(resultat.stdout)
class Reglages(unittest.TestCase):
def test_par_defaut_puis_bornes(self):
sortie = executer("""
sortie.defaut = normaliserReglagesRonde(null);
sortie.bornes = [normaliserReglagesRonde({secondes: 3}).secondes,
normaliserReglagesRonde({secondes: "9999"}).secondes,
normaliserReglagesRonde({secondes: "abc"}).secondes,
normaliserReglagesRonde({secondes: ""}).secondes,
normaliserReglagesRonde({secondes: "45"}).secondes];
sortie.heures = normaliserReglagesRonde({debut: "25:00", fin: "07:30"});
sortie.exclues = normaliserReglagesRonde({exclues: ["cam-a", 3, null]}).exclues;
""")
self.assertEqual(sortie["defaut"], {"exclues": [], "secondes": 30, "debut": "", "fin": ""})
self.assertEqual(sortie["bornes"], [10, 300, 30, 30, 45])
self.assertEqual((sortie["heures"]["debut"], sortie["heures"]["fin"]), ("", "07:30"))
self.assertEqual(sortie["exclues"], ["cam-a"])
def test_retenus_dans_le_navigateur(self):
sortie = executer("""
sortie.lus = reglagesRonde;
reglagesRonde = normaliserReglagesRonde({secondes: 20, exclues: ["cam-b"]});
enregistrerReglagesRonde();
sortie.relus = lireReglagesRonde();
""", stockage={"blink2video.ronde": "{pas du json"})
self.assertEqual(sortie["lus"]["secondes"], 30)
self.assertEqual(sortie["relus"],
{"exclues": ["cam-b"], "secondes": 20, "debut": "", "fin": ""})
def test_plage_horaire(self):
sortie = executer("""
const a = (debut, fin, hhmm) => dansPlageRonde({debut, fin}, new VraieDate(`2026-10-17T${hhmm}:00`));
sortie.r = [a("", "", "03:00"), a("08:00", "", "03:00"), a("08:00", "08:00", "03:00"),
a("08:00", "20:00", "07:59"), a("08:00", "20:00", "08:00"),
a("08:00", "20:00", "19:59"), a("08:00", "20:00", "20:00"),
a("22:00", "06:00", "23:00"), a("22:00", "06:00", "05:59"),
a("22:00", "06:00", "06:00"), a("22:00", "06:00", "12:00")];
""")
self.assertEqual(sortie["r"], [True, True, True, False, True, True, False,
True, True, False, False])
def test_cameras_dans_l_ordre_de_la_grille(self):
sortie = executer("""
sortie.toutes = camerasRonde(normaliserReglagesRonde(null)).map((c) => c.key);
sortie.choisies = camerasRonde(normaliserReglagesRonde({exclues: ["cam-a"]})).map((c) => c.key);
""", masquees=("D",))
# C hors ligne, D masquée : ni l'une ni l'autre n'a sa place.
self.assertEqual(sortie["toutes"], ["cam-a", "cam-b"])
self.assertEqual(sortie["choisies"], ["cam-b"])
class Reprise(unittest.TestCase):
"""Issue #40 : une ronde que seule la fermeture de la page a coupée
reprend à la réouverture ; arrêtée à la main, elle ne reprend pas."""
def test_demarree_elle_se_marque_arretee_elle_s_efface(self):
sortie = executer("""
sortie.avant = rondeEtaitEnCours();
demarrerRonde();
sortie.pendant = magasin["blink2video.ronde.enCours"];
sortie.relue = rondeEtaitEnCours();
arreterRonde();
sortie.apres = "blink2video.ronde.enCours" in magasin;
""")
self.assertEqual((sortie["avant"], sortie["pendant"], sortie["relue"], sortie["apres"]),
(False, "1", True, False))
def test_quitter_le_plein_ecran_compte_comme_un_arret(self):
sortie = executer("""
demarrerRonde();
surPleinEcranRonde();
document.fullscreenElement = null;
surPleinEcranRonde();
sortie.active = ronde.active;
sortie.marque = "blink2video.ronde.enCours" in magasin;
""")
self.assertEqual((sortie["active"], sortie["marque"]), (False, False))
def test_stockage_indisponible_ni_reprise_ni_plantage(self):
sortie = executer("""
globalThis.localStorage = { getItem() { throw new Error("bloqué"); },
setItem() { throw new Error("bloqué"); },
removeItem() { throw new Error("bloqué"); } };
demarrerRonde();
sortie.active = ronde.active;
sortie.relue = rondeEtaitEnCours();
arreterRonde();
""")
self.assertEqual((sortie["active"], sortie["relue"]), (True, False))
class Enchainement(unittest.TestCase):
def test_chaque_camera_a_son_temps_a_partir_de_sa_premiere_image(self):
sortie = executer("""
demarrerRonde();
sortie.plein = document.fullscreenElement === elements.ronde && !elements.ronde.hidden;
sortie.a = {journal: journal.splice(0), dansLaScene: elements["live-cam-a"].parent === elements.rondeScene,
ancre: cartes["cam-a"].enfants[0].nom, attente: echeance(), titre: elements.rondeTitre.textContent};
surImageRonde({target: elements["live-cam-a"]});
sortie.antenne = echeance();
const minuterie = minuteries[0].id;
// Une reprise du direct rejoue « playing » : le temps d'antenne ne repart pas.
surImageRonde({target: elements["live-cam-a"]});
sortie.uneSeuleFois = minuteries.length === 1 && minuteries[0].id === minuterie;
declencher();
sortie.b = {journal: journal.splice(0), aRendue: elements["live-cam-a"].parent === cartes["cam-a"],
bDansLaScene: elements["live-cam-b"].parent === elements.rondeScene};
surImageRonde({target: elements["live-cam-b"]});
declencher();
sortie.retour = journal.splice(0);
""", masquees=("D",))
self.assertTrue(sortie["plein"])
self.assertEqual(sortie["a"], {"journal": ["watch:cam-a"], "dansLaScene": True,
"ancre": "ancre", "attente": 60000,
"titre": "A · 1/2 ronde.waiting"})
self.assertEqual(sortie["antenne"], 30000)
self.assertTrue(sortie["uneSeuleFois"])
self.assertEqual(sortie["b"], {"journal": ["stop:cam-a", "watch:cam-b"],
"aRendue": True, "bDansLaScene": True})
# C hors ligne et D masquée sont sautées : la ronde revient à A.
self.assertEqual(sortie["retour"], ["stop:cam-b", "watch:cam-a"])
def test_une_image_d_une_autre_case_ne_compte_pas(self):
sortie = executer("""
demarrerRonde();
surImageRonde({target: elements["live-cam-b"]});
sortie.attente = echeance();
""")
self.assertEqual(sortie["attente"], 60000)
def test_un_echec_sans_image_passe_vite_la_main(self):
sortie = executer("""
demarrerRonde();
promesses["cam-a"]();
await vider();
sortie.apresEchec = echeance();
declencher();
sortie.suivante = journal.slice(-1)[0];
""")
self.assertEqual(sortie["apresEchec"], 5000)
self.assertEqual(sortie["suivante"], "watch:cam-b")
def test_la_fin_du_direct_precedent_ne_presse_pas_le_suivant(self):
# Passer à B arrête A, dont la promesse se termine juste après : B,
# encore sans image, garderait sinon 5 s au lieu de 60 pour se réveiller.
sortie = executer("""
demarrerRonde();
surImageRonde({target: elements["live-cam-a"]});
declencher();
promesses["cam-a"]();
await vider();
sortie.attenteB = echeance();
""")
self.assertEqual(sortie["attenteB"], 60000)
def test_hors_plage_la_ronde_attend_sans_reveiller_personne(self):
sortie = executer("""
maintenant = "2026-10-17T23:00:00";
demarrerRonde();
sortie.nuit = {journal: journal.splice(0), message: elements.rondeMessage.textContent,
visible: !elements.rondeMessage.hidden, attente: echeance()};
maintenant = "2026-10-17T08:30:00";
declencher();
sortie.matin = journal.splice(0);
maintenant = "2026-10-17T20:00:00";
declencher();
sortie.soir = {journal: journal.splice(0), rendue: elements["live-cam-a"].parent === cartes["cam-a"]};
""", stockage={"blink2video.ronde": json.dumps({"debut": "08:00", "fin": "20:00"})})
self.assertEqual(sortie["nuit"], {"journal": [], "message": 'ronde.pause{"debut":"08:00"}',
"visible": True, "attente": 30000})
self.assertEqual(sortie["matin"], ["watch:cam-a"])
self.assertEqual(sortie["soir"], {"journal": ["stop:cam-a"], "rendue": True})
def test_sans_camera_la_ronde_le_dit(self):
sortie = executer("""
demarrerRonde();
sortie.r = {journal: journal, message: elements.rondeMessage.textContent, attente: echeance()};
""", stockage={"blink2video.ronde": json.dumps({"exclues": ["cam-a", "cam-b", "cam-d"]})})
self.assertEqual(sortie["r"], {"journal": [], "message": "ronde.aucune", "attente": 30000})
def test_seule_de_sa_ronde_une_camera_garde_l_ecran(self):
sortie = executer("""
demarrerRonde();
elements["live-cam-a"].video = {paused: false, ended: false, readyState: 4};
surImageRonde({target: elements["live-cam-a"]});
declencher();
sortie.continue = {journal: journal.splice(0), attente: echeance()};
elements["live-cam-a"].video.ended = true;
declencher();
sortie.relance = journal.splice(0);
""", stockage={"blink2video.ronde": json.dumps({"exclues": ["cam-b", "cam-d"]})})
self.assertEqual(sortie["continue"], {"journal": ["watch:cam-a"], "attente": 30000})
self.assertEqual(sortie["relance"], ["stop:cam-a", "watch:cam-a"])
def test_arreter_rend_la_case_et_le_plein_ecran(self):
sortie = executer("""
demarrerRonde();
journal.splice(0);
arreterRonde();
sortie.r = {journal, rendue: elements["live-cam-a"].parent === cartes["cam-a"],
cachee: elements.ronde.hidden, minuteries: minuteries.length, active: ronde.active};
""")
self.assertEqual(sortie["r"], {"journal": ["stop:cam-a", "exitFullscreen", "renderLive"],
"rendue": True, "cachee": True, "minuteries": 0,
"active": False})
def test_quitter_le_plein_ecran_arrete_la_ronde(self):
sortie = executer("""
demarrerRonde();
await vider();
surPleinEcranRonde();
document.fullscreenElement = null;
surPleinEcranRonde();
sortie.active = ronde.active;
sortie.journal = journal;
""")
self.assertFalse(sortie["active"])
self.assertIn("stop:cam-a", sortie["journal"])
def test_une_grille_effacee_pendant_le_pret_est_redessinee(self):
# Une actualisation finie pendant la ronde relit le système et pouvait
# effacer la grille : sans la redessiner, la ronde restait bloquée sur
# « aucune caméra ».
sortie = executer("""
renderLive = () => {
journal.push("renderLive");
for (const cle of ["cam-a", "cam-b", "cam-c", "cam-d"]) {
if (elements["live-" + cle].isConnected) continue;
cartes[cle] = liste.appendChild(new Noeud("carte-" + cle));
elements["live-" + cle] = cartes[cle].appendChild(new Noeud("live-" + cle));
}
};
demarrerRonde();
for (const carte of liste.enfants.slice()) liste.retirer(carte);
declencher();
sortie.r = {journal, bDansLaScene: elements["live-cam-b"].parent === elements.rondeScene,
scene: elements.rondeScene.enfants.length};
""", masquees=("D",))
self.assertEqual(sortie["r"], {"journal": ["watch:cam-a", "stop:cam-a", "renderLive", "watch:cam-b"],
"bDansLaScene": True, "scene": 1})
def test_case_dont_la_carte_a_disparu_n_est_pas_reinseree(self):
sortie = executer("""
demarrerRonde();
liste.retirer(cartes["cam-a"]);
arreterRonde();
sortie.r = {parent: elements["live-cam-a"].parent, scene: elements.rondeScene.enfants.length};
""")
self.assertEqual(sortie["r"], {"parent": None, "scene": 0})
class Structure(unittest.TestCase):
def test_chaque_element_de_la_ronde_existe_dans_la_page(self):
ids = set(re.findall(r'\$\("(ronde\w*)"\)', SOURCE))
self.assertTrue({"ronde", "rondeButton", "rondeScene", "rondeReglages"} <= ids, ids)
for identifiant in sorted(ids):
self.assertIn(f'id="{identifiant}"', PAGE, identifiant)
def test_chaque_texte_existe_en_francais_et_en_anglais(self):
cles = set(re.findall(r'data-i18n(?:-title)?="(ronde\.[\w.]+)"', PAGE))
cles |= set(re.findall(r'\bt[f]?\("(ronde\.[\w.]+)"', SOURCE))
self.assertTrue(len(cles) >= 12, cles)
for cle in sorted(cles):
self.assertEqual(len(re.findall(rf'"{re.escape(cle)}":', SOURCE)), 2,
f"{cle} : attendu une fois en français et une fois en anglais")
def test_la_ronde_couvre_la_fenetre_et_masque_les_boutons_des_cartes(self):
self.assertRegex(CSS, r"#ronde \{ position:fixed; inset:0; z-index:100;")
self.assertIn("#rondeScene .watch { display:none; }", CSS)
def test_la_grille_ne_se_redessine_pas_pendant_le_pret_d_une_case(self):
self.assertIn("if (nomsDirectsActifs().length || ronde.boite) return;", SOURCE)
# loadSystem() annonçait aussi l'interrogation en effaçant la grille.
self.assertIn('$("view").value === "live" && !nomsDirectsActifs().length && !ronde.boite', SOURCE)
if __name__ == "__main__":
unittest.main()