Files
oto-enterprise-os-dtp/05_deliverables_mvp/publiciste/tests/test_publiciste.py
T
Claude Code DTP Worker 5af86e3518 [DTP-Worker 20260812_073055] hardening correctness · rendu HTML publiciste — percent-encode le SEUL segment d'URL restant (code) dans le url('…') CSS
Durcissement d'uniformité (pas bug déclenchable aujourd'hui) qui COMPLÈTE le fix
correctness de la session 070054 (aeb30b8). Ce fix-là a percent-encodé
hero['fichier'] dans src=f"/static/projets/{code}/{quote(fichier)}", mais dans la
MÊME url('…') CSS il restait un 2ᵉ segment dynamique — `code` — interpolé brut puis
html-échappé via _esc(src). C'est exactement l'anti-pattern identifié par 070054
(html.escape = mauvais encodeur pour un segment d'URL), appliqué de façon incomplète.

Fix : src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}" — quote() est
le SEUL encodeur correct pour un segment d'URL ; le CONTEXTE, pas la donnée courante,
dicte l'encodeur. Zéro dérive : codes réels = P01..P09 (§Projets), quote("p01")=="p01"
→ byte-repro inchangé. Cas frère exact de 060044/criteria.py (« durcir le SEUL membre
non-durci d'une famille ») : ici le SEUL segment d'URL non-encodé de la même url().

Test à dents (test_hero_media_code_segment_percent_encoded) : code théorique "P'X" →
prouvé FAIL sans quote(code), PASS avec. Suite publiciste 25→26, matrice 630/613→631/614.

Défaut d'abord : classe encodage-contextuel balayée sur tous les modules HTML/CSS/URL ;
2 candidats Explore réfutés non-défauts (chat mount_html = persona forcée Amélie par
invariant 7 + portail_key enum ; code segment = théorique mais durci par uniformité).
Cascade régénérée dans l'ordre (quality_report en DERNIER) · 3 fiches compteurs réalignées.
run_ci 33/0/0 · NFC-clean · zéro code moteur V18 (bloqué D-06 #6) · zéro gate (#5) · zéro invention (#6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 07:41:05 +00:00

271 lines
11 KiB
Python

#!/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("<!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)
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("</article>", start)
return html[start:end]
if __name__ == "__main__":
unittest.main(verbosity=2)