#!/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)