Set-OPS-Public/scripts/tests/test_rendu_gui.py
Daniel Allaire 2887b57f0c GUI : les six registres ont un formulaire genere, et la sauvegarde aussi
CHAMPS_ECRITS_A_LA_MAIN est vide. Serveurs et applications, les deux plus
gros, sont passes au generateur — chargement, rendu et sauvegarde.

L EPREUVE QUI COMPTE. Ouvrir chaque vue et enregistrer sans rien toucher
doit renvoyer exactement le plan qu on vient de lire : 14 serveurs, 25
applications, 2 domaines, 4 bases, IDENTIQUE partout. C est ce qui separe
un formulaire genere d un formulaire qui en a l air — un champ visible a
l ecran et perdu en silence a l enregistrement serait le pire des deux
mondes. test_rendu_gui.py le mesure a chaque make prouver.

TROIS DEFAUTS TROUVES EN CHEMIN.

Le formulaire annoncait des defauts INVENTES : 2048 Mo, 2 coeurs, 16G. Il
n existe aucun defaut fixe — deriver_ressources calcule depuis les roles
portes (1024 et 1 pour infra-pki-01, 5632 et 4 pour collab-01). Un repere
faux fait croire qu on connait la valeur. Le schema nomme le champ derive,
et l ecran montre la valeur reelle de cet hote. L option vide d un select
dit desormais ce qu elle produira : « (defaut : asgard) ».

Une SECONDE occurrence du defaut d hier dormait dans sourceDeValeurs :
elle lisait encore data.nomenclature. Elle n avait jamais leve parce que
la vue Serveurs, seule a emprunter cette source, avait un formulaire ecrit
a la main. Elle a leve a la seconde ou le generateur l a prise. Le banc ne
voit que les chemins vivants : verifier_gui.py fait donc aussi une
verification STATIQUE, qui voit ce qui dort.

La validation client s accrochait a data-v, pose a la main sur trois
champs. Le formulaire genere l aurait perdu et la validation serait passee
au vert sur ZERO champ. Le generateur marque chaque controle, et la
sauvegarde refuse si elle n en inspecte aucun.

DEUX CHAMPS GARDENT LEUR EDITEUR, et le schema le dit (x-editeur) : la
matrice des integrations montre les universelles et les exemptions, et
l editeur de liens contraint le role a meta/liens.yml. Le generateur s
efface plutot que de remplacer un editeur qui en sait plus que lui.

LIMITE : je n ai toujours pas ouvert ces pages dans un navigateur.

make prouver : CONFORME, 61 OK, 0 echec, 1 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 18:26:33 -04:00

246 lines
12 KiB
Python

