Files
oto-enterprise-os-dtp/05_deliverables_mvp/seo/tests/test_seo.py
T
Claude Code DTP Worker 0d3b2420c3
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
[DTP-Worker 20260730_085719] Auto exec · session 20260730_085719
2026-07-30 09:12:19 +00:00

323 lines
14 KiB
Python

#!/usr/bin/env python3
"""Tests du generateur SEO trilingue (Sprint 6 · SEO).
Stdlib pur (`unittest`) -> aucune installation pip requise sur le runner Gitea.
La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente.
Axes centraux :
- VOLUME/COUVERTURE : 200+ mots-cles, 3 langues, min par langue (roadmap L60) ;
- ANTI-INVENTION (#6) : chaque mot-cle est sourcE ; aucun chiffre fabrique ;
schema.org n'expose un prix que pour un projet 'disponible' sourcE (USD #10) ;
- hreflang : alternates completes + x-default + canonical coherents.
Chaque invariant anti-invention a une injection negative dediee.
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
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 seolib import builder, deps, keywords, schemaorg # noqa: E402
import seo_gen as gen # noqa: E402
try:
import jsonschema # type: ignore
_HAS_JSONSCHEMA = True
except Exception: # pragma: no cover
_HAS_JSONSCHEMA = False
def _fresh():
spec = deps.load_spec()
master = deps.load_master()
bundle = builder.build_bundle(spec, master)
return spec, master, bundle
class BuildAndSchema(unittest.TestCase):
def setUp(self):
self.spec, self.master, self.bundle = _fresh()
def test_validate_passes_on_fixture(self):
self.assertEqual(gen._validate(self.spec, self.master, self.bundle), [])
def test_fixture_conforms_to_publiciste_contract(self):
self.assertEqual(deps.validate_master(self.master), [])
def test_output_schema_valid(self):
schema = deps.load_json(deps.SCHEMA_PATH)
self.assertEqual(deps.validate(self.bundle, schema), [])
@unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent")
def test_output_schema_valid_oracle(self):
schema = deps.load_json(deps.SCHEMA_PATH)
jsonschema.validate(self.bundle, schema) # ne leve pas => OK
def test_deterministic(self):
b2 = builder.build_bundle(self.spec, self.master)
self.assertEqual(json.dumps(self.bundle, sort_keys=True),
json.dumps(b2, sort_keys=True))
def test_cli_build_and_validate(self):
env = dict(os.environ)
out = os.path.join(_HERE, "_tmp_out")
r = subprocess.run([sys.executable, os.path.join(_MODULE, "seo_gen.py"),
"build", "-o", out], capture_output=True, text=True, env=env)
self.assertEqual(r.returncode, 0, r.stderr)
for f in ("seo_keywords.json", "seo_schema_org.json", "seo_hreflang.json",
"MANIFEST.json"):
self.assertTrue(os.path.exists(os.path.join(out, f)))
r2 = subprocess.run([sys.executable, os.path.join(_MODULE, "seo_gen.py"),
"validate"], capture_output=True, text=True, env=env)
self.assertEqual(r2.returncode, 0, r2.stderr)
class Keywords(unittest.TestCase):
def setUp(self):
self.spec, self.master, self.bundle = _fresh()
self.kws = self.bundle["keywords"]
def test_volume_target(self):
self.assertGreaterEqual(len(self.kws), self.spec["targets"]["min_keywords_total"])
def test_three_langs_min_each(self):
for lang in self.spec["site"]["langs"]:
n = sum(1 for k in self.kws if k["lang"] == lang)
self.assertGreaterEqual(n, self.spec["targets"]["min_keywords_per_lang"], lang)
def test_unique_term_lang(self):
keys = [(k["term"], k["lang"]) for k in self.kws]
self.assertEqual(len(keys), len(set(keys)))
def test_every_keyword_sourced_and_resolvable(self):
by_code = {p["code"] for p in self.master["projets"]}
for k in self.kws:
self.assertTrue(k["sources"], k["term"])
for s in k["sources"]:
if s.startswith("projet:"):
self.assertIn(s.split(".")[0].split(":")[1], by_code)
else:
self.assertTrue(s.startswith("lexicon:"), s)
def test_no_fabricated_digit_in_terms(self):
by_code = {p["code"]: p for p in self.master["projets"]}
for k in self.kws:
td = deps.digits(k["term"])
if not td:
continue
allowed = set()
if k["projet"] in by_code:
p = by_code[k["projet"]]
allowed = deps.digits(p["nom"] + " " + p["localisation"])
self.assertTrue(td <= allowed, k["term"])
def test_numeric_project_name_survives(self):
# P09 « 1069 Crisfer » : les chiffres du nom documente sont autorises.
terms = {k["term"] for k in self.kws if k["projet"] == "P09"}
self.assertIn("1069 Crisfer", terms)
def test_confotur_global_keyword_present_when_documented(self):
fr = {k["term"] for k in self.kws if k["lang"] == "fr" and k["category"] == "confotur"}
self.assertTrue(any("CONFOTUR" in t for t in fr))
def test_confotur_absent_when_not_documented(self):
master = copy.deepcopy(self.master)
for p in master["projets"]:
p["inclus"] = []
kws = keywords.generate(self.spec, master)
self.assertFalse([k for k in kws if k["category"] == "confotur"])
def test_negative_fabricated_digit_flagged(self):
bundle = copy.deepcopy(self.bundle)
bundle["keywords"][0]["term"] = bundle["keywords"][0]["term"] + " 250000"
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("invention interdite" in e for e in errs), errs)
def test_negative_unresolvable_source_flagged(self):
bundle = copy.deepcopy(self.bundle)
bundle["keywords"][0]["sources"] = ["projet:P99.nom"]
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("non resoluble" in e for e in errs), errs)
def test_negative_duplicate_flagged(self):
bundle = copy.deepcopy(self.bundle)
bundle["keywords"].append(copy.deepcopy(bundle["keywords"][0]))
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("duplique" in e for e in errs), errs)
def test_negative_below_target_flagged(self):
bundle = copy.deepcopy(self.bundle)
bundle["keywords"] = bundle["keywords"][:10]
bundle["manifest"]["counts"]["keywords_total"] = 10
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("cible" in e for e in errs), errs)
class SchemaOrg(unittest.TestCase):
def setUp(self):
self.spec, self.master, self.bundle = _fresh()
self.graph = self.bundle["schema_org"]["@graph"]
def test_context(self):
self.assertEqual(self.bundle["schema_org"]["@context"], "https://schema.org")
def test_one_org_and_one_listing_per_project(self):
orgs = [n for n in self.graph if n["@type"] == "Organization"]
listings = [n for n in self.graph if n["@type"] == "Residence"]
self.assertEqual(len(orgs), 1)
self.assertEqual(len(listings), len(self.master["projets"]))
names = {n["name"] for n in listings}
self.assertEqual(names, {p["nom"] for p in self.master["projets"]})
def test_locality_and_url_from_source(self):
by_name = {n["name"]: n for n in self.graph if n["@type"] == "Residence"}
for p in self.master["projets"]:
node = by_name[p["nom"]]
self.assertEqual(node["address"]["addressLocality"], p["localisation"])
self.assertTrue(node["url"].startswith(self.spec["site"]["base_url"]))
def test_no_offers_for_en_developpement(self):
offers = sum(len(n.get("offers", [])) for n in self.graph)
self.assertEqual(offers, 0)
@staticmethod
def _master_with_dispo(price=199000.0):
"""Rend P02 'disponible' avec une typologie a prix sourcE (reste conforme
au contrat Publiciste : disponible => prix numerique)."""
spec = deps.load_spec()
master = deps.load_master()
p = next(x for x in master["projets"] if x["code"] == "P02")
p["statut"] = "disponible"
p["typologies"] = [{"nom": "Studio", "prix_depuis_usd": price}]
return spec, master
def test_disponible_with_sourced_price_emits_usd_offer(self):
spec, master = self._master_with_dispo()
bundle = builder.build_bundle(spec, master)
self.assertEqual(gen._validate(spec, master, bundle), [])
node = [n for n in bundle["schema_org"]["@graph"]
if n.get("name") == "Coral del Sur"][0]
self.assertEqual(len(node["offers"]), 1)
self.assertEqual(node["offers"][0]["priceCurrency"], "USD")
self.assertEqual(node["offers"][0]["price"], 199000.0)
def test_disponible_without_price_emits_no_offer(self):
# Cas de bordure au niveau du generateur schema.org (le contrat amont
# interdit deja disponible sans prix) : aucune offre fabriquee.
projet = {"code": "P02", "nom": "X", "statut": "disponible",
"localisation": "Y", "typologies": [{"nom": "S", "prix_depuis_usd": None}]}
self.assertEqual(schemaorg._offers_for(projet), [])
def test_negative_offer_on_en_developpement_flagged(self):
bundle = copy.deepcopy(self.bundle)
node = [n for n in bundle["schema_org"]["@graph"] if n["@type"] == "Residence"][0]
node["offers"] = [{"@type": "Offer", "price": 100000, "priceCurrency": "USD",
"availability": "https://schema.org/InStock"}]
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("offre interdite" in e or "presence d'offre" in e for e in errs), errs)
def test_negative_wrong_currency_flagged(self):
spec, master = self._master_with_dispo()
bundle = builder.build_bundle(spec, master)
node = [n for n in bundle["schema_org"]["@graph"]
if n.get("name") == "Coral del Sur"][0]
node["offers"][0]["priceCurrency"] = "EUR"
errs = gen._validate(spec, master, bundle)
self.assertTrue(any("devise" in e for e in errs), errs)
def test_negative_fabricated_price_flagged(self):
spec, master = self._master_with_dispo()
bundle = builder.build_bundle(spec, master)
node = [n for n in bundle["schema_org"]["@graph"]
if n.get("name") == "Coral del Sur"][0]
node["offers"][0]["price"] = 999999 # non sourcE
errs = gen._validate(spec, master, bundle)
self.assertTrue(any("non sourcE" in e for e in errs), errs)
class Hreflang(unittest.TestCase):
def setUp(self):
self.spec, self.master, self.bundle = _fresh()
self.href = self.bundle["hreflang"]
def test_one_page_per_project_plus_home(self):
names = {pg["page"] for pg in self.href["pages"]}
self.assertEqual(names, {"home"} | {p["code"] for p in self.master["projets"]})
def test_alternates_complete_and_xdefault(self):
langs = self.spec["site"]["langs"]
default = self.spec["site"]["default_lang"]
for pg in self.href["pages"]:
alts = {a["hreflang"]: a["href"] for a in pg["alternates"]}
self.assertEqual(set(alts), set(langs) | {"x-default"})
self.assertEqual(alts["x-default"], alts[default])
self.assertEqual(pg["canonical"], alts[default])
def test_all_urls_under_base(self):
base = self.spec["site"]["base_url"].rstrip("/")
for pg in self.href["pages"]:
self.assertTrue(pg["canonical"].startswith(base))
for a in pg["alternates"]:
self.assertTrue(a["href"].startswith(base))
def test_lang_prefix_applied(self):
home = [pg for pg in self.href["pages"] if pg["page"] == "home"][0]
alts = {a["hreflang"]: a["href"] for a in home["alternates"]}
self.assertEqual(alts["fr"], "https://vente.otov7.com/")
self.assertEqual(alts["en"], "https://vente.otov7.com/en/")
self.assertEqual(alts["es"], "https://vente.otov7.com/es/")
def test_negative_broken_xdefault_flagged(self):
bundle = copy.deepcopy(self.bundle)
for a in bundle["hreflang"]["pages"][0]["alternates"]:
if a["hreflang"] == "x-default":
a["href"] = "https://vente.otov7.com/zz/"
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("x-default" in e for e in errs), errs)
def test_negative_missing_alternate_flagged(self):
bundle = copy.deepcopy(self.bundle)
bundle["hreflang"]["pages"][0]["alternates"] = [
a for a in bundle["hreflang"]["pages"][0]["alternates"] if a["hreflang"] != "es"
]
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("alternates" in e for e in errs), errs)
class Manifest(unittest.TestCase):
def setUp(self):
self.spec, self.master, self.bundle = _fresh()
def test_counts_coherent(self):
c = self.bundle["manifest"]["counts"]
self.assertEqual(c["keywords_total"], len(self.bundle["keywords"]))
self.assertEqual(c["projects"], len(self.master["projets"]))
self.assertEqual(c["hreflang_pages"], len(self.bundle["hreflang"]["pages"]))
self.assertEqual(sum(c["keywords_per_lang"].values()), c["keywords_total"])
def test_negative_tampered_count_flagged(self):
bundle = copy.deepcopy(self.bundle)
bundle["manifest"]["counts"]["keywords_total"] = 7
errs = gen._validate(self.spec, self.master, bundle)
self.assertTrue(any("manifest.counts" in e for e in errs), errs)
def test_slugify(self):
self.assertEqual(deps.slugify("Aqua Terra Las Terrenas"), "aqua-terra-las-terrenas")
self.assertEqual(deps.slugify("1069 Crisfer"), "1069-crisfer")
self.assertEqual(deps.slugify("Xamana Cantiles"), "xamana-cantiles")
if __name__ == "__main__":
unittest.main(verbosity=2)