Files
oto-enterprise-os-dtp/05_deliverables_mvp/demo/scenarios/tests/test_demo_scenario.py
T
Claude Code DTP Worker aebe9b7ba9 [DTP-Worker] Sprint 7/8 · Livrable démo : prompteur Markdown (out/run_sheet.md)
Le seul livrable démo (demo/scenarios) ne produisait qu'un run-sheet JSON
machine — aucun support lisible par un présentateur, alors que le Sprint 7 vise
un scénario « prêt à jouer ». Rendu Markdown in-repo depuis le JSON = même
pattern que faisabilite (rend des .md), zéro écriture VPS.

- scenlib/render.py : render_markdown() PUR/déterministe, ne lit que le
  run-sheet (déjà anti-inventé), aucun chiffre nouveau (#6).
- build émet out/run_sheet.md (prompteur : par beat, table « À dire | Chiffre |
  Source (preuve) » traçant le pointeur RFC 6901 amont).
- Compat vérifiée : HANDOFF 4Big ne json.load que les .json (ignore .md) ;
  check_artifacts diffe tout fichier build → md commité + reproductible.
- Consommateurs régénérés : régression 551→558 (22 suites PASS) ;
  quality_report.json (README 5011→5575 o · 32→39 test_*) PASS 22/22 ;
  03_agents/qa/AGENT.md 551→558 ; README démo auto-score 96→97.
- 7 gates verts · 39 tests démo · 34 tests audit · arbre propre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 04:09:23 +00:00

276 lines
11 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, render # 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 TestRenderMarkdown(unittest.TestCase):
"""Le prompteur Markdown : déterministe et sans donnée nouvelle (#6)."""
def setUp(self):
_, self.run_sheet, _ = _fresh()
self.md = render.render_markdown(self.run_sheet)
def test_deterministe(self):
self.assertEqual(self.md, render.render_markdown(self.run_sheet))
def test_termine_par_un_saut_de_ligne(self):
self.assertTrue(self.md.endswith("\n"))
self.assertFalse(self.md.endswith("\n\n"))
def test_titres_scenarios_presents(self):
for sc in self.run_sheet["scenarios"]:
self.assertIn(sc["titre"], self.md)
self.assertIn(sc["id"], self.md)
def test_chaque_beat_rendu(self):
for sc in self.run_sheet["scenarios"]:
for b in sc["beats"]:
self.assertIn(b["titre"], self.md)
self.assertIn(b["role_narratif"], self.md)
def test_valeurs_proviennent_du_run_sheet(self):
# Anti-invention : chaque valeur affichée est celle, formatée, d'une
# citation — jamais un chiffre nouveau. On vérifie la présence verbatim.
for sc in self.run_sheet["scenarios"]:
for b in sc["beats"]:
for c in b["evidence"]:
self.assertIn(render._fmt(c["valeur"]), self.md)
def test_fmt_bool_liste_none(self):
self.assertEqual(render._fmt(True), "oui")
self.assertEqual(render._fmt(False), "non")
self.assertEqual(render._fmt(["fr", "en"]), "fr, en")
self.assertEqual(render._fmt(50), "50")
self.assertEqual(render._fmt(None), "`—`")
def test_placeholder_non_promu_en_chiffre(self):
# Un placeholder survivant est rendu entre back-ticks, jamais nu.
self.assertEqual(render._fmt("{{m:f#/p}}"), "`{{m:f#/p}}`")
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", "run_sheet.md"):
self.assertTrue(os.path.isfile(os.path.join(d, name)))
if __name__ == "__main__":
unittest.main(verbosity=2)