[DTP-Worker] Sprint 4 · Générateur Workspaces ERPNext 5 portails rôle (Frontend Console · roadmap L49)

Livre le dernier volet ouvert de Sprint 4 : Frontend Console « 5 portails
(Ventes/Construction/Achat/Compta/Direction) ». Contrainte #1 (ERPNext natif) :
le portail de landing par rôle EST le DocType Workspace v15 → 5 Workspaces natifs.

Anti-invention (#6) : rôles et DocTypes dérivés du contrat rbac_50_roles.json ;
chaque lien/raccourci vise un DocType présent dans les permissions_cibles du
portail (droit prouvé) ; couverture exhaustive ; flag custom issu du contrat ;
tokens de marque repris verbatim de CLAUDE.md #4. CLI + 12 invariants + 19 tests
(4 négatifs). Régression 260 tests verts. Hand-off VPS #8 documenté.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 07:38:44 +00:00
parent 7117394651
commit c0d2e21ef3
14 changed files with 2891 additions and 1 deletions
@@ -0,0 +1,248 @@
"""Tests du generateur de Workspaces (stdlib `unittest`, zero pip).
Reutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
s'il est installe, l'oracle `jsonschema` en plus. Invariants couverts :
1. Le bundle valide contre workspace.schema.json (maison + oracle).
2. Exactement les 5 portails METIER du contrat (plateforme exclue), ordre spec.
3. ANTI-INVENTION (#6) : tout lien/raccourci vise un DocType autorise (permissions_cibles).
4. Couverture EXHAUSTIVE + sans doublon des DocTypes autorises par les cartes.
5. Restriction de visibilite = tous les roles du portail (contrat), tries.
6. Table Links coherente (Card Break -> link_count == liens reels).
7. Raccourcis : couleur = accent, ordre = spec ; compteurs desk (aucun chiffre stocke).
8. Workspace.module reste None (a_confirmer, hand-off VPS #8).
9. `content` (JSON de blocs) coherent avec raccourcis + cartes.
10. Flag `custom` issu du CONTRAT (source unique).
11. Tokens de marque = CLAUDE.md #4 (jamais fabriques).
12. Determinisme : deux builds bit-a-bit identiques.
13. NEGATIFS : les invariants mordent (invention, oubli de couverture, mauvais portail).
"""
import copy
import importlib.util
import json
import os
import sys
import tempfile
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # portails/
_FRONTEND = os.path.normpath(os.path.join(_GEN, "..")) # frontend/
_DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, "..")) # 05_deliverables_mvp/
_RBAC = os.path.join(_DELIVERABLES, "rbac")
sys.path.insert(0, _GEN)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from wslib import builder, frappe # noqa: E402
from lib import validator as maison # noqa: E402
_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
_SPEC = os.path.join(_GEN, "portails_spec.json")
_SCHEMA = os.path.join(_GEN, "workspace.schema.json")
def _load(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _load_cli():
spec = importlib.util.spec_from_file_location(
"workspaces_gen", os.path.join(_GEN, "workspaces_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class WorkspacesGenTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.contract = _load(_CONTRACT)
cls.spec = _load(_SPEC)
cls.schema = _load(_SCHEMA)
cls.bundle = builder.build_bundle(cls.contract, cls.spec)
cls.workspaces = cls.bundle["workspace"]
cls.dt_map = builder.doctypes_by_portail(cls.contract)
cls.roles_map = builder.roles_by_portail(cls.contract)
cls.flags = builder.custom_flags(cls.contract)
cls.cli = _load_cli()
# 1 --------------------------------------------------------------------
def test_valide_schema_maison(self):
self.assertEqual(maison.validate(self.bundle, self.schema), [])
def test_valide_schema_oracle(self):
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema absent (oracle facultatif)")
jsonschema.validate(self.bundle, self.schema)
def test_validate_bundle_reel_ok(self):
# schema + 12 invariants du CLI, sur le bundle reel (disque).
self.assertEqual(self.cli._validate_bundle(self.bundle), [])
# 2 --------------------------------------------------------------------
def test_cinq_portails_metier_ordonnes(self):
keys = [w["label"] for w in self.workspaces]
self.assertEqual(len(keys), 5)
spec_keys = [p["key"] for p in self.spec["portails"]]
self.assertEqual(set(spec_keys), set(self.contract["portails_business"]))
self.assertNotIn("plateforme", spec_keys) # console technique exclue
# 3 --------------------------------------------------------------------
def test_anti_invention_liens_autorises(self):
for p, w in zip(self.spec["portails"], self.workspaces):
authorized = self.dt_map[p["key"]]
for row in w["links"]:
if row["type"] == "Link":
self.assertIn(row["link_to"], authorized)
for s in w["shortcuts"]:
self.assertIn(s["link_to"], authorized)
# 4 --------------------------------------------------------------------
def test_couverture_exhaustive(self):
for p in self.spec["portails"]:
dts = [dt for c in p["cartes"] for dt in c["doctypes"]]
self.assertEqual(len(dts), len(set(dts)), f"{p['key']}: doublon")
self.assertEqual(set(dts), self.dt_map[p["key"]], f"{p['key']}: couverture")
# 5 --------------------------------------------------------------------
def test_roles_restriction(self):
total = 0
for p, w in zip(self.spec["portails"], self.workspaces):
roles = [r["role"] for r in w["roles"]]
self.assertEqual(roles, sorted(roles))
self.assertEqual(set(roles), set(self.roles_map[p["key"]]))
self.assertTrue(all(r["parenttype"] == "Workspace" for r in w["roles"]))
total += len(roles)
self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])
# 6 --------------------------------------------------------------------
def test_links_card_break_coherent(self):
for w in self.workspaces:
rows = w["links"]
self.assertEqual(rows[0]["type"], "Card Break")
i = 0
while i < len(rows):
self.assertEqual(rows[i]["type"], "Card Break")
declared = rows[i]["link_count"]
j = i + 1
seen = 0
while j < len(rows) and rows[j]["type"] == "Link":
seen += 1
j += 1
self.assertEqual(seen, declared)
i = j
# 7 --------------------------------------------------------------------
def test_shortcuts_accent_et_ordre(self):
for p, w in zip(self.spec["portails"], self.workspaces):
self.assertEqual([s["link_to"] for s in w["shortcuts"]], p["raccourcis"])
for s in w["shortcuts"]:
self.assertEqual(s["color"], p["accent_desk"])
self.assertEqual(s["type"], "DocType")
self.assertEqual(s["doc_view"], "List")
# Aucun chiffre stocke (compteur calcule LIVE par le desk).
self.assertNotIn("stats_filter", s)
# 8 --------------------------------------------------------------------
def test_module_none(self):
for w in self.workspaces:
self.assertIsNone(w["module"])
self.assertEqual(w["public"], 1)
# 9 --------------------------------------------------------------------
def test_content_coherent(self):
for p, w in zip(self.spec["portails"], self.workspaces):
blocks = json.loads(w["content"])
headers = [b for b in blocks if b["type"] == "header"]
self.assertEqual(len(headers), 1)
self.assertEqual(headers[0]["data"]["text"], p["label"])
self.assertEqual(
[b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"],
p["raccourcis"],
)
self.assertEqual(
[b["data"]["card_name"] for b in blocks if b["type"] == "card"],
[c["titre"] for c in p["cartes"]],
)
# 10 -------------------------------------------------------------------
def test_flag_custom_du_contrat(self):
for m in self.bundle["manifest"]["workspaces"]:
p = next(x for x in self.spec["portails"] if x["key"] == m["portail"])
expected = sorted({
dt for c in p["cartes"] for dt in c["doctypes"] if self.flags.get(dt)
})
self.assertEqual(m["doctypes_custom"], expected)
self.assertEqual(
self.bundle["manifest"]["doctypes_custom_a_creer"],
sorted({"CONFOTUR Application", "Faisabilité", "Publiciste Log"}),
)
# 11 -------------------------------------------------------------------
def test_marque_claude_md(self):
brand = self.bundle["manifest"]["brand"]
self.assertEqual(brand["accent"]["valeur"], "#f0b429")
self.assertEqual(brand["fond"]["valeur"], "#0a0a12")
self.assertTrue(brand["accent"]["source"])
# 12 -------------------------------------------------------------------
def test_determinisme(self):
b1 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
b2 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
self.assertEqual(json.dumps(b1, sort_keys=True), json.dumps(b2, sort_keys=True))
# 13 · NEGATIFS ---------------------------------------------------------
def _validate_with_spec(self, mutated_spec):
"""Ecrit une spec mutee dans un tmp, y pointe le CLI, revalide."""
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, "spec.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(mutated_spec, fh, ensure_ascii=False)
orig = self.cli._SPEC_PATH
self.cli._SPEC_PATH = path
try:
bundle = self.cli._build_bundle()
return self.cli._validate_bundle(bundle)
finally:
self.cli._SPEC_PATH = orig
def test_negatif_doctype_invente(self):
bad = copy.deepcopy(self.spec)
bad["portails"][0]["cartes"][0]["doctypes"].append("DocType Bidon Invente")
errs = self._validate_with_spec(bad)
self.assertTrue(any("non autorise" in e for e in errs), errs)
def test_negatif_couverture_incomplete(self):
bad = copy.deepcopy(self.spec)
# retire un DocType d'une carte -> couverture non exhaustive.
bad["portails"][0]["cartes"][0]["doctypes"].pop()
errs = self._validate_with_spec(bad)
self.assertTrue(any("couverture" in e for e in errs), errs)
def test_negatif_mauvais_ensemble_portails(self):
bad = copy.deepcopy(self.spec)
bad["portails"] = bad["portails"][:4] # 4 portails != 5 metier
errs = self._validate_with_spec(bad)
self.assertTrue(any("portails_business" in e or "portails metier" in e for e in errs), errs)
def test_negatif_raccourci_hors_permissions(self):
bad = copy.deepcopy(self.spec)
bad["portails"][1]["raccourcis"].append("Sales Invoice") # non autorise en construction
errs = self._validate_with_spec(bad)
self.assertTrue(any("raccourci vise DocType non autorise" in e for e in errs), errs)
# Garde-fous frappe.py --------------------------------------------------
def test_content_deterministe_frappe(self):
c1 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
c2 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
self.assertEqual(c1, c2)
self.assertTrue(json.loads(c1)) # JSON valide
if __name__ == "__main__":
unittest.main(verbosity=2)