#!/usr/bin/env python3
"""Le GUI REND-IL ? Chaque vue est dessinee pour de vrai, dans un DOM simule.
CE QUE `verifier_gui.py` NE POUVAIT PAS VOIR (2026-09-08). Il passe le JS a
`node --check` : la syntaxe, donc, et rien d'autre. Le formulaire genere des bases
lisait `data.schema` — or il n'existe aucun `data` global dans cette page ; c'est une
const locale de `charger()`. Syntaxe parfaite, `ReferenceError` a l'ouverture. Pire dans
`sauvegarderBases`, ou `const data` est declare PLUS BAS dans la meme fonction : zone
morte temporelle, l'enregistrement jetait avant meme d'envoyer.
Le defaut a survecu a une verification sous node parce que le banc APPELAIT la fonction
en lui passant `data` — il reproduisait la fonction, pas sa PORTEE. Une garantie qu'on
n'a jamais vue echouer n'est pas une garantie.
Ce banc-ci charge le JS entier, tel que la page le recoit, dans un DOM simule, appelle
`charger()` sur la VRAIE reponse de l'API, puis dessine CHAQUE vue. Toute reference a un
identifiant qui n'existe pas leve, et le test echoue.
Ce qu'il ne fait PAS : juger l'apparence. Il dit que ca rend, pas que c'est beau.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import inventory_gui as g # noqa: E402
# Un DOM juste assez reel : tout element repond, retient son innerHTML, et n'invente
# rien. Un `getElementById` qui rendrait `null` masquerait les fautes qu'on cherche.
BANC = r"""
'use strict';
function Elem(id) {
this.id = id; this.innerHTML = ''; this.textContent = ''; this.value = '';
this.hidden = false; this.disabled = false; this.title = ''; this.className = '';
this.style = {}; this.dataset = {}; this.children = [];
this.classList = {add(){}, remove(){}, toggle(){}, contains(){ return false; }};
}
Elem.prototype.appendChild = function (e) { this.children.push(e); return e; };
Elem.prototype.removeChild = function (e) { return e; };
Elem.prototype.setAttribute = function () {};
Elem.prototype.getAttribute = function () { return ''; };
Elem.prototype.addEventListener = function () {};
Elem.prototype.querySelector = function () { return null; };
// `querySelectorAll('[data-champ]')` rend vraiment les controles du dernier rendu : le
// GUI s'en sert pour valider avant d'enregistrer, et un stub qui rend toujours [] ferait
// passer cette validation sur ZERO champ — le banc mesurerait alors moins que rien.
Elem.prototype.querySelectorAll = function (sel) {
if (!/data-champ/.test(String(sel))) { return []; }
var html = this.innerHTML || '';
var out = [], re = /<(input|select)([^>]*?)data-champ="([^"]*)"([^>]*)>/g, m;
while ((m = re.exec(html)) !== null) {
var attrs = m[2] + m[4];
var v = /\svalue="([^"]*)"/.exec(attrs);
out.push({__champ: m[3], value: v ? v[1] : '',
getAttribute: function (n) { return n === 'data-champ' ? this.__champ : null; },
classList: {add: function () {}, remove: function () {}, toggle: function () {}},
focus: function () {}});
}
return out;
};
Elem.prototype.focus = function () {};
Elem.prototype.remove = function () {};
Elem.prototype.closest = function () { return null; };
Elem.prototype.scrollIntoView = function () {};
var __elems = {};
var document = {
getElementById: function (id) { return __elems[id] || (__elems[id] = new Elem(id)); },
createElement: function () { return new Elem('cree'); },
querySelector: function () { return null; },
// `#detail [data-champ]` : le GUI interroge le DOCUMENT, pas l'element. Un stub qui
// rendait [] ici faisait croire que le formulaire n'etait pas marque — et la
// sauvegarde des serveurs refusait, silencieusement, dans le banc seul.
querySelectorAll: function (sel) {
var m = /^#([\w-]+)\s+(.+)$/.exec(String(sel));
if (m && __elems[m[1]]) { return __elems[m[1]].querySelectorAll(m[2]); }
return [];
},
addEventListener: function () {},
body: new Elem('body'),
documentElement: new Elem('html'),
};
var window = {addEventListener: function () {}, location: {href: '', reload: function () {}},
matchMedia: function () { return {matches: false, addEventListener: function () {}}; }};
var navigator = {clipboard: {writeText: function () { return Promise.resolve(); }}};
function MutationObserver(f) { this.observe = function () {}; this.disconnect = function () {}; }
function requestAnimationFrame(f) { return 0; }
function cancelAnimationFrame() {}
function EventSource() { this.addEventListener = function () {}; this.close = function () {}; }
function alert() {} function confirm() { return false; } function prompt() { return null; }
var localStorage = {getItem: function () { return null; }, setItem: function () {}, removeItem: function () {}};
var __PAYLOAD__ = JSON.parse(process.env.PAYLOAD);
var __postsCaptures = {};
// `fetch` rend la VRAIE reponse de /api/inventaire ; les autres routes rendent un objet
// vide plutot que de jeter, pour que les sondes de fond ne masquent pas l'erreur cherchee.
function fetch(url, opts) {
if (opts && opts.method === 'POST') { __postsCaptures[String(url)] = JSON.parse(opts.body); }
// Un POST rend la MEME charge que l'inventaire : le GUI recharge apres sauvegarde, et
// lui rendre {} le ferait repartir d'un plan vide — un faux vert, pas un test.
var corps = (String(url).indexOf('/api/inventaire') === 0 || (opts && opts.method === 'POST'))
? __PAYLOAD__ : {};
return Promise.resolve({ok: true, status: 200, json: function () { return Promise.resolve(corps); },
text: function () { return Promise.resolve(''); }});
}
var __erreurs = [];
function __essayer(quoi, f) { try { f(); } catch (e) { __erreurs.push(quoi + ' : ' + e); } }
"""
APRES = r"""
// --- L'EPREUVE -----------------------------------------------------------------
charger().then(function () {
['serveurs', 'applications', 'bases', 'domaines', 'nomenclature',
'integrations', 'flux', 'couches', 'reseau'].forEach(function (v) {
__essayer('setVue(' + v + ')', function () { setVue(v); });
});
// Les vues a maitre-detail ne montrent leur formulaire QU'UNE FOIS une entree
// choisie : dessiner la grille seule ne prouve rien du formulaire genere.
__essayer('bases + selection', function () {
setVue('bases');
if (typeof bdApplis !== 'undefined' && bdApplis.length) { choisirBase('bd:0'); }
if (typeof bdServeurs !== 'undefined' && bdServeurs.length) { choisirBase('srv:0'); }
});
__essayer('nomenclature + selection', function () {
setVue('nomenclature');
var f = Object.keys(nomEdit.fonctions || {});
var c = Object.keys(nomEdit.categories || {});
if (f.length) { choisirNom('fon:' + f[0]); }
if (c.length) { choisirNom('cat:' + c[0]); }
});
__essayer('domaines + selection', function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); });
__essayer('applications + selection', function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); });
console.log('__RESULTAT__' + JSON.stringify(__erreurs));
}).catch(function (e) {
console.log('__RESULTAT__' + JSON.stringify(['charger() : ' + (e && e.stack || e)]));
});
"""
class TestRenduGui(unittest.TestCase):
"""Chaque vue du GUI se dessine sans lever."""
@classmethod
def setUpClass(cls):
if not shutil.which("node"):
raise unittest.SkipTest("node absent")
html = g.HTML.replace("__JETON__", "TEST")
blocs = re.findall(r"<script>(.*?)</script>", html, re.S)
assert blocs, "aucun bloc <script>"
cls.js = BANC + "\n".join(blocs) + APRES
def _jouer(self, js: str, payload: dict) -> list:
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f:
f.write(js)
chemin = f.name
try:
r = subprocess.run(["node", chemin], capture_output=True, text=True, timeout=120,
env={**__import__("os").environ, "PAYLOAD": json.dumps(payload)})
finally:
Path(chemin).unlink(missing_ok=True)
marque = "__RESULTAT__"
for ligne in r.stdout.splitlines():
if ligne.startswith(marque):
return json.loads(ligne[len(marque):])
self.fail(f"le banc n'a rien rendu.\nstdout: {r.stdout[-2000:]}\nstderr: {r.stderr[-2000:]}")
def test_toutes_les_vues_rendent(self):
"""La vraie reponse de l'API, les neuf vues, zero exception."""
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
erreurs = self._jouer(self.js, payload)
self.assertEqual(erreurs, [], "des vues ont leve :\n - " + "\n - ".join(erreurs))
def test_controle_negatif_reference_absente(self):
"""LE CONTROLE. Une lecture d'identifiant inexistant DOIT faire echouer le banc.
Sans lui, ce test passerait aussi bien sur un banc qui n'execute rien. On rejoue
exactement la faute du 2026-09-08 : le formulaire des bases lit un global qui
n'existe pas.
"""
casse = self.js.replace("const reg = schemaPlan[nomRegistre] || null;",
"const reg = (donneesQuiNexistentPas.schema || {})[nomRegistre] || null;")
self.assertNotEqual(casse, self.js, "le motif du controle negatif n'existe plus")
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
erreurs = self._jouer(casse, payload)
self.assertTrue(any("donneesQuiNexistentPas" in e for e in erreurs),
f"le banc n'a pas vu la reference absente : {erreurs}")
def test_charger_puis_sauvegarder_ne_change_pas_le_plan(self):
"""L'EPREUVE DECISIVE : ouvrir une vue et enregistrer sans rien toucher.
Les six formulaires sont GENERES, et leur sauvegarde l'est aussi : les champs
ecrits sont ceux que le schema declare. C'est ce qui evite le pire des deux
mondes — un champ visible a l'ecran et perdu en silence a l'enregistrement.
Encore faut-il le mesurer. On charge la vraie reponse de l'API, on ouvre chaque
vue, on appelle sa sauvegarde, on intercepte le POST, et on compare la charge au
plan REEL lu par le moteur. Aucune entite perdue, aucune en trop, aucun champ
different : sinon un simple aller-retour appauvrirait le plan.
"""
js = self.js.replace(APRES, r"""
charger()
.then(function () { setVue('serveurs'); choisirServeur(0); return sauvegarderServeurs(); })
.then(function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); return sauvegarderApplications(); })
.then(function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); return sauvegarderDomaines(); })
.then(function () { setVue('bases'); return sauvegarderBases(); })
.then(function () { console.log('__RESULTAT__' + JSON.stringify(__postsCaptures)); })
.catch(function (e) { console.log('__RESULTAT__' + JSON.stringify({erreur: String(e && e.stack || e)})); });
""")
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
posts = self._jouer(js, payload)
self.assertNotIn("erreur", posts, posts.get("erreur", ""))
attendu = {
"/api/serveurs": (g.charger_serveurs(g.FICHIER_SERVEURS), "serveurs"),
"/api/applications": (g.charger_applications(g.FICHIER_APPLICATIONS), "applications"),
"/api/domaines": (g.charger_domaines(g.FICHIER_DOMAINES), "domaines_publics"),
"/api/bases": (g.charger_bases_donnees(g.FICHIER_BASES), "bases_donnees"),
}
for url, (plan, racine) in attendu.items():
with self.subTest(route=url):
envoye = (posts.get(url) or {}).get(racine)
self.assertIsNotNone(envoye, f"{url} n'a pas ete appele — la sauvegarde a-t-elle refuse ?")
reel = plan.get(racine) or {}
self.assertEqual(sorted(envoye), sorted(reel), "entites perdues ou ajoutees")
for cle in sorted(reel):
self.assertEqual(envoye[cle], reel[cle],
f"{racine}.{cle} : l'aller-retour a change l'entite")
def test_le_schema_est_bien_servi(self):
"""Le formulaire ne peut rien generer si l'API n'envoie pas le schema."""
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
self.assertIn("schema", payload, "l'API ne sert pas le schema du plan")
self.assertIn("nomenclature", payload["schema"])
if __name__ == "__main__":
unittest.main()