[DTP-Worker] Sprint 7 · Générateur Scénarios démo (run-sheet P07 banquier / P05 client) (CRM+Faisabilité · roadmap L68)
Orchestrateur méta-niveau : compose les hand-offs out/ des 10 modules déjà livrés en un run-sheet de démo jouable, via pointeurs RFC 6901 résolus à la construction (zéro chiffre en dur · #6). Réutilise le validateur Publiciste et la preuve de couverture CI de l'auditeur 4Big (#5) — une démo ne s'appuie que sur des modules gated. Enregistre demo/scenarios dans l'audit 4Big (17→18, verdict PASS 18/18 à 100). 32 tests (9 injections négatives) · régression 474 tests verts · guards CI verts · builds déterministes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,87 @@
|
||||
# Scénarios démo · run-sheet de pitch — Sprint 7 (CRM + Faisabilité)
|
||||
|
||||
> Roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` · Sprint 7 l.68 —
|
||||
> « **CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready)** ».
|
||||
|
||||
Générateur **de méta-niveau** qui **compose** les livrables déjà produits en un
|
||||
**run-sheet de démo** prêt à jouer, sans écrire une seule donnée métier lui-même.
|
||||
Deux scénarios sont assemblés :
|
||||
|
||||
| Scénario | Projet | Audience | Angle |
|
||||
|---|---|---|---|
|
||||
| `S-P07-BANQUIER` | **P07 Aqua Terra Las Terrenas** | banquier | bancabilité, conformité fiscale, CONFOTUR, gouvernance, gate qualité |
|
||||
| `S-P05-CLIENT` | **P05 Las Colinas Najayo Arriba** | client | portail, OTOIA embarqué, pipeline lisible, dossier transparent, SEO |
|
||||
|
||||
## Le principe : zéro invention (CLAUDE.md #6)
|
||||
|
||||
Un scénario est une suite de **beats** (scènes). Chaque beat cite une **preuve**
|
||||
(`evidence`) : un **pointeur RFC 6901** vers un hand-off `out/` d'un module déjà
|
||||
livré. Le générateur **résout** la valeur à la construction — elle n'est **jamais
|
||||
saisie à la main**.
|
||||
|
||||
```
|
||||
scenario_spec.json out/ des modules livrés (10 modules cités)
|
||||
(pointeurs, 0 chiffre) ───────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ demo_scenario_gen build ▼
|
||||
out/run_sheet.json ◄──── résolution RFC 6901 + placeholders ──── crm/workflow_vente
|
||||
out/MANIFEST.json fiscal/ecf_dgii
|
||||
legal/confotur
|
||||
rbac/apply_plan
|
||||
qa/audit_4big …
|
||||
```
|
||||
|
||||
Une valeur introuvable devient un **placeholder** `{{module:file#pointer}}` —
|
||||
jamais un `0` fabriqué, jamais un `null` amont promu en chiffre de pitch.
|
||||
|
||||
## Garanties (le CLI REFUSE d'écrire si l'une casse)
|
||||
|
||||
1. **Anti-invention** — chaque citation est **re-résolue indépendamment** depuis
|
||||
le disque et comparée au run-sheet (`_check_anti_invention`).
|
||||
2. **Couverture CI** — tout module cité doit être **gated** par
|
||||
`.gitea/workflows/ci.yml`. On **réutilise** la preuve de couverture de
|
||||
l'auditeur 4Big (`qa/audit_4big/q4lib/registry.py`) — une démo ne repose que
|
||||
sur du validé. Zéro duplication (#5).
|
||||
3. **Orchestration réelle** — chaque scénario tisse **≥ 2 modules distincts** et
|
||||
**≥ 3 beats**, et ne **s'auto-cite jamais** (séparation des pouvoirs).
|
||||
4. **Prêt à jouer** — l'artefact livré est `pret=true` : 0 citation non résolue,
|
||||
0 module non gated. Sinon, rien n'est écrit.
|
||||
5. **Contrat de sortie** — `MANIFEST.json` est validé contre `scenario.schema.json`
|
||||
par le **validateur maison** du Publiciste (draft-07, sans pip · #2).
|
||||
|
||||
## Utilisation
|
||||
|
||||
```bash
|
||||
# Assemble le run-sheet (défaut: ./out)
|
||||
python3 demo_scenario_gen.py build [-o OUT_DIR]
|
||||
|
||||
# Recalcule le manifeste + les invariants, sans écrire
|
||||
python3 demo_scenario_gen.py validate
|
||||
|
||||
# Tests (32 méthodes, dont injections négatives)
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
## Fichiers
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `scenario_spec.json` | Les 2 scénarios · beats · pointeurs de preuve (**0 chiffre en dur**) |
|
||||
| `scenlib/deps.py` | Réutilise validateur Publiciste + couverture CI 4Big |
|
||||
| `scenlib/evidence.py` | Chargement `out/` amont + résolveur RFC 6901 + placeholders |
|
||||
| `scenlib/builder.py` | Assemblage déterministe run-sheet + manifeste |
|
||||
| `demo_scenario_gen.py` | CLI `build`/`validate` + invariants |
|
||||
| `scenario.schema.json` | Contrat de sortie draft-07 du manifeste |
|
||||
| `out/run_sheet.json` | **Hand-off principal** — run-sheet jouable |
|
||||
| `out/MANIFEST.json` | Diagnostic + preuve de couverture + drapeau `pret` |
|
||||
|
||||
## Hors périmètre worker (VPS · #8)
|
||||
|
||||
Le rendu final (deck / prompteur / page démo `otov7.com`) et le branchement au
|
||||
`data_room` réel des projets restent **côté serveur** — ce worker n'écrit jamais
|
||||
sur le VPS. Le run-sheet est le **contrat** que ce rendu consommera.
|
||||
|
||||
## Provenance des libellés projets
|
||||
|
||||
`P05 Las Colinas Najayo Arriba` et `P07 Aqua Terra Las Terrenas` proviennent
|
||||
**verbatim** de `CLAUDE.md · §Projets` — aucun projet inventé.
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Générateur de scénarios démo (run-sheet de pitch) · Sprint 7 · CRM + Faisabilité.
|
||||
|
||||
Livrable **Sprint 7** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` l.68 :
|
||||
« CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »).
|
||||
|
||||
C'est un **orchestrateur de méta-niveau** : il ne crée aucune donnée métier, il
|
||||
COMPOSE les hand-offs `out/` déjà produits par les modules livrés en un run-sheet
|
||||
de démo prêt à jouer (P07 banquier · P05 client). Toute valeur chiffrée du
|
||||
run-sheet est RÉSOLUE depuis un artefact amont via un pointeur RFC 6901 — jamais
|
||||
saisie à la main (anti-invention CLAUDE.md #6). Une valeur introuvable devient un
|
||||
placeholder `{{…}}`, jamais un chiffre fabriqué.
|
||||
|
||||
Chaîne de valeur :
|
||||
|
||||
out/ des modules livrés (workflow, e-CF, CONFOTUR, RBAC, 4Big, portails, …)
|
||||
│ pointeurs RFC 6901 (scenario_spec.json)
|
||||
▼
|
||||
demo_scenario_gen ──► out/run_sheet.json + out/MANIFEST.json
|
||||
│
|
||||
▼ (VPS · hors périmètre worker · #8)
|
||||
rendu deck / prompteur ─► démo publique otov7.com
|
||||
|
||||
Garanties (le CLI REFUSE d'écrire si l'une casse) :
|
||||
· chaque module cité est GATED par le CI (`.gitea/workflows/ci.yml`) — la démo
|
||||
ne s'appuie que sur du validé (réutilise la preuve de l'auditeur 4Big) ;
|
||||
· chaque valeur est re-résolue indépendamment et comparée (anti-invention) ;
|
||||
· l'artefact livré est `pret=true` (0 citation non résolue · 0 module non gated).
|
||||
|
||||
Sous-commandes :
|
||||
build [-o OUT_DIR] → OUT_DIR/{run_sheet,MANIFEST}.json
|
||||
validate → manifeste + invariants (sans écrire)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from scenlib import builder, deps, evidence # noqa: E402
|
||||
|
||||
|
||||
def _eprint(*args) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _load_spec() -> dict:
|
||||
with open(os.path.join(_HERE, "scenario_spec.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _load_schema() -> dict:
|
||||
with open(os.path.join(_HERE, "scenario.schema.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Invariants — honnêteté #6 : le CLI n'écrit RIEN si l'un casse.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def check_invariants(spec: dict, run_sheet: dict, manifest: dict) -> list[str]:
|
||||
motifs: list[str] = []
|
||||
|
||||
# (a) manifeste conforme au schéma de sortie (validateur maison Publiciste).
|
||||
motifs += [f"manifest: {e}" for e in deps.validate(manifest, _load_schema())]
|
||||
|
||||
# (b) identité des scénarios : ids uniques, projet/audience déclarés au spec.
|
||||
ids = [sc["id"] for sc in run_sheet["scenarios"]]
|
||||
if len(ids) != len(set(ids)):
|
||||
motifs.append(f"ids de scénario non uniques : {ids}")
|
||||
for sc in run_sheet["scenarios"]:
|
||||
if sc["projet"] not in spec["projets"]:
|
||||
motifs.append(f"{sc['id']} : projet {sc['projet']} absent du spec.projets")
|
||||
if sc["audience"] not in spec["audiences"]:
|
||||
motifs.append(f"{sc['id']} : audience {sc['audience']} hors spec.audiences")
|
||||
if sc["projet_libelle"] != spec["projets"].get(sc["projet"]):
|
||||
motifs.append(f"{sc['id']} : libellé projet incohérent avec le spec")
|
||||
|
||||
# (c) structure de chaque scénario : beats ordonnés 1..n, ≥3 beats,
|
||||
# ≥2 modules distincts (un orchestrateur DOIT tisser plusieurs livrables),
|
||||
# et jamais s'auto-citer (pas de preuve circulaire · SoD).
|
||||
for sc in run_sheet["scenarios"]:
|
||||
ordres = [b["ordre"] for b in sc["beats"]]
|
||||
if ordres != list(range(1, len(sc["beats"]) + 1)):
|
||||
motifs.append(f"{sc['id']} : ordres de beats non contigus {ordres}")
|
||||
if len(sc["beats"]) < 3:
|
||||
motifs.append(f"{sc['id']} : {len(sc['beats'])} beat(s) (min 3)")
|
||||
mods = {c["module"] for b in sc["beats"] for c in b["evidence"]}
|
||||
if len(mods) < 2:
|
||||
motifs.append(f"{sc['id']} : {len(mods)} module cité (min 2 · orchestration)")
|
||||
if builder.SELF_MODULE in mods:
|
||||
motifs.append(f"{sc['id']} : auto-citation de {builder.SELF_MODULE} (SoD)")
|
||||
|
||||
# (d) ANTI-INVENTION : re-résolution indépendante de CHAQUE citation.
|
||||
motifs += _check_anti_invention(spec, run_sheet)
|
||||
|
||||
# (e) placeholder ⟺ non résolu (aucune fuite de None ni de faux chiffre).
|
||||
for sc in run_sheet["scenarios"]:
|
||||
for b in sc["beats"]:
|
||||
for c in b["evidence"]:
|
||||
ph = evidence.is_placeholder(c["valeur"])
|
||||
if c["resolu"] and (ph or c["valeur"] is None):
|
||||
motifs.append(f"{c['module']}#{c['pointer']} : resolu mais valeur placeholder/None")
|
||||
if not c["resolu"] and not ph:
|
||||
motifs.append(f"{c['module']}#{c['pointer']} : non résolu sans placeholder")
|
||||
|
||||
# (f) comptes recalculés == manifeste (aucun compteur « posé »).
|
||||
motifs += _check_counts(run_sheet, manifest)
|
||||
|
||||
# (g) couverture CI : tout module cité doit être gated + drapeau pret.
|
||||
if not manifest["couverture_ci"]["ok"] or manifest["couverture_ci"]["modules_cites_non_gated"]:
|
||||
motifs.append("module cité NON gated par le CI : "
|
||||
f"{manifest['couverture_ci']['modules_cites_non_gated']}")
|
||||
if not manifest["pret"]:
|
||||
motifs.append("run-sheet non prêt (citations non résolues ou modules non gated) — "
|
||||
"un livrable démo doit être prêt à jouer (#5)")
|
||||
|
||||
return motifs
|
||||
|
||||
|
||||
def _check_anti_invention(spec: dict, run_sheet: dict) -> list[str]:
|
||||
"""Re-résout chaque pointeur avec un loader neuf et compare au run-sheet.
|
||||
|
||||
Prouve qu'aucune valeur n'a été « posée » : elle DOIT redécouler de l'artefact
|
||||
amont sur disque. Recoupe aussi spec ↔ run-sheet (mêmes pointeurs, même ordre).
|
||||
"""
|
||||
motifs: list[str] = []
|
||||
loader = evidence.Loader()
|
||||
spec_by_id = {sc["id"]: sc for sc in spec["scenarios"]}
|
||||
|
||||
for sc in run_sheet["scenarios"]:
|
||||
spec_sc = spec_by_id.get(sc["id"])
|
||||
if spec_sc is None:
|
||||
motifs.append(f"{sc['id']} : scénario absent du spec")
|
||||
continue
|
||||
if len(spec_sc["beats"]) != len(sc["beats"]):
|
||||
motifs.append(f"{sc['id']} : nb de beats spec≠run-sheet")
|
||||
continue
|
||||
for spec_b, b in zip(spec_sc["beats"], sc["beats"]):
|
||||
spec_ev = spec_b["evidence"]
|
||||
if len(spec_ev) != len(b["evidence"]):
|
||||
motifs.append(f"{sc['id']}/{b['id']} : nb de citations spec≠run-sheet")
|
||||
continue
|
||||
for se, c in zip(spec_ev, b["evidence"]):
|
||||
if (se["module"], se["file"], se["pointer"]) != \
|
||||
(c["module"], c["file"], c["pointer"]):
|
||||
motifs.append(f"{sc['id']}/{b['id']} : citation dérivée du spec")
|
||||
continue
|
||||
exp_val, exp_res = evidence.resolve(
|
||||
loader, se["module"], se["file"], se["pointer"])
|
||||
if exp_res != c["resolu"] or exp_val != c["valeur"]:
|
||||
motifs.append(
|
||||
f"{se['module']}#{se['pointer']} : run-sheet {c['valeur']!r} "
|
||||
f"!= re-résolution {exp_val!r} (invention/dérive)")
|
||||
return motifs
|
||||
|
||||
|
||||
def _check_counts(run_sheet: dict, manifest: dict) -> list[str]:
|
||||
motifs: list[str] = []
|
||||
cites = [c for sc in run_sheet["scenarios"] for b in sc["beats"] for c in b["evidence"]]
|
||||
exp = {
|
||||
"scenarios": len(run_sheet["scenarios"]),
|
||||
"beats_total": sum(len(sc["beats"]) for sc in run_sheet["scenarios"]),
|
||||
"citations_total": len(cites),
|
||||
"citations_non_resolues": sum(1 for c in cites if not c["resolu"]),
|
||||
"modules_cites_uniques": len({c["module"] for c in cites}),
|
||||
}
|
||||
for cle, val in exp.items():
|
||||
if manifest["counts"].get(cle) != val:
|
||||
motifs.append(f"counts.{cle} : manifeste {manifest['counts'].get(cle)} != recalcul {val}")
|
||||
return motifs
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _assemble(generated_at: str | None) -> tuple[dict, dict, dict]:
|
||||
spec = _load_spec()
|
||||
run_sheet = builder.build_run_sheet(spec, generated_at=generated_at)
|
||||
manifest = builder.build_manifest(spec, run_sheet)
|
||||
return spec, run_sheet, manifest
|
||||
|
||||
|
||||
def cmd_build(ns: argparse.Namespace) -> int:
|
||||
spec, run_sheet, manifest = _assemble(ns.generated_at)
|
||||
motifs = check_invariants(spec, run_sheet, manifest)
|
||||
if motifs:
|
||||
_eprint("❌ invariants cassés — RIEN écrit :")
|
||||
for m in motifs:
|
||||
_eprint(" ·", m)
|
||||
return 1
|
||||
|
||||
os.makedirs(ns.out, exist_ok=True)
|
||||
for name, obj in (("run_sheet.json", run_sheet), ("MANIFEST.json", manifest)):
|
||||
with open(os.path.join(ns.out, name), "w", encoding="utf-8") as fh:
|
||||
json.dump(obj, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
|
||||
c = manifest["counts"]
|
||||
_eprint(f"✅ run-sheet → {ns.out}")
|
||||
_eprint(f" scénarios={c['scenarios']} · beats={c['beats_total']} · "
|
||||
f"citations={c['citations_total']} (non résolues={c['citations_non_resolues']}) · "
|
||||
f"modules cités={c['modules_cites_uniques']}")
|
||||
for sc in manifest["scenarios"]:
|
||||
_eprint(f" · {sc['id']} [{sc['audience']}] {sc['projet']} — "
|
||||
f"{sc['beats']} beats · {sc['duree_min']} min · {sc['citations_resolues']}/{sc['citations']} résolues")
|
||||
_eprint(f" pret={manifest['pret']} · couverture CI ok={manifest['couverture_ci']['ok']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(ns: argparse.Namespace) -> int:
|
||||
spec, run_sheet, manifest = _assemble(ns.generated_at)
|
||||
motifs = check_invariants(spec, run_sheet, manifest)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
if motifs:
|
||||
_eprint(f"❌ {len(motifs)} invariant(s) cassé(s) :")
|
||||
for m in motifs:
|
||||
_eprint(" ·", m)
|
||||
return 1
|
||||
_eprint(f"✅ manifeste conforme + invariants OK (pret={manifest['pret']}).")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_argparser() -> argparse.ArgumentParser:
|
||||
ap = argparse.ArgumentParser(prog="demo_scenario_gen",
|
||||
description=__doc__.splitlines()[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("build", help="scenario_spec.json → out/{run_sheet,MANIFEST}.json")
|
||||
p.add_argument("-o", "--out", default=os.path.join(_HERE, "out"),
|
||||
help="dossier de sortie (défaut: ./out)")
|
||||
p.add_argument("--generated-at", dest="generated_at", default=None)
|
||||
p.set_defaults(func=cmd_build)
|
||||
|
||||
p = sub.add_parser("validate", help="manifeste + invariants (sans écrire)")
|
||||
p.add_argument("--generated-at", dest="generated_at", default=None)
|
||||
p.set_defaults(func=cmd_validate)
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ns = build_argparser().parse_args(argv)
|
||||
return ns.func(ns)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"deliverable": "Scénarios démo · run-sheet de pitch",
|
||||
"sprint": "Sprint 7 · CRM + Faisabilité",
|
||||
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 7 l.68 « CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »",
|
||||
"generated_from": "scenario_spec.json",
|
||||
"deliverables_root": "05_deliverables_mvp",
|
||||
"self_module": "demo/scenarios",
|
||||
"generated_at": null,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "S-P07-BANQUIER",
|
||||
"projet": "P07",
|
||||
"audience": "banquier",
|
||||
"beats": 6,
|
||||
"citations": 13,
|
||||
"citations_resolues": 13,
|
||||
"duree_min": 12,
|
||||
"modules_cites": [
|
||||
"crm/dossier_vente",
|
||||
"crm/workflow_vente",
|
||||
"fiscal/ecf_dgii",
|
||||
"legal/confotur",
|
||||
"qa/audit_4big",
|
||||
"rbac/apply_plan"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-P05-CLIENT",
|
||||
"projet": "P05",
|
||||
"audience": "client",
|
||||
"beats": 6,
|
||||
"citations": 10,
|
||||
"citations_resolues": 10,
|
||||
"duree_min": 12,
|
||||
"modules_cites": [
|
||||
"crm/commissions",
|
||||
"crm/dossier_vente",
|
||||
"crm/workflow_vente",
|
||||
"frontend/chat_otoia",
|
||||
"frontend/portails",
|
||||
"seo"
|
||||
]
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"scenarios": 2,
|
||||
"beats_total": 12,
|
||||
"citations_total": 23,
|
||||
"citations_non_resolues": 0,
|
||||
"modules_cites_uniques": 10
|
||||
},
|
||||
"modules_cites": [
|
||||
"crm/commissions",
|
||||
"crm/dossier_vente",
|
||||
"crm/workflow_vente",
|
||||
"fiscal/ecf_dgii",
|
||||
"frontend/chat_otoia",
|
||||
"frontend/portails",
|
||||
"legal/confotur",
|
||||
"qa/audit_4big",
|
||||
"rbac/apply_plan",
|
||||
"seo"
|
||||
],
|
||||
"couverture_ci": {
|
||||
"ok": true,
|
||||
"modules_cites_non_gated": []
|
||||
},
|
||||
"pret": true
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"doc": "OTO · Run-sheet démo (méta-livrable)",
|
||||
"spec_version": "1.0.0",
|
||||
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 7 l.68 « CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »",
|
||||
"generated_at": null,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "S-P07-BANQUIER",
|
||||
"projet": "P07",
|
||||
"projet_libelle": "Aqua Terra Las Terrenas",
|
||||
"audience": "banquier",
|
||||
"titre": "P07 Aqua Terra — Pitch banquier (bancabilité & gouvernance)",
|
||||
"objectif": "Démontrer qu'un dossier de vente OTO est bancable : pipeline structuré, conformité fiscale e-CF DGII, incitation CONFOTUR, gouvernance RBAC et gate qualité 4Big.",
|
||||
"duree_min": 12,
|
||||
"modules_cites": [
|
||||
"crm/dossier_vente",
|
||||
"crm/workflow_vente",
|
||||
"fiscal/ecf_dgii",
|
||||
"legal/confotur",
|
||||
"qa/audit_4big",
|
||||
"rbac/apply_plan"
|
||||
],
|
||||
"beats": [
|
||||
{
|
||||
"ordre": 1,
|
||||
"id": "b1-pipeline",
|
||||
"titre": "Un pipeline de vente verrouillé de bout en bout",
|
||||
"role_narratif": "Ouvrir sur la traçabilité : la vente n'est pas un tableur, c'est un workflow ERPNext soumis (docstatus) piloté par rôle.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "crm/workflow_vente",
|
||||
"file": "out/workflow.json",
|
||||
"pointer": "/0/workflow_name",
|
||||
"label": "Nom du pipeline",
|
||||
"valeur": "OTO Vente Pipeline",
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "crm/workflow_vente",
|
||||
"file": "out/workflow.json",
|
||||
"pointer": "/0/document_type",
|
||||
"label": "DocType piloté",
|
||||
"valeur": "OTO Dossier Vente",
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 2,
|
||||
"id": "b2-ecf",
|
||||
"titre": "Facturation fiscale conforme DGII (e-CF · Compupar)",
|
||||
"role_narratif": "Rassurer sur le risque fiscal : chaque encaissement est un e-CF certifié, pas une facture maison.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "fiscal/ecf_dgii",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/provider",
|
||||
"label": "Fournisseur e-CF",
|
||||
"valeur": "Compupar",
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "fiscal/ecf_dgii",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/tipos_en_scope",
|
||||
"label": "Types e-CF en périmètre",
|
||||
"valeur": 5,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 3,
|
||||
"id": "b3-confotur",
|
||||
"titre": "Incitation CONFOTUR dé-risquée et outillée",
|
||||
"role_narratif": "Montrer l'avantage fiscal touristique comme un actif documenté : DocType soumis, dépôt outillé.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "legal/confotur",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/doctype_name",
|
||||
"label": "DocType CONFOTUR",
|
||||
"valeur": "CONFOTUR Application",
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "legal/confotur",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/is_submittable",
|
||||
"label": "Soumissible (verrou légal)",
|
||||
"valeur": true,
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "legal/confotur",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/depot_events",
|
||||
"label": "Étapes de dépôt outillées",
|
||||
"valeur": 2,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 4,
|
||||
"id": "b4-rbac",
|
||||
"titre": "Séparation des pouvoirs (RBAC 50 rôles)",
|
||||
"role_narratif": "Répondre à la question d'audit du banquier : qui peut faire quoi ? Permissions dérivées, pas ad hoc.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "rbac/apply_plan",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/roles",
|
||||
"label": "Rôles RBAC",
|
||||
"valeur": 50,
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "rbac/apply_plan",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/custom_docperm",
|
||||
"label": "Permissions DocType (Custom DocPerm)",
|
||||
"valeur": 116,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 5,
|
||||
"id": "b5-qualite",
|
||||
"titre": "Un gate qualité 4Big, pas une promesse",
|
||||
"role_narratif": "Clore la confiance : la plateforme s'auto-note et bloque sous 95/100 sur 100 % des livrables.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "qa/audit_4big",
|
||||
"file": "out/quality_report.json",
|
||||
"pointer": "/pass_score",
|
||||
"label": "Seuil bloquant 4Big",
|
||||
"valeur": 95,
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "qa/audit_4big",
|
||||
"file": "out/quality_report.json",
|
||||
"pointer": "/coverage/ci_modules_count",
|
||||
"label": "Modules sous gate qualité",
|
||||
"valeur": 18,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 6,
|
||||
"id": "b6-dossier",
|
||||
"titre": "Le dossier de vente, pièce bancable finale",
|
||||
"role_narratif": "Refermer sur l'objet concret que le banquier instruira : un DocType riche et soumis.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "crm/dossier_vente",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/fields",
|
||||
"label": "Champs du dossier de vente",
|
||||
"valeur": 30,
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "crm/dossier_vente",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/pipeline_states",
|
||||
"label": "États de pipeline couverts",
|
||||
"valeur": 9,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-P05-CLIENT",
|
||||
"projet": "P05",
|
||||
"projet_libelle": "Las Colinas Najayo Arriba",
|
||||
"audience": "client",
|
||||
"titre": "P05 Las Colinas Najayo Arriba — Parcours client (réservation prête)",
|
||||
"objectif": "Faire vivre le parcours d'un acheteur : découverte du portail, assistance OTOIA, pipeline lisible, dossier transparent, et comment le client nous a trouvés (SEO trilingue).",
|
||||
"duree_min": 12,
|
||||
"modules_cites": [
|
||||
"crm/commissions",
|
||||
"crm/dossier_vente",
|
||||
"crm/workflow_vente",
|
||||
"frontend/chat_otoia",
|
||||
"frontend/portails",
|
||||
"seo"
|
||||
],
|
||||
"beats": [
|
||||
{
|
||||
"ordre": 1,
|
||||
"id": "b1-portail",
|
||||
"titre": "« Choisir mon unité » — 5 portails luxury",
|
||||
"role_narratif": "Poser l'expérience d'entrée : un portail par rôle, cohérent et couvert.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "frontend/portails",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/workspaces",
|
||||
"label": "Portails (workspaces)",
|
||||
"valeur": 5,
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "frontend/portails",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/roles_couverts",
|
||||
"label": "Rôles couverts par les portails",
|
||||
"valeur": 44,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 2,
|
||||
"id": "b2-otoia",
|
||||
"titre": "OTOIA répond dans le portail",
|
||||
"role_narratif": "Montrer l'assistance embarquée : le client n'est jamais seul face à l'écran.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "frontend/chat_otoia",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/deliverable",
|
||||
"label": "Assistant embarqué",
|
||||
"valeur": "Chat OTOIA embarqué par portail",
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 3,
|
||||
"id": "b3-parcours",
|
||||
"titre": "Du premier contact à la réservation",
|
||||
"role_narratif": "Dérouler le pipeline côté client : Lead → … → Réservation confirmée, lisible et rassurant.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "crm/workflow_vente",
|
||||
"file": "out/workflow.json",
|
||||
"pointer": "/0/states/0/state",
|
||||
"label": "Première étape",
|
||||
"valeur": "Lead",
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "crm/workflow_vente",
|
||||
"file": "out/workflow.json",
|
||||
"pointer": "/0/states/3/state",
|
||||
"label": "Étape réservation",
|
||||
"valeur": "Réservation confirmée",
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 4,
|
||||
"id": "b4-dossier",
|
||||
"titre": "Un dossier de vente transparent",
|
||||
"role_narratif": "Donner confiance : tout est consigné dans un dossier structuré que le client peut suivre.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "crm/dossier_vente",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/fields",
|
||||
"label": "Champs du dossier de vente",
|
||||
"valeur": 30,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 5,
|
||||
"id": "b5-commissions",
|
||||
"titre": "Un conseiller rémunéré à la transparence",
|
||||
"role_narratif": "Aligner l'intérêt du conseiller et du client : barème de commissions traçable par événement.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "crm/commissions",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/bareme_name",
|
||||
"label": "Barème de commissions",
|
||||
"valeur": "OTO Barème Commissions Ventes",
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "crm/commissions",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/evenements",
|
||||
"label": "Événements déclencheurs",
|
||||
"valeur": 5,
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordre": 6,
|
||||
"id": "b6-seo",
|
||||
"titre": "Comment le client nous a trouvés (SEO trilingue)",
|
||||
"role_narratif": "Refermer la boucle acquisition : indexation FR/EN/ES, le parcours commence bien avant le portail.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{
|
||||
"module": "seo",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/counts/keywords_total",
|
||||
"label": "Mots-clés indexés",
|
||||
"valeur": 258,
|
||||
"resolu": true
|
||||
},
|
||||
{
|
||||
"module": "seo",
|
||||
"file": "out/MANIFEST.json",
|
||||
"pointer": "/langs",
|
||||
"label": "Langues",
|
||||
"valeur": [
|
||||
"fr",
|
||||
"en",
|
||||
"es"
|
||||
],
|
||||
"resolu": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://otov7.com/schemas/demo-scenario-manifest-1.0.0.json",
|
||||
"title": "OTO · Manifeste Scénarios démo (run-sheet)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"deliverable", "sprint", "roadmap_ref", "generated_from",
|
||||
"deliverables_root", "self_module", "generated_at", "scenarios",
|
||||
"counts", "modules_cites", "couverture_ci", "pret"
|
||||
],
|
||||
"properties": {
|
||||
"deliverable": {"type": "string", "minLength": 1},
|
||||
"sprint": {"type": "string", "minLength": 1},
|
||||
"roadmap_ref": {"type": "string", "minLength": 1},
|
||||
"generated_from": {"type": "string", "const": "scenario_spec.json"},
|
||||
"deliverables_root": {"type": "string", "const": "05_deliverables_mvp"},
|
||||
"self_module": {"type": "string", "const": "demo/scenarios"},
|
||||
"generated_at": {"type": ["string", "null"]},
|
||||
"scenarios": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "projet", "audience", "beats", "citations",
|
||||
"citations_resolues", "duree_min", "modules_cites"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^S-P0[1-9]-[A-Z]+$"},
|
||||
"projet": {"type": "string", "pattern": "^P0[1-9]$"},
|
||||
"audience": {"type": "string", "enum": ["banquier", "client"]},
|
||||
"beats": {"type": "integer", "minimum": 3},
|
||||
"citations": {"type": "integer", "minimum": 1},
|
||||
"citations_resolues": {"type": "integer", "minimum": 0},
|
||||
"duree_min": {"type": "integer", "minimum": 1},
|
||||
"modules_cites": {
|
||||
"type": "array", "minItems": 2, "uniqueItems": true,
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["scenarios", "beats_total", "citations_total",
|
||||
"citations_non_resolues", "modules_cites_uniques"],
|
||||
"properties": {
|
||||
"scenarios": {"type": "integer", "minimum": 1},
|
||||
"beats_total": {"type": "integer", "minimum": 1},
|
||||
"citations_total": {"type": "integer", "minimum": 1},
|
||||
"citations_non_resolues": {"type": "integer", "minimum": 0},
|
||||
"modules_cites_uniques": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"modules_cites": {
|
||||
"type": "array", "minItems": 1, "uniqueItems": true,
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"couverture_ci": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ok", "modules_cites_non_gated"],
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"},
|
||||
"modules_cites_non_gated": {
|
||||
"type": "array", "items": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pret": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"spec_version": "1.0.0",
|
||||
"deliverable": "Scénarios démo · run-sheet de pitch",
|
||||
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 7 l.68 « CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »",
|
||||
"self_module": "demo/scenarios",
|
||||
"audiences": ["banquier", "client"],
|
||||
"projets": {
|
||||
"P05": "Las Colinas Najayo Arriba",
|
||||
"P07": "Aqua Terra Las Terrenas"
|
||||
},
|
||||
"note_anti_invention": "Aucune valeur chiffrée n'est écrite dans ce spec. Chaque `evidence` est un POINTEUR (RFC 6901) vers un hand-off `out/` d'un module déjà livré et gated par le CI ; le générateur résout la valeur à la construction. Une valeur non résolue devient un placeholder `{{module:file#pointer}}` — jamais un chiffre fabriqué (CLAUDE.md #6).",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "S-P07-BANQUIER",
|
||||
"projet": "P07",
|
||||
"audience": "banquier",
|
||||
"titre": "P07 Aqua Terra — Pitch banquier (bancabilité & gouvernance)",
|
||||
"objectif": "Démontrer qu'un dossier de vente OTO est bancable : pipeline structuré, conformité fiscale e-CF DGII, incitation CONFOTUR, gouvernance RBAC et gate qualité 4Big.",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1-pipeline",
|
||||
"titre": "Un pipeline de vente verrouillé de bout en bout",
|
||||
"role_narratif": "Ouvrir sur la traçabilité : la vente n'est pas un tableur, c'est un workflow ERPNext soumis (docstatus) piloté par rôle.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/workflow_name", "label": "Nom du pipeline"},
|
||||
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/document_type", "label": "DocType piloté"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2-ecf",
|
||||
"titre": "Facturation fiscale conforme DGII (e-CF · Compupar)",
|
||||
"role_narratif": "Rassurer sur le risque fiscal : chaque encaissement est un e-CF certifié, pas une facture maison.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "fiscal/ecf_dgii", "file": "out/MANIFEST.json", "pointer": "/provider", "label": "Fournisseur e-CF"},
|
||||
{"module": "fiscal/ecf_dgii", "file": "out/MANIFEST.json", "pointer": "/counts/tipos_en_scope", "label": "Types e-CF en périmètre"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3-confotur",
|
||||
"titre": "Incitation CONFOTUR dé-risquée et outillée",
|
||||
"role_narratif": "Montrer l'avantage fiscal touristique comme un actif documenté : DocType soumis, dépôt outillé.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "legal/confotur", "file": "out/MANIFEST.json", "pointer": "/doctype_name", "label": "DocType CONFOTUR"},
|
||||
{"module": "legal/confotur", "file": "out/MANIFEST.json", "pointer": "/is_submittable", "label": "Soumissible (verrou légal)"},
|
||||
{"module": "legal/confotur", "file": "out/MANIFEST.json", "pointer": "/counts/depot_events", "label": "Étapes de dépôt outillées"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4-rbac",
|
||||
"titre": "Séparation des pouvoirs (RBAC 50 rôles)",
|
||||
"role_narratif": "Répondre à la question d'audit du banquier : qui peut faire quoi ? Permissions dérivées, pas ad hoc.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "rbac/apply_plan", "file": "out/MANIFEST.json", "pointer": "/counts/roles", "label": "Rôles RBAC"},
|
||||
{"module": "rbac/apply_plan", "file": "out/MANIFEST.json", "pointer": "/counts/custom_docperm", "label": "Permissions DocType (Custom DocPerm)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5-qualite",
|
||||
"titre": "Un gate qualité 4Big, pas une promesse",
|
||||
"role_narratif": "Clore la confiance : la plateforme s'auto-note et bloque sous 95/100 sur 100 % des livrables.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "qa/audit_4big", "file": "out/quality_report.json", "pointer": "/pass_score", "label": "Seuil bloquant 4Big"},
|
||||
{"module": "qa/audit_4big", "file": "out/quality_report.json", "pointer": "/coverage/ci_modules_count", "label": "Modules sous gate qualité"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6-dossier",
|
||||
"titre": "Le dossier de vente, pièce bancable finale",
|
||||
"role_narratif": "Refermer sur l'objet concret que le banquier instruira : un DocType riche et soumis.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "crm/dossier_vente", "file": "out/MANIFEST.json", "pointer": "/counts/fields", "label": "Champs du dossier de vente"},
|
||||
{"module": "crm/dossier_vente", "file": "out/MANIFEST.json", "pointer": "/counts/pipeline_states", "label": "États de pipeline couverts"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-P05-CLIENT",
|
||||
"projet": "P05",
|
||||
"audience": "client",
|
||||
"titre": "P05 Las Colinas Najayo Arriba — Parcours client (réservation prête)",
|
||||
"objectif": "Faire vivre le parcours d'un acheteur : découverte du portail, assistance OTOIA, pipeline lisible, dossier transparent, et comment le client nous a trouvés (SEO trilingue).",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1-portail",
|
||||
"titre": "« Choisir mon unité » — 5 portails luxury",
|
||||
"role_narratif": "Poser l'expérience d'entrée : un portail par rôle, cohérent et couvert.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "frontend/portails", "file": "out/MANIFEST.json", "pointer": "/counts/workspaces", "label": "Portails (workspaces)"},
|
||||
{"module": "frontend/portails", "file": "out/MANIFEST.json", "pointer": "/counts/roles_couverts", "label": "Rôles couverts par les portails"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2-otoia",
|
||||
"titre": "OTOIA répond dans le portail",
|
||||
"role_narratif": "Montrer l'assistance embarquée : le client n'est jamais seul face à l'écran.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "frontend/chat_otoia", "file": "out/MANIFEST.json", "pointer": "/deliverable", "label": "Assistant embarqué"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3-parcours",
|
||||
"titre": "Du premier contact à la réservation",
|
||||
"role_narratif": "Dérouler le pipeline côté client : Lead → … → Réservation confirmée, lisible et rassurant.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/states/0/state", "label": "Première étape"},
|
||||
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/states/3/state", "label": "Étape réservation"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4-dossier",
|
||||
"titre": "Un dossier de vente transparent",
|
||||
"role_narratif": "Donner confiance : tout est consigné dans un dossier structuré que le client peut suivre.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "crm/dossier_vente", "file": "out/MANIFEST.json", "pointer": "/counts/fields", "label": "Champs du dossier de vente"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5-commissions",
|
||||
"titre": "Un conseiller rémunéré à la transparence",
|
||||
"role_narratif": "Aligner l'intérêt du conseiller et du client : barème de commissions traçable par événement.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "crm/commissions", "file": "out/MANIFEST.json", "pointer": "/bareme_name", "label": "Barème de commissions"},
|
||||
{"module": "crm/commissions", "file": "out/MANIFEST.json", "pointer": "/counts/evenements", "label": "Événements déclencheurs"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6-seo",
|
||||
"titre": "Comment le client nous a trouvés (SEO trilingue)",
|
||||
"role_narratif": "Refermer la boucle acquisition : indexation FR/EN/ES, le parcours commence bien avant le portail.",
|
||||
"duree_min": 2,
|
||||
"evidence": [
|
||||
{"module": "seo", "file": "out/MANIFEST.json", "pointer": "/counts/keywords_total", "label": "Mots-clés indexés"},
|
||||
{"module": "seo", "file": "out/MANIFEST.json", "pointer": "/langs", "label": "Langues"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Bibliothèque du générateur de scénarios démo (run-sheet de pitch).
|
||||
|
||||
Découpage :
|
||||
- deps : réutilisation des briques déjà livrées (validateur maison +
|
||||
preuve de couverture CI) · zéro duplication (#5).
|
||||
- evidence : chargement des hand-offs `out/` amont + résolution de pointeurs
|
||||
RFC 6901 · cœur anti-invention (#6).
|
||||
- builder : assemblage déterministe du run-sheet + du manifeste.
|
||||
"""
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Assemblage déterministe du run-sheet démo + du manifeste (hand-off).
|
||||
|
||||
Le builder est PUR : mêmes entrées (spec + artefacts `out/` sur disque) ⇒ même
|
||||
sortie. Aucun horodatage implicite, aucun ordre non déterministe. L'ordre des
|
||||
beats est dérivé de la position dans le spec (jamais d'un champ `ordre` en dur,
|
||||
pour éviter toute dérive). Les modules cités sont recoupés avec l'ensemble des
|
||||
modules gated par le CI (via `deps.ci_module_paths`) — une démo ne s'appuie que
|
||||
sur du validé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import deps, evidence
|
||||
|
||||
SELF_MODULE = "demo/scenarios"
|
||||
|
||||
|
||||
def _build_beat(loader: evidence.Loader, beat: dict, ordre: int) -> dict:
|
||||
cites = []
|
||||
for ev in beat["evidence"]:
|
||||
module, file, pointer = ev["module"], ev["file"], ev["pointer"]
|
||||
valeur, resolu = evidence.resolve(loader, module, file, pointer)
|
||||
cites.append({
|
||||
"module": module,
|
||||
"file": file,
|
||||
"pointer": pointer,
|
||||
"label": ev["label"],
|
||||
"valeur": valeur,
|
||||
"resolu": resolu,
|
||||
})
|
||||
return {
|
||||
"ordre": ordre,
|
||||
"id": beat["id"],
|
||||
"titre": beat["titre"],
|
||||
"role_narratif": beat["role_narratif"],
|
||||
"duree_min": beat["duree_min"],
|
||||
"evidence": cites,
|
||||
}
|
||||
|
||||
|
||||
def _build_scenario(loader: evidence.Loader, spec: dict, sc: dict) -> dict:
|
||||
beats = [_build_beat(loader, b, i + 1) for i, b in enumerate(sc["beats"])]
|
||||
modules = sorted({c["module"] for b in beats for c in b["evidence"]})
|
||||
duree = sum(b["duree_min"] for b in beats)
|
||||
return {
|
||||
"id": sc["id"],
|
||||
"projet": sc["projet"],
|
||||
"projet_libelle": spec["projets"][sc["projet"]],
|
||||
"audience": sc["audience"],
|
||||
"titre": sc["titre"],
|
||||
"objectif": sc["objectif"],
|
||||
"duree_min": duree,
|
||||
"modules_cites": modules,
|
||||
"beats": beats,
|
||||
}
|
||||
|
||||
|
||||
def build_run_sheet(spec: dict, generated_at: str | None = None) -> dict:
|
||||
"""Construit le run-sheet complet (le hand-off principal)."""
|
||||
loader = evidence.Loader()
|
||||
scenarios = [_build_scenario(loader, spec, sc) for sc in spec["scenarios"]]
|
||||
return {
|
||||
"doc": "OTO · Run-sheet démo (méta-livrable)",
|
||||
"spec_version": spec["spec_version"],
|
||||
"roadmap_ref": spec["roadmap_ref"],
|
||||
"generated_at": generated_at,
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
|
||||
|
||||
def _all_citations(run_sheet: dict) -> list[dict]:
|
||||
return [c for sc in run_sheet["scenarios"] for b in sc["beats"] for c in b["evidence"]]
|
||||
|
||||
|
||||
def build_manifest(spec: dict, run_sheet: dict) -> dict:
|
||||
"""Manifeste diagnostic + preuve de couverture CI + drapeau `pret`."""
|
||||
cites = _all_citations(run_sheet)
|
||||
non_resolues = [c for c in cites if not c["resolu"]]
|
||||
modules_cites = sorted({c["module"] for c in cites})
|
||||
|
||||
gated = deps.ci_module_paths() # modules réellement gated par le CI
|
||||
non_gated = sorted(m for m in modules_cites if m not in gated)
|
||||
|
||||
scen_diag = []
|
||||
for sc in run_sheet["scenarios"]:
|
||||
sc_cites = [c for b in sc["beats"] for c in b["evidence"]]
|
||||
scen_diag.append({
|
||||
"id": sc["id"],
|
||||
"projet": sc["projet"],
|
||||
"audience": sc["audience"],
|
||||
"beats": len(sc["beats"]),
|
||||
"citations": len(sc_cites),
|
||||
"citations_resolues": sum(1 for c in sc_cites if c["resolu"]),
|
||||
"duree_min": sc["duree_min"],
|
||||
"modules_cites": sc["modules_cites"],
|
||||
})
|
||||
|
||||
couverture_ok = not non_gated
|
||||
pret = (not non_resolues) and couverture_ok
|
||||
|
||||
return {
|
||||
"deliverable": "Scénarios démo · run-sheet de pitch",
|
||||
"sprint": "Sprint 7 · CRM + Faisabilité",
|
||||
"roadmap_ref": spec["roadmap_ref"],
|
||||
"generated_from": "scenario_spec.json",
|
||||
"deliverables_root": "05_deliverables_mvp",
|
||||
"self_module": SELF_MODULE,
|
||||
"generated_at": run_sheet["generated_at"],
|
||||
"scenarios": scen_diag,
|
||||
"counts": {
|
||||
"scenarios": len(run_sheet["scenarios"]),
|
||||
"beats_total": sum(len(sc["beats"]) for sc in run_sheet["scenarios"]),
|
||||
"citations_total": len(cites),
|
||||
"citations_non_resolues": len(non_resolues),
|
||||
"modules_cites_uniques": len(modules_cites),
|
||||
},
|
||||
"modules_cites": modules_cites,
|
||||
"couverture_ci": {
|
||||
"ok": couverture_ok,
|
||||
"modules_cites_non_gated": non_gated,
|
||||
},
|
||||
"pret": pret,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
|
||||
|
||||
L'orchestrateur démo ne redéfinit RIEN qui existe ailleurs :
|
||||
|
||||
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
|
||||
sous-ensemble) — le run-sheet est validé SANS pip, car le
|
||||
gate CI Gitea Actions tourne sans réseau (CLAUDE.md #2).
|
||||
- `ci_module_paths` : la PREUVE de couverture de l'auditeur 4Big
|
||||
(`qa/audit_4big/q4lib/registry.py`), qui lit
|
||||
`.gitea/workflows/ci.yml` pour connaître les modules
|
||||
réellement gated. On la réutilise pour garantir qu'une
|
||||
démo ne s'appuie QUE sur des livrables validés par le CI —
|
||||
jamais sur du code non gated.
|
||||
|
||||
Import par manipulation de `sys.path` (idiome des modules voisins :
|
||||
`banclib/deps.py`, `q4lib/deps.py`) plutôt que duplication — une seule source de
|
||||
vérité pour tout le mandat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# demo/scenarios/scenlib → 05_deliverables_mvp
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
|
||||
_PUB = os.path.join(_DELIVERABLES, "publiciste")
|
||||
_AUDIT = os.path.join(_DELIVERABLES, "qa", "audit_4big")
|
||||
|
||||
for _p in (_PUB, _AUDIT):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from lib import validator # type: ignore # noqa: E402
|
||||
from q4lib import registry # type: ignore # noqa: E402
|
||||
|
||||
validate = validator.validate
|
||||
ci_module_paths = registry.ci_module_paths
|
||||
|
||||
# Racine des livrables, exposée aux autres modules scenlib.
|
||||
DELIVERABLES_ROOT = _DELIVERABLES
|
||||
|
||||
__all__ = ["validate", "ci_module_paths", "DELIVERABLES_ROOT"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Cœur anti-invention (#6) : résolution des preuves depuis les hand-offs amont.
|
||||
|
||||
Un scénario démo ne contient AUCUN chiffre en dur. Chaque « evidence » du spec est
|
||||
un pointeur `(module, file, pointer)` vers un artefact `out/` déjà produit par un
|
||||
module livré. Ce fichier :
|
||||
|
||||
1. charge l'artefact JSON (avec cache) et signale toute absence / JSON invalide ;
|
||||
2. résout un pointeur RFC 6901 (`/0/counts/fields`) vers sa valeur verbatim ;
|
||||
3. quand la valeur n'existe pas, renvoie un PLACEHOLDER `{{module:file#pointer}}`
|
||||
— jamais un chiffre fabriqué, jamais un `0` posé.
|
||||
|
||||
La résolution est PURE et rejouable : l'invariant `INV_ANTI_INVENTION` du CLI
|
||||
re-résout indépendamment chaque pointeur et compare à ce qui figure dans le
|
||||
run-sheet — impossible de « poser » une valeur à la main.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .deps import DELIVERABLES_ROOT
|
||||
|
||||
# Sentinelle « non résolu » — un placeholder lisible, jamais confondable avec une
|
||||
# vraie valeur (ni un nombre, ni une chaîne métier).
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def placeholder(module: str, file: str, pointer: str) -> str:
|
||||
return f"{{{{{module}:{file}#{pointer}}}}}"
|
||||
|
||||
|
||||
def is_placeholder(value: Any) -> bool:
|
||||
return isinstance(value, str) and value.startswith("{{") and value.endswith("}}")
|
||||
|
||||
|
||||
def artifact_path(module: str, file: str) -> str:
|
||||
"""Chemin absolu de l'artefact `out/` (jamais hors des livrables)."""
|
||||
return os.path.join(DELIVERABLES_ROOT, module, file)
|
||||
|
||||
|
||||
class Loader:
|
||||
"""Charge et met en cache les artefacts amont pour une passe de build."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._errors: dict[str, str] = {}
|
||||
|
||||
def load(self, module: str, file: str) -> Any:
|
||||
key = f"{module}/{file}"
|
||||
if key in self._cache or key in self._errors:
|
||||
return self._cache.get(key, _MISSING)
|
||||
path = artifact_path(module, file)
|
||||
if not os.path.isfile(path):
|
||||
self._errors[key] = "artefact absent"
|
||||
return _MISSING
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
obj = json.load(fh)
|
||||
except (OSError, ValueError) as exc:
|
||||
self._errors[key] = f"JSON invalide : {exc}"
|
||||
return _MISSING
|
||||
self._cache[key] = obj
|
||||
return obj
|
||||
|
||||
def errors(self) -> dict[str, str]:
|
||||
return dict(self._errors)
|
||||
|
||||
|
||||
def _split_pointer(pointer: str) -> list[str]:
|
||||
"""Découpe un pointeur RFC 6901 (`/a/0/b`) en tokens déséchappés."""
|
||||
if pointer == "":
|
||||
return []
|
||||
if not pointer.startswith("/"):
|
||||
raise ValueError(f"pointeur RFC 6901 invalide (doit commencer par '/') : {pointer!r}")
|
||||
tokens = pointer.split("/")[1:]
|
||||
return [t.replace("~1", "/").replace("~0", "~") for t in tokens]
|
||||
|
||||
|
||||
def _descend(obj: Any, token: str) -> Any:
|
||||
"""Un pas de descente : clé d'objet ou index de liste (jamais d'erreur)."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(token, _MISSING)
|
||||
if isinstance(obj, list):
|
||||
if token.lstrip("-").isdigit():
|
||||
idx = int(token)
|
||||
if -len(obj) <= idx < len(obj):
|
||||
return obj[idx]
|
||||
return _MISSING
|
||||
return _MISSING
|
||||
|
||||
|
||||
def resolve(loader: Loader, module: str, file: str, pointer: str) -> tuple[Any, bool]:
|
||||
"""Résout un pointeur en `(valeur, resolu)`.
|
||||
|
||||
`resolu=False` ⇒ `valeur` est un placeholder `{{…}}` (artefact absent / JSON
|
||||
invalide / chemin inexistant). `resolu=True` ⇒ valeur verbatim non `None`.
|
||||
Une valeur littérale `null` dans l'artefact est traitée comme NON résolue :
|
||||
un chiffre « à confirmer » ne doit jamais alimenter un pitch (#6).
|
||||
"""
|
||||
root = loader.load(module, file)
|
||||
if root is _MISSING:
|
||||
return placeholder(module, file, pointer), False
|
||||
node: Any = root
|
||||
for token in _split_pointer(pointer):
|
||||
node = _descend(node, token)
|
||||
if node is _MISSING:
|
||||
return placeholder(module, file, pointer), False
|
||||
if node is None:
|
||||
return placeholder(module, file, pointer), False
|
||||
return node, True
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests du générateur de scénarios démo (run-sheet).
|
||||
|
||||
Couvre : assemblage réel (les artefacts `out/` amont doivent exister et être
|
||||
gated), résolution de pointeurs RFC 6901, cœur anti-invention (#6), invariants,
|
||||
et un lot d'INJECTIONS NÉGATIVES prouvant que chaque garde-fou rougit vraiment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_MOD = os.path.normpath(os.path.join(_HERE, ".."))
|
||||
sys.path.insert(0, _MOD)
|
||||
|
||||
import demo_scenario_gen as gen # noqa: E402
|
||||
from scenlib import builder, evidence # noqa: E402
|
||||
|
||||
|
||||
def _fresh():
|
||||
"""(spec, run_sheet, manifest) construits depuis le dépôt réel."""
|
||||
return gen._assemble(generated_at=None)
|
||||
|
||||
|
||||
class TestBuildReel(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.spec, self.run_sheet, self.manifest = _fresh()
|
||||
|
||||
def test_build_sans_invariant_casse(self):
|
||||
self.assertEqual(gen.check_invariants(self.spec, self.run_sheet, self.manifest), [])
|
||||
|
||||
def test_deux_scenarios_p07_p05(self):
|
||||
ids = {sc["id"] for sc in self.run_sheet["scenarios"]}
|
||||
self.assertEqual(ids, {"S-P07-BANQUIER", "S-P05-CLIENT"})
|
||||
projets = {sc["projet"] for sc in self.run_sheet["scenarios"]}
|
||||
self.assertEqual(projets, {"P05", "P07"})
|
||||
|
||||
def test_audiences_declarees(self):
|
||||
for sc in self.run_sheet["scenarios"]:
|
||||
self.assertIn(sc["audience"], self.spec["audiences"])
|
||||
|
||||
def test_libelles_projets_sources_du_spec(self):
|
||||
for sc in self.run_sheet["scenarios"]:
|
||||
self.assertEqual(sc["projet_libelle"], self.spec["projets"][sc["projet"]])
|
||||
|
||||
def test_beats_ordonnes_et_min_trois(self):
|
||||
for sc in self.run_sheet["scenarios"]:
|
||||
self.assertGreaterEqual(len(sc["beats"]), 3)
|
||||
self.assertEqual([b["ordre"] for b in sc["beats"]],
|
||||
list(range(1, len(sc["beats"]) + 1)))
|
||||
|
||||
def test_orchestration_min_deux_modules(self):
|
||||
for sc in self.run_sheet["scenarios"]:
|
||||
self.assertGreaterEqual(len(set(sc["modules_cites"])), 2)
|
||||
|
||||
def test_toutes_citations_resolues(self):
|
||||
self.assertEqual(self.manifest["counts"]["citations_non_resolues"], 0)
|
||||
for sc in self.run_sheet["scenarios"]:
|
||||
for b in sc["beats"]:
|
||||
for c in b["evidence"]:
|
||||
self.assertTrue(c["resolu"])
|
||||
self.assertFalse(evidence.is_placeholder(c["valeur"]))
|
||||
self.assertIsNotNone(c["valeur"])
|
||||
|
||||
def test_pret_et_couverture_ci(self):
|
||||
self.assertTrue(self.manifest["pret"])
|
||||
self.assertTrue(self.manifest["couverture_ci"]["ok"])
|
||||
self.assertEqual(self.manifest["couverture_ci"]["modules_cites_non_gated"], [])
|
||||
|
||||
def test_pas_auto_citation(self):
|
||||
mods = {c["module"] for sc in self.run_sheet["scenarios"]
|
||||
for b in sc["beats"] for c in b["evidence"]}
|
||||
self.assertNotIn(builder.SELF_MODULE, mods)
|
||||
|
||||
def test_manifest_conforme_schema(self):
|
||||
self.assertEqual(gen.deps.validate(self.manifest, gen._load_schema()), [])
|
||||
|
||||
def test_duree_est_somme_des_beats(self):
|
||||
for sc in self.run_sheet["scenarios"]:
|
||||
self.assertEqual(sc["duree_min"], sum(b["duree_min"] for b in sc["beats"]))
|
||||
|
||||
def test_determinisme(self):
|
||||
_, rs2, mf2 = _fresh()
|
||||
self.assertEqual(self.run_sheet, rs2)
|
||||
self.assertEqual(self.manifest, mf2)
|
||||
|
||||
def test_valeurs_verbatim_depuis_artefacts(self):
|
||||
# Une valeur du run-sheet doit être EXACTEMENT celle du fichier amont.
|
||||
wf = json.load(open(os.path.join(
|
||||
builder.deps.DELIVERABLES_ROOT, "crm/workflow_vente/out/workflow.json"),
|
||||
encoding="utf-8"))
|
||||
beat1 = self.run_sheet["scenarios"][0]["beats"][0]["evidence"][0]
|
||||
self.assertEqual(beat1["valeur"], wf[0]["workflow_name"])
|
||||
|
||||
|
||||
class TestPointeurRFC6901(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.loader = evidence.Loader()
|
||||
|
||||
def test_split_racine_et_echappement(self):
|
||||
self.assertEqual(evidence._split_pointer(""), [])
|
||||
self.assertEqual(evidence._split_pointer("/a/b"), ["a", "b"])
|
||||
self.assertEqual(evidence._split_pointer("/a~1b/c~0d"), ["a/b", "c~d"])
|
||||
|
||||
def test_split_refuse_pointeur_sans_slash(self):
|
||||
with self.assertRaises(ValueError):
|
||||
evidence._split_pointer("a/b")
|
||||
|
||||
def test_descend_cle_index_negatif(self):
|
||||
self.assertEqual(evidence._descend({"x": 1}, "x"), 1)
|
||||
self.assertEqual(evidence._descend([10, 20, 30], "0"), 10)
|
||||
self.assertEqual(evidence._descend([10, 20, 30], "-1"), 30)
|
||||
self.assertIs(evidence._descend([1], "5"), evidence._MISSING)
|
||||
self.assertIs(evidence._descend(42, "x"), evidence._MISSING)
|
||||
|
||||
def test_resolve_valeur_existante(self):
|
||||
val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
|
||||
"out/workflow.json", "/0/document_type")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(val, "OTO Dossier Vente")
|
||||
|
||||
def test_resolve_null_est_non_resolu(self):
|
||||
# `condition: null` dans une transition → placeholder, pas résolu (#6).
|
||||
val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
|
||||
"out/workflow.json", "/0/transitions/0/condition")
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(evidence.is_placeholder(val))
|
||||
|
||||
def test_resolve_artefact_absent(self):
|
||||
val, ok = evidence.resolve(self.loader, "module/inexistant",
|
||||
"out/rien.json", "/x")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(val, "{{module/inexistant:out/rien.json#/x}}")
|
||||
|
||||
def test_resolve_pointeur_manquant(self):
|
||||
val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
|
||||
"out/workflow.json", "/0/champ_absent")
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(evidence.is_placeholder(val))
|
||||
|
||||
def test_is_placeholder(self):
|
||||
self.assertTrue(evidence.is_placeholder("{{a:b#/c}}"))
|
||||
self.assertFalse(evidence.is_placeholder("valeur"))
|
||||
self.assertFalse(evidence.is_placeholder(258))
|
||||
|
||||
|
||||
class TestInjectionsNegatives(unittest.TestCase):
|
||||
"""Chaque garde-fou DOIT rougir sur une dégradation ciblée."""
|
||||
|
||||
def setUp(self):
|
||||
self.spec, self.run_sheet, self.manifest = _fresh()
|
||||
|
||||
def _motifs(self, rs, mf):
|
||||
return gen.check_invariants(self.spec, rs, mf)
|
||||
|
||||
def test_anti_invention_valeur_falsifiee(self):
|
||||
rs = copy.deepcopy(self.run_sheet)
|
||||
rs["scenarios"][0]["beats"][0]["evidence"][0]["valeur"] = "CHIFFRE INVENTÉ"
|
||||
mf = builder.build_manifest(self.spec, rs)
|
||||
self.assertTrue(any("invention/dérive" in m for m in self._motifs(rs, mf)))
|
||||
|
||||
def test_compteur_falsifie(self):
|
||||
mf = copy.deepcopy(self.manifest)
|
||||
mf["counts"]["citations_total"] += 1
|
||||
motifs = self._motifs(self.run_sheet, mf)
|
||||
self.assertTrue(any("counts.citations_total" in m for m in motifs))
|
||||
|
||||
def test_placeholder_incoherent(self):
|
||||
rs = copy.deepcopy(self.run_sheet)
|
||||
c = rs["scenarios"][0]["beats"][0]["evidence"][0]
|
||||
c["resolu"] = False # résolu marqué faux mais valeur reste réelle
|
||||
mf = builder.build_manifest(self.spec, rs)
|
||||
self.assertTrue(any("non résolu sans placeholder" in m
|
||||
or "invention/dérive" in m for m in self._motifs(rs, mf)))
|
||||
|
||||
def test_module_non_gated_detecte(self):
|
||||
# On force couverture_ci en KO → invariant (g) doit rougir.
|
||||
mf = copy.deepcopy(self.manifest)
|
||||
mf["couverture_ci"]["ok"] = False
|
||||
mf["couverture_ci"]["modules_cites_non_gated"] = ["module/hors-ci"]
|
||||
self.assertTrue(any("NON gated" in m for m in self._motifs(self.run_sheet, mf)))
|
||||
|
||||
def test_non_pret_bloque(self):
|
||||
mf = copy.deepcopy(self.manifest)
|
||||
mf["pret"] = False
|
||||
self.assertTrue(any("non prêt" in m for m in self._motifs(self.run_sheet, mf)))
|
||||
|
||||
def test_auto_citation_detectee(self):
|
||||
rs = copy.deepcopy(self.run_sheet)
|
||||
rs["scenarios"][0]["beats"][0]["evidence"][0]["module"] = builder.SELF_MODULE
|
||||
mf = builder.build_manifest(self.spec, rs)
|
||||
self.assertTrue(any("auto-citation" in m or "invention/dérive" in m
|
||||
for m in self._motifs(rs, mf)))
|
||||
|
||||
def test_ordres_non_contigus_detectes(self):
|
||||
rs = copy.deepcopy(self.run_sheet)
|
||||
rs["scenarios"][0]["beats"][0]["ordre"] = 99
|
||||
mf = builder.build_manifest(self.spec, rs)
|
||||
self.assertTrue(any("non contigus" in m for m in self._motifs(rs, mf)))
|
||||
|
||||
def test_schema_rejette_beats_insuffisants(self):
|
||||
mf = copy.deepcopy(self.manifest)
|
||||
mf["scenarios"][0]["beats"] = 1
|
||||
self.assertTrue(gen.deps.validate(mf, gen._load_schema()))
|
||||
|
||||
def test_schema_rejette_id_hors_pattern(self):
|
||||
mf = copy.deepcopy(self.manifest)
|
||||
mf["scenarios"][0]["id"] = "mauvais-id"
|
||||
self.assertTrue(gen.deps.validate(mf, gen._load_schema()))
|
||||
|
||||
|
||||
class TestCLI(unittest.TestCase):
|
||||
def test_validate_retourne_zero(self):
|
||||
self.assertEqual(gen.main(["validate"]), 0)
|
||||
|
||||
def test_build_ecrit_out(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
self.assertEqual(gen.main(["build", "-o", d]), 0)
|
||||
for name in ("run_sheet.json", "MANIFEST.json"):
|
||||
self.assertTrue(os.path.isfile(os.path.join(d, name)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user