[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:
@@ -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)
|
||||
Reference in New Issue
Block a user