"""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 via # _validate_or_die) ET retourne le code 0. On l'asserte explicitement : # ainsi le test mord aussi si un refactor remplaçait sys.exit(1) par un # `return 1` silencieux (le SystemExit ne serait alors plus levé). self.assertEqual(gen.main(["validate"]), 0) if __name__ == "__main__": unittest.main()