cac65386ab
Orchestrateur méta-niveau : compose les hand-offs out/ des 10 modules déjà livrés en un run-sheet de démo jouable, via pointeurs RFC 6901 résolus à la construction (zéro chiffre en dur · #6). Réutilise le validateur Publiciste et la preuve de couverture CI de l'auditeur 4Big (#5) — une démo ne s'appuie que sur des modules gated. Enregistre demo/scenarios dans l'audit 4Big (17→18, verdict PASS 18/18 à 100). 32 tests (9 injections négatives) · régression 474 tests verts · guards CI verts · builds déterministes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
9.4 KiB
Python
231 lines
9.4 KiB
Python
#!/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)
|