#!/usr/bin/env python3 """Tests du générateur de scénarios démo (run-sheet). Couvre : assemblage réel (les artefacts `out/` amont doivent exister et être gated), résolution de pointeurs RFC 6901, cœur anti-invention (#6), invariants, et un lot d'INJECTIONS NÉGATIVES prouvant que chaque garde-fou rougit vraiment. """ from __future__ import annotations import copy import json import os import sys import unittest _HERE = os.path.dirname(os.path.abspath(__file__)) _MOD = os.path.normpath(os.path.join(_HERE, "..")) sys.path.insert(0, _MOD) import demo_scenario_gen as gen # noqa: E402 from scenlib import builder, evidence # noqa: E402 def _fresh(): """(spec, run_sheet, manifest) construits depuis le dépôt réel.""" return gen._assemble(generated_at=None) class TestBuildReel(unittest.TestCase): def setUp(self): self.spec, self.run_sheet, self.manifest = _fresh() def test_build_sans_invariant_casse(self): self.assertEqual(gen.check_invariants(self.spec, self.run_sheet, self.manifest), []) def test_deux_scenarios_p07_p05(self): ids = {sc["id"] for sc in self.run_sheet["scenarios"]} self.assertEqual(ids, {"S-P07-BANQUIER", "S-P05-CLIENT"}) projets = {sc["projet"] for sc in self.run_sheet["scenarios"]} self.assertEqual(projets, {"P05", "P07"}) def test_audiences_declarees(self): for sc in self.run_sheet["scenarios"]: self.assertIn(sc["audience"], self.spec["audiences"]) def test_libelles_projets_sources_du_spec(self): for sc in self.run_sheet["scenarios"]: self.assertEqual(sc["projet_libelle"], self.spec["projets"][sc["projet"]]) def test_beats_ordonnes_et_min_trois(self): for sc in self.run_sheet["scenarios"]: self.assertGreaterEqual(len(sc["beats"]), 3) self.assertEqual([b["ordre"] for b in sc["beats"]], list(range(1, len(sc["beats"]) + 1))) def test_orchestration_min_deux_modules(self): for sc in self.run_sheet["scenarios"]: self.assertGreaterEqual(len(set(sc["modules_cites"])), 2) def test_toutes_citations_resolues(self): self.assertEqual(self.manifest["counts"]["citations_non_resolues"], 0) for sc in self.run_sheet["scenarios"]: for b in sc["beats"]: for c in b["evidence"]: self.assertTrue(c["resolu"]) self.assertFalse(evidence.is_placeholder(c["valeur"])) self.assertIsNotNone(c["valeur"]) def test_pret_et_couverture_ci(self): self.assertTrue(self.manifest["pret"]) self.assertTrue(self.manifest["couverture_ci"]["ok"]) self.assertEqual(self.manifest["couverture_ci"]["modules_cites_non_gated"], []) def test_pas_auto_citation(self): mods = {c["module"] for sc in self.run_sheet["scenarios"] for b in sc["beats"] for c in b["evidence"]} self.assertNotIn(builder.SELF_MODULE, mods) def test_manifest_conforme_schema(self): self.assertEqual(gen.deps.validate(self.manifest, gen._load_schema()), []) def test_duree_est_somme_des_beats(self): for sc in self.run_sheet["scenarios"]: self.assertEqual(sc["duree_min"], sum(b["duree_min"] for b in sc["beats"])) def test_determinisme(self): _, rs2, mf2 = _fresh() self.assertEqual(self.run_sheet, rs2) self.assertEqual(self.manifest, mf2) def test_valeurs_verbatim_depuis_artefacts(self): # Une valeur du run-sheet doit être EXACTEMENT celle du fichier amont. wf = json.load(open(os.path.join( builder.deps.DELIVERABLES_ROOT, "crm/workflow_vente/out/workflow.json"), encoding="utf-8")) beat1 = self.run_sheet["scenarios"][0]["beats"][0]["evidence"][0] self.assertEqual(beat1["valeur"], wf[0]["workflow_name"]) class TestPointeurRFC6901(unittest.TestCase): def setUp(self): self.loader = evidence.Loader() def test_split_racine_et_echappement(self): self.assertEqual(evidence._split_pointer(""), []) self.assertEqual(evidence._split_pointer("/a/b"), ["a", "b"]) self.assertEqual(evidence._split_pointer("/a~1b/c~0d"), ["a/b", "c~d"]) def test_split_refuse_pointeur_sans_slash(self): with self.assertRaises(ValueError): evidence._split_pointer("a/b") def test_descend_cle_index_negatif(self): self.assertEqual(evidence._descend({"x": 1}, "x"), 1) self.assertEqual(evidence._descend([10, 20, 30], "0"), 10) self.assertEqual(evidence._descend([10, 20, 30], "-1"), 30) self.assertIs(evidence._descend([1], "5"), evidence._MISSING) self.assertIs(evidence._descend(42, "x"), evidence._MISSING) def test_resolve_valeur_existante(self): val, ok = evidence.resolve(self.loader, "crm/workflow_vente", "out/workflow.json", "/0/document_type") self.assertTrue(ok) self.assertEqual(val, "OTO Dossier Vente") def test_resolve_null_est_non_resolu(self): # `condition: null` dans une transition → placeholder, pas résolu (#6). val, ok = evidence.resolve(self.loader, "crm/workflow_vente", "out/workflow.json", "/0/transitions/0/condition") self.assertFalse(ok) self.assertTrue(evidence.is_placeholder(val)) def test_resolve_artefact_absent(self): val, ok = evidence.resolve(self.loader, "module/inexistant", "out/rien.json", "/x") self.assertFalse(ok) self.assertEqual(val, "{{module/inexistant:out/rien.json#/x}}") def test_resolve_pointeur_manquant(self): val, ok = evidence.resolve(self.loader, "crm/workflow_vente", "out/workflow.json", "/0/champ_absent") self.assertFalse(ok) self.assertTrue(evidence.is_placeholder(val)) def test_is_placeholder(self): self.assertTrue(evidence.is_placeholder("{{a:b#/c}}")) self.assertFalse(evidence.is_placeholder("valeur")) self.assertFalse(evidence.is_placeholder(258)) class TestInjectionsNegatives(unittest.TestCase): """Chaque garde-fou DOIT rougir sur une dégradation ciblée.""" def setUp(self): self.spec, self.run_sheet, self.manifest = _fresh() def _motifs(self, rs, mf): return gen.check_invariants(self.spec, rs, mf) def test_anti_invention_valeur_falsifiee(self): rs = copy.deepcopy(self.run_sheet) rs["scenarios"][0]["beats"][0]["evidence"][0]["valeur"] = "CHIFFRE INVENTÉ" mf = builder.build_manifest(self.spec, rs) self.assertTrue(any("invention/dérive" in m for m in self._motifs(rs, mf))) def test_compteur_falsifie(self): mf = copy.deepcopy(self.manifest) mf["counts"]["citations_total"] += 1 motifs = self._motifs(self.run_sheet, mf) self.assertTrue(any("counts.citations_total" in m for m in motifs)) def test_placeholder_incoherent(self): rs = copy.deepcopy(self.run_sheet) c = rs["scenarios"][0]["beats"][0]["evidence"][0] c["resolu"] = False # résolu marqué faux mais valeur reste réelle mf = builder.build_manifest(self.spec, rs) self.assertTrue(any("non résolu sans placeholder" in m or "invention/dérive" in m for m in self._motifs(rs, mf))) def test_module_non_gated_detecte(self): # On force couverture_ci en KO → invariant (g) doit rougir. mf = copy.deepcopy(self.manifest) mf["couverture_ci"]["ok"] = False mf["couverture_ci"]["modules_cites_non_gated"] = ["module/hors-ci"] self.assertTrue(any("NON gated" in m for m in self._motifs(self.run_sheet, mf))) def test_non_pret_bloque(self): mf = copy.deepcopy(self.manifest) mf["pret"] = False self.assertTrue(any("non prêt" in m for m in self._motifs(self.run_sheet, mf))) def test_auto_citation_detectee(self): rs = copy.deepcopy(self.run_sheet) rs["scenarios"][0]["beats"][0]["evidence"][0]["module"] = builder.SELF_MODULE mf = builder.build_manifest(self.spec, rs) self.assertTrue(any("auto-citation" in m or "invention/dérive" in m for m in self._motifs(rs, mf))) def test_ordres_non_contigus_detectes(self): rs = copy.deepcopy(self.run_sheet) rs["scenarios"][0]["beats"][0]["ordre"] = 99 mf = builder.build_manifest(self.spec, rs) self.assertTrue(any("non contigus" in m for m in self._motifs(rs, mf))) def test_schema_rejette_beats_insuffisants(self): mf = copy.deepcopy(self.manifest) mf["scenarios"][0]["beats"] = 1 self.assertTrue(gen.deps.validate(mf, gen._load_schema())) def test_schema_rejette_id_hors_pattern(self): mf = copy.deepcopy(self.manifest) mf["scenarios"][0]["id"] = "mauvais-id" self.assertTrue(gen.deps.validate(mf, gen._load_schema())) class TestCLI(unittest.TestCase): def test_validate_retourne_zero(self): self.assertEqual(gen.main(["validate"]), 0) def test_build_ecrit_out(self): import tempfile with tempfile.TemporaryDirectory() as d: self.assertEqual(gen.main(["build", "-o", d]), 0) for name in ("run_sheet.json", "MANIFEST.json"): self.assertTrue(os.path.isfile(os.path.join(d, name))) if __name__ == "__main__": unittest.main(verbosity=2)