5d9ea5687d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
300 lines
12 KiB
Python
300 lines
12 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 10 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 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 copy
|
|
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 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)}
|
|
|
|
|
|
def _mini_audit() -> dict:
|
|
return {"a/one": "S2", "b/two": "S3"}
|
|
|
|
|
|
def _mini_spec() -> dict:
|
|
return {
|
|
"version": "test",
|
|
"reference_cadre": "fixture",
|
|
"roadmap_ref": "ROADMAP.md",
|
|
"self_module": _SELF,
|
|
"cible_portage": "recette",
|
|
"extra_module_sprint": {},
|
|
"sprint_deliverables": [
|
|
{"id": "S2", "kind": "sprint_deliverable", "sprint": "S2",
|
|
"roadmap_line": 39, "statement": "livrable S2", "status": "in_repo",
|
|
"evidence_modules": ["a/one"], "evidence_artifacts": [],
|
|
"out_of_scope": []},
|
|
{"id": "S3", "kind": "sprint_deliverable", "sprint": "S3",
|
|
"roadmap_line": 46, "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": 81, "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 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()
|