[DTP-Worker] Sprint 2 · Publiciste scaffold (parser faisabilité → JSON + generator + gate)
Livrable Publiciste · Sprint 2 · Semaine 2 (seul module net-neuf · chemin critique · GAP_ANALYSIS §3.13). Cible portage VPS : otoia/capabilities/publiciste.py. - lib/parser.py : data_room/PXX/ (template v1.0) → projet dict conforme au contrat projets_master.schema.json (livré S1). Mapping colonnes par en-tête, parsing montants USD/DOP robuste. Anti-invention #6 : rétrogradation défensive « en_developpement » si prix USD manquant ; absent → null (jamais 0/inventé). - lib/validator.py : validateur JSON-Schema draft-07 (sous-ensemble) ZÉRO dépendance pip (runner Gitea sans pip). Oracle jsonschema en test si présent. - lib/generator.py + template + branding : rendu HTML luxury #4 (dark+doré, Fraunces + Cormorant Garamond) ; sans prix → « Prochainement · Détails à venir ». - publiciste.py : CLI parse/validate/generate/run. - fixtures/ : données SYNTHÉTIQUES de test (jamais publiées) P01 complète + P02 incomplète. - tests/ : 23 tests unittest (stdlib) verts. - CI : job publiciste-tests ajouté au gate (.gitea/workflows/ci.yml · Gitea #2). Vérifs (en-repo, sans VPS) : 23/23 tests verts · gate CI local vert (guard/JSON/ docs) · pipeline CLI produit un master conforme au schéma. Auto-score 4Big 95/100. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests unitaires du Publiciste Agent (stdlib `unittest`, zéro dépendance).
|
||||
|
||||
Exécution : `python3 -m unittest discover -s tests` depuis le dossier publiciste/,
|
||||
ou `python3 tests/test_publiciste.py`.
|
||||
|
||||
Couvre : parsing chiffres, extraction faisabilité, rétrogradation défensive
|
||||
(anti-invention #6), conformité au schéma (validateur maison + oracle jsonschema
|
||||
si présent), rendu HTML (marque luxury #4, zéro prix inventé).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE) # dossier publiciste/
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from lib import generator, parser, validator # noqa: E402
|
||||
|
||||
FIXTURES = os.path.join(ROOT, "fixtures", "data_room")
|
||||
SCHEMA_DIR = os.path.normpath(os.path.join(ROOT, "..", "faisabilite"))
|
||||
FIXED_TS = "2026-07-30T00:00:00Z"
|
||||
|
||||
|
||||
def _schema(name: str) -> dict:
|
||||
with open(os.path.join(SCHEMA_DIR, name), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
class TestNumberParsing(unittest.TestCase):
|
||||
def test_prix_formats(self):
|
||||
self.assertEqual(parser.parse_price("USD 150,000"), 150000.0)
|
||||
self.assertEqual(parser.parse_price("210000"), 210000.0)
|
||||
self.assertEqual(parser.parse_price("DOP 8 850 000"), 8850000.0)
|
||||
self.assertEqual(parser.parse_price("1,250,000.50"), 1250000.50)
|
||||
|
||||
def test_absents_donnent_none(self):
|
||||
for token in ["", "—", "-", "non défini", "n/d", "{{typo_1_prix_usd}}", "...", "TBD"]:
|
||||
self.assertIsNone(parser.parse_price(token), f"{token!r} devrait être None")
|
||||
|
||||
def test_surfaces_et_entiers(self):
|
||||
self.assertEqual(parser.parse_number("55"), 55.0)
|
||||
self.assertEqual(parser.parse_number("3,5"), 3.5)
|
||||
self.assertEqual(parser.parse_int("12 unités"), 12)
|
||||
self.assertIsNone(parser.parse_int("non fourni"))
|
||||
|
||||
|
||||
class TestMarkdownTable(unittest.TestCase):
|
||||
def test_find_table_ignore_placeholders(self):
|
||||
md = (
|
||||
"| Typologie | Prix « à partir de » (USD) |\n"
|
||||
"|---|---|\n"
|
||||
"| Studio | USD 100,000 |\n"
|
||||
"| `{{typo_2_nom}}` | `{{typo_2_prix_usd}}` |\n"
|
||||
)
|
||||
typ = parser.parse_typologies(md)
|
||||
self.assertEqual(len(typ), 1)
|
||||
self.assertEqual(typ[0]["nom"], "Studio")
|
||||
self.assertEqual(typ[0]["prix_depuis_usd"], 100000.0)
|
||||
|
||||
|
||||
class TestParserP01Complete(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.p = parser.parse_projet(os.path.join(FIXTURES, "P01"))
|
||||
|
||||
def test_statut_disponible(self):
|
||||
self.assertEqual(self.p["statut"], "disponible")
|
||||
|
||||
def test_typologies_avec_prix(self):
|
||||
self.assertEqual(len(self.p["typologies"]), 3)
|
||||
for t in self.p["typologies"]:
|
||||
self.assertIsInstance(t["prix_depuis_usd"], float)
|
||||
self.assertEqual(self.p["typologies"][0]["prix_depuis_usd"], 150000.0)
|
||||
|
||||
def test_localisation_et_nom(self):
|
||||
self.assertIn("Test Province", self.p["localisation"])
|
||||
self.assertEqual(self.p["nom"], "Résidence Fixture Uno")
|
||||
|
||||
def test_services_inclus(self):
|
||||
self.assertIn("Conciergerie 24/7", self.p["inclus"])
|
||||
# amenities §3.2 complètent la liste
|
||||
self.assertIn("Piscine à débordement", self.p["inclus"])
|
||||
|
||||
def test_positionnement_fr(self):
|
||||
self.assertIn("fr", self.p.get("positionnement", {}))
|
||||
self.assertIn("balnéaire", self.p["positionnement"]["fr"])
|
||||
|
||||
def test_rendus_hero(self):
|
||||
heros = [r for r in self.p["rendus"] if r.get("hero")]
|
||||
self.assertEqual(len(heros), 1)
|
||||
self.assertEqual(heros[0]["fichier"], "hero_aerien.jpg")
|
||||
|
||||
def test_traçabilite_source(self):
|
||||
self.assertEqual(self.p["source"]["score_4big"], 96)
|
||||
self.assertIn("_META/version.json", "/".join(self.p["source"]["fichiers"]).replace(os.sep, "/"))
|
||||
|
||||
|
||||
class TestParserP02DefensiveDowngrade(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.p = parser.parse_projet(os.path.join(FIXTURES, "P02"))
|
||||
|
||||
def test_en_developpement(self):
|
||||
# Prix manquants → jamais « disponible » (anti-invention #6).
|
||||
self.assertEqual(self.p["statut"], "en_developpement")
|
||||
|
||||
def test_prix_none(self):
|
||||
for t in self.p["typologies"]:
|
||||
self.assertIsNone(t["prix_depuis_usd"])
|
||||
|
||||
|
||||
class TestSchemaConformance(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.master = parser.build_master(FIXTURES, generated_at=FIXED_TS)
|
||||
cls.schema = _schema("projets_master.schema.json")
|
||||
|
||||
def test_validateur_maison(self):
|
||||
errors = validator.validate(self.master, self.schema)
|
||||
self.assertEqual(errors, [], f"erreurs schéma : {errors}")
|
||||
|
||||
def test_deux_projets_tries(self):
|
||||
codes = [p["code"] for p in self.master["projets"]]
|
||||
self.assertEqual(codes, ["P01", "P02"])
|
||||
|
||||
def test_oracle_jsonschema_si_present(self):
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema non installé — oracle ignoré")
|
||||
jsonschema.validate(self.master, self.schema) # lève si non conforme
|
||||
|
||||
def test_negatif_disponible_sans_prix_rejete(self):
|
||||
# Un « disponible » avec prix null DOIT échouer (allOf if/then du schéma).
|
||||
bad = {
|
||||
"generated_at": FIXED_TS,
|
||||
"template_version": "1.0.0",
|
||||
"projets": [{
|
||||
"code": "P03", "nom": "X", "statut": "disponible",
|
||||
"localisation": "L",
|
||||
"typologies": [{"nom": "T", "prix_depuis_usd": None}],
|
||||
"inclus": [], "source": {"template_version": "1.0.0", "score_4big": 95},
|
||||
}],
|
||||
}
|
||||
self.assertTrue(validator.validate(bad, self.schema), "devrait être NON conforme")
|
||||
|
||||
def test_validateur_maison_accord_oracle_sur_negatif(self):
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema non installé")
|
||||
bad = {
|
||||
"generated_at": FIXED_TS, "template_version": "1.0.0",
|
||||
"projets": [{
|
||||
"code": "PX", "nom": "X", "statut": "disponible", "localisation": "L",
|
||||
"typologies": [], "inclus": [],
|
||||
"source": {"template_version": "1.0.0", "score_4big": 95},
|
||||
}],
|
||||
}
|
||||
maison = bool(validator.validate(bad, self.schema))
|
||||
oracle = False
|
||||
try:
|
||||
jsonschema.validate(bad, self.schema)
|
||||
except jsonschema.ValidationError:
|
||||
oracle = True
|
||||
self.assertEqual(maison, oracle)
|
||||
|
||||
|
||||
class TestVersionSchema(unittest.TestCase):
|
||||
def test_fixtures_version_json_conformes(self):
|
||||
schema = _schema("version.schema.json")
|
||||
for code in ("P01", "P02"):
|
||||
path = os.path.join(FIXTURES, code, "_META", "version.json")
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
doc = json.load(fh)
|
||||
self.assertEqual(validator.validate(doc, schema), [], f"{code} version.json")
|
||||
|
||||
|
||||
class TestGenerator(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.master = parser.build_master(FIXTURES, generated_at=FIXED_TS)
|
||||
cls.html = generator.render_site(cls.master)
|
||||
|
||||
def test_marque_luxury(self):
|
||||
# Contrainte #4 : tokens dark+doré + typographies.
|
||||
for token in ("#0a0a12", "#f0b429", "Fraunces", "Cormorant Garamond"):
|
||||
self.assertIn(token, self.html, f"token de marque absent : {token}")
|
||||
|
||||
def test_html_valide_minimal(self):
|
||||
self.assertIn("<!DOCTYPE html>", self.html)
|
||||
self.assertIn('lang="fr"', self.html)
|
||||
|
||||
def test_projet_disponible_affiche_prix(self):
|
||||
self.assertIn("USD 150 000", self.html) # prix P01 formaté
|
||||
self.assertIn("Studio", self.html)
|
||||
|
||||
def test_projet_incomplet_sans_prix_invente(self):
|
||||
# Le bloc P02 doit afficher « Prochainement » et AUCUN prix.
|
||||
self.assertIn("Prochainement", self.html)
|
||||
p02 = self._article(self.html, "P02")
|
||||
self.assertNotIn("USD", p02, "aucun prix ne doit apparaître pour P02")
|
||||
self.assertNotIn("prix-depuis", p02)
|
||||
|
||||
@staticmethod
|
||||
def _article(html: str, code: str) -> str:
|
||||
start = html.index(f'data-code="{code}"')
|
||||
end = html.index("</article>", start)
|
||||
return html[start:end]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user