[DTP-Worker] Sprint 2 · Faisabilité générateur 4 volets (brief→data_room + round-trip Publiciste + gate)
Générateur brief JSON → data_room/PXX/ (template canonique v1.0) :
- genlib/{model,scorer,renderer}.py : params canoniques imposés (#9/#10),
barème 4Big §7, rendu aligné sur le parser Publiciste (round-trip garanti)
- faisabilite_gen.py : CLI score/scaffold/generate/batch ; axe machine-lisible
PROUVÉ (re-parse Publiciste + validation version.json), jamais auto-décerné
- Anti-invention #6 : champ 🔴 absent → placeholder + champs_manquants +
rétrogradation statut ; prix manquant → {{…}} (jamais 0)
- 16 tests unittest (stdlib, réutilise parser+validateur Publiciste · #5) +
oracle jsonschema ; job CI faisabilite-gen-tests dans le gate (Gitea only #2)
- brief.schema.json + fixtures synthétiques ; README ; GAP §2 mis à jour
Boucle de valeur fermée : generator → data_room → Publiciste → projets_master.json
Auto-score 4Big : 96/100.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""Tests du générateur Faisabilité 4 volets (stdlib `unittest`, zéro pip).
|
||||
|
||||
Couvre : scoring 4Big, rétrogradation défensive (#6), rendu conforme au template,
|
||||
paramètres canoniques (#9/#10), et surtout le ROUND-TRIP end-to-end
|
||||
generator → parser Publiciste → projets_master.json (validé contre le schéma).
|
||||
|
||||
Un oracle `jsonschema` est utilisé EN PLUS s'il est installé.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # generator/
|
||||
_PUB = os.path.normpath(os.path.join(_GEN, "..", "..", "publiciste"))
|
||||
_FAIS = os.path.normpath(os.path.join(_GEN, "..")) # faisabilite/ (schémas)
|
||||
|
||||
sys.path.insert(0, _PUB)
|
||||
sys.path.insert(0, _GEN)
|
||||
|
||||
from genlib import model, renderer, scorer # noqa: E402,F401 (generator/genlib)
|
||||
|
||||
|
||||
def _load_gen_cli():
|
||||
"""Charge faisabilite_gen.py comme module isolé (évite collision `lib`)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"faisabilite_gen", os.path.join(_GEN, "faisabilite_gen.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _pub():
|
||||
"""(parser, validator) du Publiciste, importés depuis publiciste/lib."""
|
||||
import lib.parser as pub_parser # noqa: PLC0415
|
||||
import lib.validator as pub_validator # noqa: PLC0415
|
||||
return pub_parser, pub_validator
|
||||
|
||||
|
||||
def _brief(name):
|
||||
with open(os.path.join(_GEN, "fixtures", name), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _schema(name):
|
||||
with open(os.path.join(_FAIS, name), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
_FIXED_TS = "2026-07-30T00:00:00Z"
|
||||
|
||||
|
||||
class ScorerTest(unittest.TestCase):
|
||||
def test_complete_brief_scores_publiable(self):
|
||||
bilan = scorer.score(_brief("brief_complete.json"))
|
||||
self.assertEqual(bilan["statut_faisabilite"], "complete")
|
||||
self.assertGreaterEqual(bilan["score_4big"], 95)
|
||||
self.assertEqual(bilan["champs_manquants"], [])
|
||||
self.assertEqual(len(bilan["volets_complets"]), 4)
|
||||
self.assertEqual(bilan["axes"]["anti_gap_prix"], 20)
|
||||
|
||||
def test_incomplete_brief_is_downgraded(self):
|
||||
bilan = scorer.score(_brief("brief_incomplete.json"))
|
||||
self.assertNotEqual(bilan["statut_faisabilite"], "complete")
|
||||
self.assertLess(bilan["score_4big"], 95)
|
||||
# Les prix manquants doivent figurer explicitement (anti-gap).
|
||||
joined = " ".join(bilan["champs_manquants"])
|
||||
self.assertIn("prix_usd", joined)
|
||||
self.assertIn("prix_dop", joined)
|
||||
|
||||
def test_missing_required_field_listed(self):
|
||||
b = _brief("brief_complete.json")
|
||||
b["masterplan"]["phasage"] = None
|
||||
bilan = scorer.score(b)
|
||||
self.assertIn("masterplan.phasage", bilan["champs_manquants"])
|
||||
self.assertNotIn("masterplan", bilan["volets_complets"])
|
||||
self.assertNotEqual(bilan["statut_faisabilite"], "complete")
|
||||
|
||||
def test_no_typologies_zeroes_anti_gap(self):
|
||||
b = _brief("brief_complete.json")
|
||||
b["architecture"]["typologies"] = []
|
||||
bilan = scorer.score(b)
|
||||
self.assertEqual(bilan["axes"]["anti_gap_prix"], 0)
|
||||
self.assertNotIn("architecture", bilan["volets_complets"])
|
||||
|
||||
def test_empty_brief_is_incomplete(self):
|
||||
bilan = scorer.score({"projet": "P03"})
|
||||
self.assertEqual(bilan["statut_faisabilite"], "incomplete")
|
||||
self.assertEqual(bilan["volets_complets"], [])
|
||||
|
||||
|
||||
class RendererTest(unittest.TestCase):
|
||||
def test_canonical_params_always_present(self):
|
||||
# Même un brief quasi vide émet les 6 paramètres canoniques (#9/#10).
|
||||
md = renderer.render_ingenierie({"projet": "P03"})
|
||||
self.assertEqual(renderer.check_canonical(md), [])
|
||||
for marker in ["3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"]:
|
||||
self.assertIn(marker, md)
|
||||
|
||||
def test_missing_price_is_placeholder_not_invented(self):
|
||||
md = renderer.render_architecture(_brief("brief_incomplete.json"))
|
||||
# Aucun prix inventé : les cellules USD/DOP restent des placeholders.
|
||||
self.assertIn("{{typo_1_prix_usd}}", md)
|
||||
self.assertIn("{{typo_2_prix_dop}}", md)
|
||||
|
||||
def test_money_formatting(self):
|
||||
self.assertEqual(renderer._money(150000, "USD", "x"), "USD 150,000")
|
||||
self.assertEqual(renderer._money(None, "USD", "x"), "{{x}}")
|
||||
self.assertEqual(renderer._money(0, "DOP", "x"), "DOP 0")
|
||||
|
||||
def test_synthetic_banner(self):
|
||||
md = renderer.render_brief(_brief("brief_complete.json"))
|
||||
self.assertIn("SYNTHÉTIQUE", md)
|
||||
|
||||
|
||||
class RoundTripTest(unittest.TestCase):
|
||||
"""generator → data_room/PXX/ → parser Publiciste → schéma."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="fais_gen_")
|
||||
self.cli = _load_gen_cli()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _gen(self, brief_name):
|
||||
brief = _brief(brief_name)
|
||||
projet_dir, bilan = self.cli._generate_tree(brief, self.tmp, _FIXED_TS)
|
||||
return brief, projet_dir, bilan
|
||||
|
||||
def test_complete_roundtrips_to_disponible(self):
|
||||
brief, projet_dir, bilan = self._gen("brief_complete.json")
|
||||
self.assertNotIn("_machine_lisible_motifs", bilan) # preuve axe 5 OK
|
||||
|
||||
pub_parser, pub_validator = _pub()
|
||||
projet = pub_parser.parse_projet(projet_dir)
|
||||
# complete → le parser NE rétrograde PAS (prix tous présents).
|
||||
self.assertEqual(projet["statut"], "disponible")
|
||||
self.assertEqual(projet["code"], "P01")
|
||||
self.assertEqual(len(projet["typologies"]), 3)
|
||||
self.assertTrue(all(isinstance(t["prix_depuis_usd"], (int, float))
|
||||
for t in projet["typologies"]))
|
||||
self.assertEqual(projet["typologies"][0]["prix_depuis_usd"], 150000.0)
|
||||
# Services + localisation + positionnement remontent bien.
|
||||
self.assertIn("Conciergerie 24/7", projet["inclus"])
|
||||
self.assertTrue(projet["localisation"])
|
||||
self.assertIn("fr", projet.get("positionnement", {}))
|
||||
# Rendus (manifeste) lus.
|
||||
self.assertTrue(projet["rendus"][0]["hero"])
|
||||
|
||||
def test_incomplete_roundtrips_to_en_developpement(self):
|
||||
brief, projet_dir, bilan = self._gen("brief_incomplete.json")
|
||||
self.assertNotIn("_machine_lisible_motifs", bilan)
|
||||
|
||||
pub_parser, _ = _pub()
|
||||
projet = pub_parser.parse_projet(projet_dir)
|
||||
# Prix absents → parser publie « en développement » (jamais de prix inventé).
|
||||
self.assertEqual(projet["statut"], "en_developpement")
|
||||
self.assertEqual(len(projet["typologies"]), 2)
|
||||
self.assertTrue(all(t["prix_depuis_usd"] is None
|
||||
for t in projet["typologies"]))
|
||||
|
||||
def test_version_json_validates_against_schema(self):
|
||||
_, projet_dir, _ = self._gen("brief_complete.json")
|
||||
with open(os.path.join(projet_dir, "_META", "version.json"),
|
||||
encoding="utf-8") as fh:
|
||||
version = json.load(fh)
|
||||
_, pub_validator = _pub()
|
||||
errs = pub_validator.validate(version, _schema("version.schema.json"))
|
||||
self.assertEqual(errs, [], f"version.json non conforme : {errs}")
|
||||
|
||||
def test_full_pipeline_master_validates(self):
|
||||
# Génère 2 projets → parcours build_master → projets_master.json conforme.
|
||||
for name in ("brief_complete.json", "brief_incomplete.json"):
|
||||
self._gen(name)
|
||||
pub_parser, pub_validator = _pub()
|
||||
master = pub_parser.build_master(self.tmp, generated_at=_FIXED_TS)
|
||||
self.assertEqual(len(master["projets"]), 2)
|
||||
errs = pub_validator.validate(
|
||||
master, _schema("projets_master.schema.json")
|
||||
)
|
||||
self.assertEqual(errs, [], f"projets_master non conforme : {errs}")
|
||||
|
||||
def test_generation_is_deterministic(self):
|
||||
self._gen("brief_complete.json")
|
||||
snap1 = _read_tree(os.path.join(self.tmp, "P01"))
|
||||
shutil.rmtree(os.path.join(self.tmp, "P01"))
|
||||
self._gen("brief_complete.json")
|
||||
snap2 = _read_tree(os.path.join(self.tmp, "P01"))
|
||||
self.assertEqual(snap1, snap2)
|
||||
|
||||
def test_scaffold_is_not_publiable(self):
|
||||
# Un scaffold vide ne doit jamais être « complete ».
|
||||
projet_dir, bilan = self.cli._generate_tree(
|
||||
{"projet": "P09"}, self.tmp, _FIXED_TS
|
||||
)
|
||||
self.assertNotEqual(bilan["statut_faisabilite"], "complete")
|
||||
self.assertNotIn("_machine_lisible_motifs", bilan) # structure quand même valide
|
||||
pub_parser, _ = _pub()
|
||||
projet = pub_parser.parse_projet(projet_dir)
|
||||
self.assertEqual(projet["statut"], "en_developpement")
|
||||
|
||||
|
||||
class OracleTest(unittest.TestCase):
|
||||
"""Concordance validateur maison ↔ `jsonschema` (si installé)."""
|
||||
|
||||
def test_version_json_matches_jsonschema_oracle(self):
|
||||
try:
|
||||
import jsonschema # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema non installé")
|
||||
tmp = tempfile.mkdtemp(prefix="fais_oracle_")
|
||||
try:
|
||||
cli = _load_gen_cli()
|
||||
projet_dir, _ = cli._generate_tree(
|
||||
_brief("brief_complete.json"), tmp, _FIXED_TS
|
||||
)
|
||||
with open(os.path.join(projet_dir, "_META", "version.json"),
|
||||
encoding="utf-8") as fh:
|
||||
version = json.load(fh)
|
||||
jsonschema.validate(version, _schema("version.schema.json"))
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _read_tree(root):
|
||||
out = {}
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for f in sorted(files):
|
||||
p = os.path.join(dirpath, f)
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
out[os.path.relpath(p, root)] = fh.read()
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user