[DTP-Worker] Sprint 6 · Générateur Chat OTOIA embarqué par portail (Custom Block natif) (ERPNext Backend · roadmap L63)
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 / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
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 / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
Carrier natif ERPNext v15 (Custom Block) + config runtime par portail. Ancrage RBAC : roles_allowed/knowledge_scope = surface exacte du portail, synchronisés avec les Has Role des Workspaces. Endpoint OTOIA null (a_confirmer). Persona Amélie + capabilities + langues FR/EN/ES sourcés (anti-invention #6). 14 invariants · 31 tests · régression 408 tests verts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
"""Tests du générateur Chat OTOIA embarqué par portail (Sprint 6 · ERPNext Backend).
|
||||
|
||||
Stdlib pur (unittest) — aucune dépendance pip. Couvre :
|
||||
- conformité au schéma + les 14 invariants du CLI (chemin nominal) ;
|
||||
- la cross-cohérence RBAC ↔ portails ↔ Workspaces ↔ seo ;
|
||||
- l'anti-invention (#6) par injections négatives (endpoint fabriqué, portée
|
||||
élargie, persona altérée, capability ajoutée, langue inventée, chiffre/URL
|
||||
dans le HTML de montage, rôles désynchronisés des Workspaces).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.normpath(os.path.join(_HERE, ".."))
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
import chat_otoia_gen as gen # noqa: E402
|
||||
from chatlib import builder, deps, frappe, knowledge # noqa: E402
|
||||
|
||||
|
||||
def build():
|
||||
return gen._build_bundle()
|
||||
|
||||
|
||||
class TestNominal(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bundle = build()
|
||||
|
||||
def test_build_valide(self):
|
||||
self.assertEqual(gen._validate_bundle(self.bundle), [])
|
||||
|
||||
def test_schema_seul(self):
|
||||
schema = deps.load_json(deps.SCHEMA_PATH)
|
||||
self.assertEqual(deps.validate(self.bundle, schema), [])
|
||||
|
||||
def test_cinq_portails_metier(self):
|
||||
contract = deps.load_contract()
|
||||
keys = [m["portail"] for m in self.bundle["chat_mount"]]
|
||||
self.assertEqual(set(keys), set(contract["portails_business"]))
|
||||
self.assertEqual(len(keys), 5)
|
||||
self.assertNotIn("plateforme", keys) # console technique exclue
|
||||
|
||||
def test_un_block_un_mount_par_portail(self):
|
||||
self.assertEqual(len(self.bundle["custom_block"]), 5)
|
||||
self.assertEqual(len(self.bundle["chat_mount"]), 5)
|
||||
names = [cb["block_name"] for cb in self.bundle["custom_block"]]
|
||||
self.assertEqual(len(names), len(set(names)))
|
||||
|
||||
def test_carrier_natif_custom_block(self):
|
||||
for cb in self.bundle["custom_block"]:
|
||||
self.assertEqual(cb["doctype"], "Custom Block")
|
||||
|
||||
def test_ordre_deterministe(self):
|
||||
contract = deps.load_contract()
|
||||
spec = deps.load_spec()
|
||||
pspec = deps.load_portails_spec()
|
||||
langs, dflt = deps.load_langs()
|
||||
b2 = builder.build_bundle(contract, spec, pspec, langs, dflt)
|
||||
self.assertEqual(
|
||||
json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
|
||||
json.dumps(b2, sort_keys=True, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
class TestAncrageRBAC(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bundle = build()
|
||||
self.contract = deps.load_contract()
|
||||
|
||||
def test_knowledge_scope_egale_surface_rbac(self):
|
||||
for m in self.bundle["chat_mount"]:
|
||||
attendu = knowledge.knowledge_scope_for(self.contract, m["portail"])
|
||||
self.assertEqual(m["knowledge_scope"], attendu)
|
||||
self.assertGreater(len(attendu), 0)
|
||||
|
||||
def test_roles_allowed_egale_roles_portail(self):
|
||||
for m in self.bundle["chat_mount"]:
|
||||
attendu = knowledge.roles_for(self.contract, m["portail"])
|
||||
self.assertEqual(m["roles_allowed"], attendu)
|
||||
|
||||
def test_roles_synchronises_avec_workspaces(self):
|
||||
ws_roles = gen._workspace_roles(deps.load_workspaces())
|
||||
for m in self.bundle["chat_mount"]:
|
||||
self.assertIn(m["workspace"], ws_roles)
|
||||
self.assertEqual(m["roles_allowed"], ws_roles[m["workspace"]])
|
||||
|
||||
def test_44_roles_couverts(self):
|
||||
# même total restreint que les Workspaces (hand-off portails).
|
||||
total = sum(len(m["roles_allowed"]) for m in self.bundle["chat_mount"])
|
||||
self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])
|
||||
self.assertEqual(total, 44)
|
||||
|
||||
|
||||
class TestPersonaCapacitesLangues(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bundle = build()
|
||||
|
||||
def test_persona_amelie_sourcee(self):
|
||||
spec = deps.load_spec()
|
||||
self.assertEqual(spec["persona"]["nom"], "Amélie")
|
||||
self.assertEqual(spec["persona"]["voix"], "multilingual_v2")
|
||||
self.assertIn("CLAUDE.md", spec["persona"]["source"])
|
||||
for m in self.bundle["chat_mount"]:
|
||||
self.assertEqual(m["persona"], {"nom": "Amélie", "voix": "multilingual_v2"})
|
||||
|
||||
def test_capabilities_sourcees_sans_ajout(self):
|
||||
spec = deps.load_spec()
|
||||
caps = [c["module"] for c in spec["capabilities"]]
|
||||
self.assertEqual(caps, ["aec.py", "knowledge.py", "prompt_engine.py", "chat.py"])
|
||||
for c in spec["capabilities"]:
|
||||
self.assertIn("CLAUDE.md", c["source"])
|
||||
for m in self.bundle["chat_mount"]:
|
||||
self.assertEqual(m["capabilities"], caps)
|
||||
|
||||
def test_langues_derivees_du_seo(self):
|
||||
langs, dflt = deps.load_langs()
|
||||
self.assertEqual(langs, ["fr", "en", "es"])
|
||||
for m in self.bundle["chat_mount"]:
|
||||
self.assertEqual(m["langues"], langs)
|
||||
self.assertEqual(m["lang_defaut"], dflt)
|
||||
|
||||
|
||||
class TestAntiInvention(unittest.TestCase):
|
||||
"""#6 : endpoint jamais fabriqué, aucun chiffre/URL, aucune portée élargie."""
|
||||
|
||||
def setUp(self):
|
||||
self.bundle = build()
|
||||
|
||||
def test_endpoint_null_partout(self):
|
||||
for m in self.bundle["chat_mount"]:
|
||||
self.assertIsNone(m["endpoint"])
|
||||
self.assertEqual(self.bundle["manifest"]["endpoint_statut"], "a_confirmer")
|
||||
self.assertIsNone(deps.load_spec()["endpoint"]["valeur"])
|
||||
|
||||
def test_html_sans_url_ni_chiffre(self):
|
||||
for cb in self.bundle["custom_block"]:
|
||||
html = cb["html"]
|
||||
self.assertNotIn("http://", html)
|
||||
self.assertNotIn("https://", html)
|
||||
self.assertFalse(any(ch.isdigit() for ch in html))
|
||||
|
||||
def test_html_id_et_block_name_deterministes(self):
|
||||
labels = {p["key"]: p["label"] for p in deps.load_portails_spec()["portails"]}
|
||||
for m in self.bundle["chat_mount"]:
|
||||
self.assertEqual(m["html_id"], f"otoia-chat-{m['portail']}")
|
||||
self.assertEqual(m["custom_block"], f"OTOIA Chat · {labels[m['portail']]}")
|
||||
|
||||
def test_brand_verbatim(self):
|
||||
b = self.bundle["manifest"]["brand"]
|
||||
self.assertEqual(b["fond"]["valeur"], "#0a0a12")
|
||||
self.assertEqual(b["accent"]["valeur"], "#f0b429")
|
||||
polices = [p["valeur"] for p in b["polices"]]
|
||||
self.assertIn("Fraunces", polices)
|
||||
self.assertIn("Cormorant Garamond", polices)
|
||||
|
||||
# --- injections négatives : chaque altération DOIT être rejetée ---
|
||||
|
||||
def _expect_error(self, mutate):
|
||||
bundle = copy.deepcopy(self.bundle)
|
||||
mutate(bundle)
|
||||
errors = gen._validate_bundle(bundle)
|
||||
self.assertNotEqual(errors, [], "une altération n'a pas été détectée")
|
||||
|
||||
def test_rejet_endpoint_fabrique(self):
|
||||
self._expect_error(lambda b: b["chat_mount"][0].__setitem__("endpoint", "https://otoia.local"))
|
||||
|
||||
def test_rejet_portee_elargie(self):
|
||||
# ajoute un DocType hors surface RBAC du portail.
|
||||
self._expect_error(lambda b: b["chat_mount"][0]["knowledge_scope"].append("Salary Slip"))
|
||||
|
||||
def test_rejet_portee_reduite(self):
|
||||
self._expect_error(lambda b: b["chat_mount"][0]["knowledge_scope"].pop())
|
||||
|
||||
def test_rejet_role_desynchronise(self):
|
||||
self._expect_error(lambda b: b["chat_mount"][0]["roles_allowed"].append("OTO Fantome"))
|
||||
|
||||
def test_rejet_persona_alteree(self):
|
||||
self._expect_error(lambda b: b["chat_mount"][0].__setitem__("persona", {"nom": "Bob", "voix": "x"}))
|
||||
|
||||
def test_rejet_capability_ajoutee(self):
|
||||
self._expect_error(lambda b: b["chat_mount"][0]["capabilities"].append("exfil.py"))
|
||||
|
||||
def test_rejet_langue_inventee(self):
|
||||
self._expect_error(lambda b: b["chat_mount"][0]["langues"].append("de"))
|
||||
|
||||
def test_rejet_html_avec_chiffre(self):
|
||||
def m(b):
|
||||
cb = b["custom_block"][0]
|
||||
cb["html"] = cb["html"].replace("></div>", " data-x=\"9\"></div>")
|
||||
self._expect_error(m)
|
||||
|
||||
def test_rejet_html_avec_url(self):
|
||||
def m(b):
|
||||
cb = b["custom_block"][0]
|
||||
cb["html"] = cb["html"].replace("></div>", " data-src=\"https://x\"></div>")
|
||||
self._expect_error(m)
|
||||
|
||||
def test_rejet_plateforme_incluse(self):
|
||||
def m(b):
|
||||
extra = copy.deepcopy(b["chat_mount"][0])
|
||||
extra["portail"] = "plateforme"
|
||||
b["chat_mount"].append(extra)
|
||||
self._expect_error(m)
|
||||
|
||||
def test_rejet_count_incoherent(self):
|
||||
self._expect_error(lambda b: b["manifest"]["counts"].__setitem__("roles_couverts", 999))
|
||||
|
||||
|
||||
class TestManifest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bundle = build()
|
||||
|
||||
def test_counts_coherents(self):
|
||||
c = self.bundle["manifest"]["counts"]
|
||||
self.assertEqual(c["portails"], 5)
|
||||
self.assertEqual(c["custom_blocks"], 5)
|
||||
self.assertEqual(c["mounts"], 5)
|
||||
union = sorted({dt for m in self.bundle["chat_mount"] for dt in m["knowledge_scope"]})
|
||||
self.assertEqual(c["knowledge_doctypes_uniques"], len(union))
|
||||
|
||||
def test_carrier_natif_documente(self):
|
||||
car = self.bundle["manifest"]["carrier"]
|
||||
self.assertEqual(car["doctype"], "Custom Block")
|
||||
self.assertEqual(car["workspace_content_block_type"], "custom_block")
|
||||
|
||||
def test_handoff_non_vide(self):
|
||||
self.assertTrue(self.bundle["manifest"]["handoff_vps"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user