#!/usr/bin/env python3 """Sauvegarder depuis le GUI ne doit emporter aucun commentaire du plan. CE QUI ETAIT VRAI JUSQU'AU 2026-09-08, mesure sur les fichiers reels de Chezlepro : domaines.yml 6 lignes de commentaire -> 3 (PERD 3) applications.yml 27 -> 5 (PERD 22) serveurs.yml 18 -> 3 (PERD 15) bases-donnees.yml 4 -> 4 (intact — il n'en portait pas) Quarante lignes, detruites par n'importe quel clic sur « Sauvegarder » dans les vues Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi `backup-01` a ete retire — « une supervision creuse est pire qu'aucune : elle est verte » — et celle qui dit dans quel ORDRE les deux roles du runner s'appliquent. C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), jamais corrige pour les registres du plan : `_fusion_chirurgicale` avait ete ecrite pour les intrants seuls, et les quatre `ecrire_*` etaient restes au `safe_dump`. Ce banc mesure les quatre ecrivains sur les VRAIS fichiers du depot, et non sur un gabarit invente : c'est la seule facon de constater ce que l'operateur perdrait. """ from __future__ import annotations import shutil import sys import tempfile import unittest from pathlib import Path RACINE = Path(__file__).resolve().parents[2] sys.path.insert(0, str(RACINE / "scripts")) import yaml # noqa: E402 import inventory_gui as g # noqa: E402 # (nom, fichier source, ecrivain, chargeur, clef racine) REGISTRES = [ ("domaines.yml", g.FICHIER_DOMAINES, g.ecrire_domaines, g.charger_domaines, "domaines_publics"), ("bases-donnees.yml", g.FICHIER_BASES, g.ecrire_bases, g.charger_bases_donnees, "bases_donnees"), ("applications.yml", g.FICHIER_APPLICATIONS, g.ecrire_applications, g.charger_applications, "applications"), ("serveurs.yml", g.FICHIER_SERVEURS, g.ecrire_serveurs, g.charger_serveurs, "serveurs"), ] def commentaires(texte: str) -> list[str]: return [l.strip() for l in texte.splitlines() if l.strip().startswith("#")] class TestEcriturePlan(unittest.TestCase): def setUp(self): self.dossier = Path(tempfile.mkdtemp(prefix="plan-")) def tearDown(self): shutil.rmtree(self.dossier, ignore_errors=True) def _copie(self, nom, source): cible = self.dossier / nom shutil.copy(source, cible) return cible def test_aller_retour_a_vide_est_identique(self): """Ecrire ce qu'on vient de lire ne doit pas changer un octet. C'est la propriete forte : sans elle, toute sauvegarde produit du bruit au diff, et le bruit finit par cacher le vrai changement. """ for nom, source, ecrire, charger, _ in REGISTRES: with self.subTest(registre=nom): cible = self._copie(nom, source) avant = cible.read_text(encoding="utf-8") ecrire(cible, charger(source)) self.assertEqual(avant, cible.read_text(encoding="utf-8")) def test_aucun_commentaire_perdu_apres_modification(self): for nom, source, ecrire, charger, racine in REGISTRES: with self.subTest(registre=nom): cible = self._copie(nom, source) avant = cible.read_text(encoding="utf-8") registre = charger(source) registre[racine]["essai-banc"] = dict( next(iter(registre[racine].values()), {}) or {}) ecrire(cible, registre) self.assertEqual(commentaires(avant), commentaires(cible.read_text(encoding="utf-8"))) def test_une_entite_ajoutee_arrive_bien(self): for nom, source, ecrire, charger, racine in REGISTRES: with self.subTest(registre=nom): cible = self._copie(nom, source) registre = charger(source) modele = dict(next(iter(registre[racine].values()), {}) or {}) registre[racine]["essai-banc"] = modele ecrire(cible, registre) relu = yaml.safe_load(cible.read_text(encoding="utf-8")) self.assertIn("essai-banc", relu[racine]) self.assertEqual(relu[racine]["essai-banc"], modele) def test_une_entite_retiree_disparait(self): for nom, source, ecrire, charger, racine in REGISTRES: with self.subTest(registre=nom): cible = self._copie(nom, source) avant = cible.read_text(encoding="utf-8") registre = charger(source) if len(registre[racine]) < 2: continue victime = sorted(registre[racine])[0] del registre[racine][victime] ecrire(cible, registre) apres = cible.read_text(encoding="utf-8") relu = yaml.safe_load(apres) self.assertNotIn(victime, relu[racine]) # Les autres entites restent, et les commentaires aussi : retirer une # entite n'est pas une raison d'effacer ce qui l'entourait. self.assertEqual(sorted(relu[racine]), sorted(registre[racine])) self.assertEqual(commentaires(avant), commentaires(apres)) def test_controle_negatif_safe_dump_detruit(self): """LE CONTROLE. L'ancienne ecriture DOIT perdre des commentaires. Sans lui, les tests ci-dessus passeraient sur un fichier qui n'en porte aucun — ils ne mesureraient rien. On rejoue le `safe_dump` d'avant sur les memes fichiers et on exige la perte. """ perdants = [] for nom, source, _, charger, racine in REGISTRES: avant = source.read_text(encoding="utf-8") naif = "---\n" + yaml.safe_dump({racine: charger(source)[racine]}, default_flow_style=False, sort_keys=False, allow_unicode=True) if len(commentaires(naif)) < len(commentaires(avant)): perdants.append(nom) self.assertIn("serveurs.yml", perdants, "le controle negatif ne reproduit plus le defaut : serveurs.yml " "ne porte-t-il plus de commentaire interne ?") self.assertGreaterEqual(len(perdants), 3, f"seuls {perdants} perdent — banc suspect") def test_un_fichier_absent_recoit_son_entete(self): """Rien a preserver : on ecrit l'entete et le contenu.""" cible = self.dossier / "neuf.yml" g.ecrire_domaines(cible, {"domaines_publics": {"exemple.ca": {"autorite": "delegue", "edge": "serveur_nginx"}}}) texte = cible.read_text(encoding="utf-8") self.assertIn("# Registre des domaines publics", texte) self.assertIn("exemple.ca", yaml.safe_load(texte)["domaines_publics"]) if __name__ == "__main__": unittest.main()