Ctrl K
ComunidadGuías…

test_serve_app_js_ronde.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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()