25541c365d
INV10 ne garantissait que « roadmap_line est un entier positif ». Ajout de parse_roadmap_anchors (deps) qui DÉRIVE la structure réelle de la roadmap, et d'INV11 qui exige que chaque roadmap_line pointe RÉELLEMENT son bullet (DELIVERABLE du sprint SX · k-ième bullet métrique) et que le « 8 + 7 » soit dérivé du fichier, pas figé. Morsure prouvée sur le spec réel (S1=999, M3=200). Régénéré consommateurs : regression 558→564 (run/plan/MANIFEST), quality_report (acceptance 31→37 méthodes, 100/100 inchangé), fiches QA + Backend, README acceptance (10→11 invariants). 7 gates verts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
382 lines
16 KiB
Python
382 lines
16 KiB
Python
"""Tests de la Matrice d'acceptation / traçabilité MVP (stdlib pur · zéro pip).
|
|
|
|
Couvre : le build réel (couverture bijective vs CI Gitea + registre 4Big), la
|
|
conformité au schéma, les 11 familles d'invariants, le déterminisme, et une
|
|
batterie d'injections négatives (chaque invariant DOIT rougir quand on le viole).
|
|
|
|
Les négatifs s'appuient sur une fixture SYNTHÉTIQUE minimale (CI + registre +
|
|
spec + roadmap factices) pour contrôler exactement l'écart injecté, sans dépendre
|
|
de la forme du dépôt réel.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import copy
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_MOD = os.path.normpath(os.path.join(_HERE, ".."))
|
|
sys.path.insert(0, _MOD)
|
|
|
|
import acceptance_gen as gen # noqa: E402
|
|
from acclib import builder, deps # noqa: E402
|
|
|
|
|
|
def _spec() -> dict:
|
|
return gen._load(gen._SPEC_PATH)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixture synthétique : deux modules gated (a/one=S2, b/two=S3) + la matrice. #
|
|
# --------------------------------------------------------------------------- #
|
|
_SELF = "qa/acceptance"
|
|
|
|
|
|
def _mini_ci(paths, self_module=_SELF) -> dict:
|
|
j2p = {f"{p.replace('/', '-')}-tests": p for p in paths}
|
|
j2p["qa-acceptance-tests"] = self_module
|
|
return {"job_to_path": j2p, "gate_needs": list(j2p)}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Roadmap SYNTHÉTIQUE : la même structure que la vraie (## SPRINT n · SX · bullet
|
|
# **DELIVERABLE**, puis section Métriques avec bullets « - ✅ »), en miniature.
|
|
# INV11 lit ce fichier ; on contrôle donc exactement les n° de ligne attendus :
|
|
# L3 → DELIVERABLE S2 · L5 → DELIVERABLE S3 · L7 → 1er bullet métrique (M1).
|
|
# --------------------------------------------------------------------------- #
|
|
_MINI_ROADMAP = (
|
|
"# Roadmap fixture\n" # L1
|
|
"## SPRINT 2 · Fixture · S2 (obj S1)\n" # L2
|
|
"- **DELIVERABLE** : livrable S2\n" # L3
|
|
"## SPRINT 3 · Fixture · S3 (obj S2)\n" # L4
|
|
"- **DELIVERABLE** : livrable S3\n" # L5
|
|
"## Métriques succès MVP (fixture)\n" # L6
|
|
"- ✅ métrique M1\n" # L7
|
|
)
|
|
_ROADMAP_S2, _ROADMAP_S3, _ROADMAP_M1 = 3, 5, 7
|
|
|
|
_fd, _ROADMAP_PATH = tempfile.mkstemp(suffix="_roadmap.md", prefix="acc_fixture_")
|
|
with os.fdopen(_fd, "w", encoding="utf-8") as _fh:
|
|
_fh.write(_MINI_ROADMAP)
|
|
atexit.register(lambda: os.path.exists(_ROADMAP_PATH) and os.remove(_ROADMAP_PATH))
|
|
|
|
|
|
def _mini_audit() -> dict:
|
|
return {"a/one": "S2", "b/two": "S3"}
|
|
|
|
|
|
def _mini_spec() -> dict:
|
|
return {
|
|
"version": "test",
|
|
"reference_cadre": "fixture",
|
|
"roadmap_ref": _ROADMAP_PATH,
|
|
"self_module": _SELF,
|
|
"cible_portage": "recette",
|
|
"extra_module_sprint": {},
|
|
"sprint_deliverables": [
|
|
{"id": "S2", "kind": "sprint_deliverable", "sprint": "S2",
|
|
"roadmap_line": _ROADMAP_S2, "statement": "livrable S2", "status": "in_repo",
|
|
"evidence_modules": ["a/one"], "evidence_artifacts": [],
|
|
"out_of_scope": []},
|
|
{"id": "S3", "kind": "sprint_deliverable", "sprint": "S3",
|
|
"roadmap_line": _ROADMAP_S3, "statement": "livrable S3", "status": "in_repo",
|
|
"evidence_modules": ["b/two"], "evidence_artifacts": [],
|
|
"out_of_scope": []},
|
|
],
|
|
"mvp_metrics": [
|
|
{"id": "M1", "kind": "mvp_metric", "sprint": None,
|
|
"roadmap_line": _ROADMAP_M1, "statement": "métrique M1", "status": "in_repo",
|
|
"evidence_modules": ["a/one"], "evidence_artifacts": [],
|
|
"out_of_scope": []},
|
|
],
|
|
}
|
|
|
|
|
|
def _mini_env():
|
|
"""(spec, ci, audit) — la fixture minimale valide."""
|
|
return _mini_spec(), _mini_ci(["a/one", "b/two"]), _mini_audit()
|
|
|
|
|
|
def _mini_errors(spec, ci=None, audit=None) -> list:
|
|
ci = ci or _mini_ci(["a/one", "b/two"])
|
|
audit = audit or _mini_audit()
|
|
bundle = builder.build_matrix(spec, ci, audit, deps)
|
|
return gen.validate_bundle(bundle, spec, ci, audit)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
class TestMiniClean(unittest.TestCase):
|
|
"""La fixture synthétique doit valider proprement (base des négatifs)."""
|
|
|
|
def test_mini_validates_clean(self):
|
|
self.assertEqual(_mini_errors(*(_mini_env()[:1]),), [],
|
|
"la fixture de base doit être verte")
|
|
|
|
def test_mini_bijective(self):
|
|
spec, ci, audit = _mini_env()
|
|
b = builder.build_matrix(spec, ci, audit, deps)
|
|
cov = b["manifest"]["coverage"]
|
|
self.assertTrue(cov["bijective"])
|
|
self.assertTrue(cov["partition_ok"])
|
|
self.assertTrue(b["verdict"])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
class TestRealBuild(unittest.TestCase):
|
|
"""Build réel contre le CI, le registre 4Big et le spec du dépôt."""
|
|
|
|
def setUp(self):
|
|
self.spec = _spec()
|
|
self.ci = deps.parse_ci()
|
|
self.audit = deps.audit_sprints()
|
|
self.bundle = builder.build_matrix(self.spec, self.ci, self.audit, deps)
|
|
|
|
def test_build_validates_clean(self):
|
|
errs = gen.validate_bundle(self.bundle, self.spec, self.ci, self.audit)
|
|
self.assertEqual(errs, [], f"invariants rouges: {errs}")
|
|
|
|
def test_schema_conformance(self):
|
|
schema = gen._load(gen._SCHEMA_PATH)
|
|
self.assertEqual(list(deps.validate(self.bundle, schema)), [])
|
|
|
|
def test_coverage_bijective(self):
|
|
cov = self.bundle["manifest"]["coverage"]
|
|
self.assertTrue(cov["bijective"])
|
|
self.assertEqual(cov["missing_in_matrix"], [])
|
|
self.assertEqual(cov["phantom_evidence"], [])
|
|
|
|
def test_partition_exact_all_sprints(self):
|
|
cov = self.bundle["manifest"]["coverage"]
|
|
self.assertTrue(cov["partition_ok"])
|
|
for p in cov["sprint_partition"]:
|
|
self.assertTrue(p["exact"], f"partition {p['sprint']} non exacte")
|
|
self.assertEqual(cov["unknown_sprint"], [])
|
|
|
|
def test_verdict_pass(self):
|
|
self.assertTrue(self.bundle["verdict"])
|
|
|
|
def test_counts_match_roadmap(self):
|
|
c = self.bundle["manifest"]["counts"]
|
|
self.assertEqual(c["sprint_deliverables"], 8) # S1..S8
|
|
self.assertEqual(c["mvp_metrics"], 7) # M1..M7
|
|
self.assertEqual(c["gated_modules"], c["cited_modules"])
|
|
|
|
def test_every_gated_module_cited_once(self):
|
|
gated = set(builder.gated_modules(self.ci, self.spec["self_module"]))
|
|
cited = set()
|
|
for d in builder.all_deliverables(self.spec):
|
|
if d["status"] == "in_repo":
|
|
cited.update(d["evidence_modules"])
|
|
self.assertEqual(cited, gated)
|
|
|
|
def test_self_module_not_cited(self):
|
|
for d in builder.all_deliverables(self.spec):
|
|
self.assertNotIn(self.spec["self_module"], d["evidence_modules"])
|
|
|
|
def test_out_of_scope_all_sourced(self):
|
|
for d in builder.all_deliverables(self.spec):
|
|
for o in d.get("out_of_scope", []):
|
|
self.assertTrue(o.get("source", "").strip(),
|
|
f"{d['id']}: hors-périmètre sans source")
|
|
|
|
def test_evidence_artifacts_exist(self):
|
|
for d in builder.all_deliverables(self.spec):
|
|
for a in d.get("evidence_artifacts", []):
|
|
self.assertTrue(deps.artifact_exists(a), f"{a} absent")
|
|
|
|
def test_determinism(self):
|
|
b2 = builder.build_matrix(self.spec, self.ci, self.audit, deps)
|
|
self.assertEqual(self.bundle, b2)
|
|
|
|
def test_sprint_windows_from_audit_registry(self):
|
|
# Toute fenêtre de sprint vient du registre 4Big (sauf l'auditeur, via
|
|
# extra) — jamais re-déclarée dans le spec d'acceptation.
|
|
ms = builder.module_sprint_map(self.spec, self.audit)
|
|
gated = builder.gated_modules(self.ci, self.spec["self_module"])
|
|
for m in gated:
|
|
self.assertIn(m, ms, f"{m} sans fenêtre de sprint")
|
|
|
|
def test_audit_4big_declared_via_extra(self):
|
|
self.assertIn("qa/audit_4big", self.spec["extra_module_sprint"])
|
|
self.assertTrue(
|
|
self.spec["extra_module_sprint"]["qa/audit_4big"]["source"].strip())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
class TestNegativeInjections(unittest.TestCase):
|
|
"""Chaque invariant DOIT rougir quand on injecte l'écart correspondant."""
|
|
|
|
def test_inv2_phantom_module_flagged(self):
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["evidence_modules"].append("z/ghost")
|
|
self.assertTrue(any("non gated" in e or "fantôme" in e
|
|
for e in _mini_errors(spec)))
|
|
|
|
def test_inv3_missing_gated_module(self):
|
|
# b/two gated mais retiré de son livrable → couverture non bijective.
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][1]["evidence_modules"] = []
|
|
# laisser un artefact pour ne pas déclencher AUSSI INV7
|
|
spec["sprint_deliverables"][1]["evidence_artifacts"] = ["GAP_ANALYSIS_SPRINT1.md"]
|
|
errs = _mini_errors(spec)
|
|
self.assertTrue(any("non tracés" in e for e in errs), errs)
|
|
|
|
def test_inv5_partition_wrong_sprint(self):
|
|
# Placer b/two (fenêtre S3) dans le livrable S2 → partition fausse.
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["evidence_modules"] = ["a/one", "b/two"]
|
|
spec["sprint_deliverables"][1]["evidence_modules"] = []
|
|
spec["sprint_deliverables"][1]["evidence_artifacts"] = ["GAP_ANALYSIS_SPRINT1.md"]
|
|
self.assertTrue(any("partition" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv4_module_without_sprint(self):
|
|
# Registre 4Big ne connaît pas b/two → module gated sans fenêtre.
|
|
spec = _mini_spec()
|
|
bad_audit = {"a/one": "S2"} # b/two manquant
|
|
errs = _mini_errors(spec, audit=bad_audit)
|
|
self.assertTrue(any("sans fenêtre" in e for e in errs), errs)
|
|
|
|
def test_inv4_extra_conflicts_with_registry(self):
|
|
spec = _mini_spec()
|
|
spec["extra_module_sprint"] = {"a/one": {"sprint": "S9", "source": "x"}}
|
|
self.assertTrue(any("conflit" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv6_out_of_scope_without_source(self):
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["out_of_scope"] = [{"item": "x", "source": " "}]
|
|
self.assertTrue(any("sans source" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv7_in_repo_without_evidence(self):
|
|
spec = _mini_spec()
|
|
spec["mvp_metrics"][0]["evidence_modules"] = []
|
|
self.assertTrue(any("sans aucune preuve" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv7_out_of_scope_with_module(self):
|
|
spec = _mini_spec()
|
|
spec["mvp_metrics"].append({
|
|
"id": "M2", "kind": "mvp_metric", "sprint": None, "roadmap_line": 82,
|
|
"statement": "m2", "status": "out_of_scope",
|
|
"evidence_modules": ["a/one"], "evidence_artifacts": [],
|
|
"out_of_scope": [{"item": "x", "source": "s"}]})
|
|
self.assertTrue(any("out_of_scope avec preuve" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv7_missing_artifact(self):
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["evidence_artifacts"] = ["does/not/exist.md"]
|
|
self.assertTrue(any("inexistant" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv8_metric_with_sprint(self):
|
|
spec = _mini_spec()
|
|
spec["mvp_metrics"][0]["sprint"] = "S4"
|
|
self.assertTrue(any("sprint non nul" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv8_sprint_deliverable_without_sprint(self):
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["sprint"] = None
|
|
self.assertTrue(any("sans sprint" in e or "sprint" in e
|
|
for e in _mini_errors(spec)))
|
|
|
|
def test_inv9_self_cited(self):
|
|
spec = _mini_spec()
|
|
# citer la matrice elle-même comme preuve (et la rendre gated)
|
|
ci = _mini_ci(["a/one", "b/two"])
|
|
ci["job_to_path"]["extra-job"] = _SELF # reste self → exclu du gated
|
|
spec["sprint_deliverables"][0]["evidence_modules"] = ["a/one", _SELF]
|
|
self.assertTrue(any("elle-même" in e or "non gated" in e
|
|
for e in _mini_errors(spec, ci=ci)))
|
|
|
|
def test_inv1_duplicate_id(self):
|
|
spec = _mini_spec()
|
|
spec["mvp_metrics"][0]["id"] = "S2" # collision avec un sprint
|
|
self.assertTrue(any("double" in e for e in _mini_errors(spec)))
|
|
|
|
def test_inv3_flags_bijective_false_consistency(self):
|
|
# Retirer un module ET son artefact → bijective doit passer à False.
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][1]["evidence_modules"] = []
|
|
spec["sprint_deliverables"][1]["evidence_artifacts"] = ["GAP_ANALYSIS_SPRINT1.md"]
|
|
b = builder.build_matrix(spec, _mini_ci(["a/one", "b/two"]), _mini_audit(), deps)
|
|
self.assertFalse(b["manifest"]["coverage"]["bijective"])
|
|
self.assertFalse(b["verdict"])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
class TestRoadmapAnchor(unittest.TestCase):
|
|
"""INV11 · les n° de ligne `roadmap_line` sont ANCRÉS à la structure réelle
|
|
de la roadmap (traçabilité vérifiée, pas déclarée)."""
|
|
|
|
def test_parse_real_roadmap_structure(self):
|
|
# Le parseur dérive 8 livrables (S1..S8) + 7 métriques de la vraie roadmap.
|
|
spec = _spec()
|
|
a = deps.parse_roadmap_anchors(spec["roadmap_ref"])
|
|
self.assertEqual(sorted(a["deliverables"]),
|
|
[f"S{i}" for i in range(1, 9)])
|
|
self.assertEqual(len(a["metrics"]), 7)
|
|
# Chaque roadmap_line du spec réel pointe bien son bullet.
|
|
for d in spec["sprint_deliverables"]:
|
|
self.assertEqual(d["roadmap_line"], a["deliverables"][d["sprint"]],
|
|
f"{d['id']} désancré")
|
|
for k, d in enumerate(spec["mvp_metrics"]):
|
|
self.assertEqual(d["roadmap_line"], a["metrics"][k], f"{d['id']} désancré")
|
|
|
|
def test_deliverable_line_offby_fails(self):
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["roadmap_line"] = 99 # ne pointe plus S2
|
|
self.assertTrue(any("ne pointe pas" in e for e in _mini_errors(spec)),
|
|
_mini_errors(spec))
|
|
|
|
def test_metric_line_offby_fails(self):
|
|
spec = _mini_spec()
|
|
spec["mvp_metrics"][0]["roadmap_line"] = 1 # pas le bullet métrique
|
|
self.assertTrue(any("bullet métrique" in e for e in _mini_errors(spec)),
|
|
_mini_errors(spec))
|
|
|
|
def test_wrong_sprint_owner_fails(self):
|
|
# Le livrable prétend S3 mais garde la ligne du DELIVERABLE de S2.
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"][0]["sprint"] = "S3"
|
|
spec["sprint_deliverables"][0]["roadmap_line"] = _ROADMAP_S2
|
|
self.assertTrue(any("ne pointe pas" in e for e in _mini_errors(spec)),
|
|
_mini_errors(spec))
|
|
|
|
def test_extra_deliverable_count_mismatch_fails(self):
|
|
# Un livrable de plus que ce que contient la roadmap fixture.
|
|
spec = _mini_spec()
|
|
spec["sprint_deliverables"].append({
|
|
"id": "S4", "kind": "sprint_deliverable", "sprint": "S4",
|
|
"roadmap_line": 3, "statement": "x", "status": "in_repo",
|
|
"evidence_modules": ["a/one"], "evidence_artifacts": [], "out_of_scope": []})
|
|
errs = _mini_errors(spec)
|
|
self.assertTrue(any("bullet(s) DELIVERABLE" in e for e in errs)
|
|
or any("aucun bullet DELIVERABLE" in e for e in errs), errs)
|
|
|
|
def test_missing_roadmap_file_fails(self):
|
|
spec = _mini_spec()
|
|
spec["roadmap_ref"] = "/nonexistent/path/ROADMAP.md"
|
|
self.assertTrue(any("illisible" in e for e in _mini_errors(spec)),
|
|
_mini_errors(spec))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
class TestCLI(unittest.TestCase):
|
|
"""Le CLI build/validate produit les artefacts et sort proprement."""
|
|
|
|
def test_build_writes_outputs(self):
|
|
import tempfile
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
gen.main(["build", "-o", tmp])
|
|
self.assertTrue(os.path.exists(os.path.join(tmp, "acceptance_matrix.json")))
|
|
self.assertTrue(os.path.exists(os.path.join(tmp, "MANIFEST.json")))
|
|
|
|
def test_validate_exits_zero(self):
|
|
# cmd_validate ne lève pas sur le dépôt réel (sinon SystemExit).
|
|
gen.main(["validate"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|