#!/usr/bin/env python3 """Tests du générateur de manifest de dépendances PIE · Annexe 12 · V10.1. Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea. La bibliothèque `jsonschema` sert d'*oracle* quand elle est présente. Axes : 1. BASELINE : le contrat vanille passe schéma + 10 invariants, est déterministe, et l'artefact `out/` commité == build frais (reproductibilité · #6). 2. MUTATION : chaque invariant attrape bien la dérive qu'il protège (on casse le bundle en mémoire et on vérifie que `_validate` remonte une erreur). 3. ANCRAGES : codes marques ⊆ CLAUDE.md §Projets ; annexe/version verbatim dans la directive ; modules downstream « gated » = répertoires de livrables réels. """ from __future__ import annotations import copy import json import os import subprocess import sys import tempfile import unittest _HERE = os.path.dirname(os.path.abspath(__file__)) _MODULE = os.path.normpath(os.path.join(_HERE, "..")) _DELIVERABLES = os.path.normpath(os.path.join(_MODULE, "..", "..")) sys.path.insert(0, _MODULE) sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) from pielib import builder, deps # noqa: E402 from lib import validator as maison # type: ignore # noqa: E402 import pie_manifest_gen as gen # noqa: E402 try: import jsonschema # type: ignore _HAS_JSONSCHEMA = True except Exception: # pragma: no cover _HAS_JSONSCHEMA = False def _load(path: str) -> dict: with open(path, encoding="utf-8") as fh: return json.load(fh) class Baseline(unittest.TestCase): """Le contrat vanille passe schéma + 10 invariants et est déterministe.""" def setUp(self): self.bundle = gen._build() def test_validate_clean(self): self.assertEqual(gen._validate(self.bundle), []) def test_counts_match_directive(self): c = self.bundle["traceability"]["counts"] self.assertEqual(c["master_data_groups"], 12) # §16-29 self.assertEqual(c["sync_rules"], 4) # §82-86 self.assertEqual(c["workflow_steps"], 10) # §108-133 self.assertEqual(c["brands"], 9) # §90-102 def test_deterministic_rebuild(self): self.assertEqual(gen._build(), gen._build()) def test_schema_oracle(self): if not _HAS_JSONSCHEMA: self.skipTest("jsonschema absent") schema = _load(gen._SCHEMA_PATH) jsonschema.validate(self.bundle["manifest"], schema) # ne lève pas class Reproducibility(unittest.TestCase): """L'artefact commité `out/` == build frais (byte-identique).""" def test_committed_matches_fresh(self): out = os.path.join(_MODULE, "out") if not os.path.isdir(out): self.skipTest("out/ non commité") with tempfile.TemporaryDirectory() as tmp: rc = gen.main(["build", "-o", tmp]) self.assertEqual(rc, 0) for name in ("pie_manifest.json", "MANIFEST.json"): with open(os.path.join(out, name), encoding="utf-8") as a, \ open(os.path.join(tmp, name), encoding="utf-8") as b: self.assertEqual(a.read(), b.read(), f"{name} a dérivé de sa source") class Anchors(unittest.TestCase): """Les ancrages hors-module pointent bien vers du réel (anti-invention #6).""" def setUp(self): self.man = gen._build()["manifest"] def test_brand_codes_subset_of_claude_md(self): codes = {b["code"] for b in self.man["brands"]} canon = deps.claude_md_project_codes() self.assertTrue(canon, "aucun code Pxx lu dans CLAUDE.md §Projets") self.assertTrue(codes <= canon, f"codes hors constitution : {codes - canon}") def test_annexe_version_in_directive(self): decl = deps.directive_declares(self.man_directive(), "Annexe 12", "V10.1") self.assertTrue(all(decl.values()), f"ancrage directive rompu : {decl}") def test_gated_modules_exist_on_fs(self): for d in self.man["downstream_registry"]: if d["statut"] == "gated": self.assertTrue(deps.module_exists(d["module"]), f"module gated introuvable : {d['module']}") def man_directive(self) -> str: return gen._build()["traceability"]["directive_source"] class Mutations(unittest.TestCase): """Chaque invariant attrape la dérive qu'il protège.""" def setUp(self): self.bundle = gen._build() def _breaks(self, mutate) -> None: bundle = copy.deepcopy(self.bundle) mutate(bundle) self.assertNotEqual(gen._validate(bundle), [], "la mutation aurait dû être refusée") def test_inv2_missing_group(self): self._breaks(lambda b: b["manifest"]["master_data_groups"].pop()) def test_inv2_wrong_group_key(self): def m(b): b["manifest"]["master_data_groups"][0]["cle"] = "inventé" self._breaks(m) def test_inv3_unknown_trigger(self): def m(b): b["manifest"]["sync_rules"][0]["trigger"] = "couleur" self._breaks(m) def test_inv4_dangling_downstream(self): def m(b): b["manifest"]["sync_rules"][0]["downstream"].append("fantome") self._breaks(m) def test_inv4_dangling_trigger_group(self): def m(b): b["manifest"]["sync_rules"][0]["trigger_group"] = "fantome" self._breaks(m) def test_inv5_status_incoherent(self): def m(b): b["manifest"]["downstream_registry"][0]["statut"] = "gated" b["manifest"]["downstream_registry"][0]["module"] = None self._breaks(m) def test_inv6_phantom_module(self): def m(b): for d in b["manifest"]["downstream_registry"]: if d["module"]: d["module"] = "faisabilite/inexistant" return self._breaks(m) def test_inv7_non_contiguous_workflow(self): def m(b): b["manifest"]["workflow"][0]["etape"] = 99 self._breaks(m) def test_inv8_alien_brand_code(self): def m(b): b["manifest"]["brands"][0]["code"] = "P99" self._breaks(m) def test_inv9_wrong_annexe(self): def m(b): b["manifest"]["annexe"] = 7 # « Annexe 7 » n'est pas dans la directive self._breaks(m) def test_inv10_storage_not_vps(self): def m(b): b["manifest"]["storage_layout"]["racine"] = "05_deliverables_mvp/pie/" self._breaks(m) def test_count_incoherent(self): def m(b): b["traceability"]["counts"]["brands"] = 999 self._breaks(m) if __name__ == "__main__": # pragma: no cover unittest.main()