#!/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_prix_espace_insecable_milliers(self): # Les outils d'édition FR/ES (Word, InDesign) insèrent l'ESPACE INSÉCABLE # U+00A0 comme séparateur de milliers dans les prix réels des data_room. # `lib/parser.py` la capte via une char-class Unicode-whitespace (`\s`). # Verrou : ce test mord si l'on réduit cette capture à de l'ASCII pur # (retrait de TOUTE prise en charge whitespace-Unicode → seul le 1er # chiffre survit, ex. 8.0 au lieu de 8 850 000) — même racine « détecter # la forme, pas une graphie » que l'arc de fixes audit_4big. # NBSP en escape explicite \u00a0 pour garder l'intention lisible (0 octet invisible). nb = "\u00a0" self.assertEqual(parser.parse_price(f"DOP 8{nb}850{nb}000"), 8850000.0) self.assertEqual(parser.parse_price(f"USD 1{nb}250{nb}000,50"), 1250000.50) self.assertEqual(parser.parse_number(f"1{nb}250"), 1250.0) self.assertEqual(parser.parse_int(f"12{nb}unités"), 12) 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("", 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) def test_hero_media_url_percent_encode(self): # Un nom de rendu réel peut contenir apostrophe/espace/accents (FR/ES) : # l'URL doit être percent-encodée pour rester valide DANS le url('…') CSS. # Sinon l'apostrophe, une fois l'attribut style HTML-décodé, referme la # chaîne CSS et casse le background-image (régression silencieuse). projet = { "code": "P07", "nom": "Aqua Terra", "statut": "disponible", "rendus": [{"fichier": "Vue d'ensemble.png", "hero": True}], } media = generator._hero_media(projet) self.assertIn("Vue%20d%27ensemble.png", media) # L'apostrophe brute ne doit JAMAIS survivre dans le url('…'). import html as _h self.assertNotIn("Vue d'ensemble", _h.unescape(media)) def test_hero_media_code_segment_percent_encoded(self): # Le segment `code` est l'AUTRE segment dynamique de la même url('…'). # Un code contenant un caractère spécial (théorique : codes réels = P01..P09) # doit lui aussi être percent-encodé — quote() est le SEUL encodeur correct # pour un segment d'URL. Sinon _esc(html) laisserait l'apostrophe, une fois # l'attribut style HTML-décodé, refermer la chaîne CSS (même bug que fichier). projet = { "code": "P'X", "nom": "Théorique", "statut": "disponible", "rendus": [{"fichier": "hero.jpg", "hero": True}], } media = generator._hero_media(projet) self.assertIn("/static/projets/p%27x/hero.jpg", media) import html as _h # Après HTML-décodage de l'attribut style, aucune apostrophe brute ne survit # dans le segment de chemin (elle refermerait le url('…')). self.assertNotIn("projets/p'x", _h.unescape(media)) @staticmethod def _article(html: str, code: str) -> str: start = html.index(f'data-code="{code}"') end = html.index("", start) return html[start:end] if __name__ == "__main__": unittest.main(verbosity=2)