[DTP-Worker] Sprint 8 · Générateur Matrice de régression exhaustive (19 suites · 474 tests · gate méta-niveau) (QA · roadmap L74)
Harnais méta-niveau : agrège l'exécution de toutes les suites gated en une matrice + verdict PASS/FAIL et fournit le compte agrégé faisant autorité (N tests verts). Périmètre dérivé du CI (réutilise q4lib/registry.parse_ci · zéro duplication) ; anti-invention (#6) : le plan ne contient aucun compteur de résultat, recomputé à la validation. Enregistré dans l'audit 4Big (18→19 modules · PASS 19/19). run exhaustif : 19/19 suites vertes · 474 tests passés. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -373,10 +373,31 @@ jobs:
|
|||||||
- name: Tests générateur Scénarios démo
|
- name: Tests générateur Scénarios démo
|
||||||
run: python3 -m unittest discover -s tests -v
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Matrice de régression exhaustive (Sprint 8 · QA · roadmap L74 « Regression
|
||||||
|
# tests exhaustifs »). Harnais de MÉTA-NIVEAU : recense TOUTES les suites de
|
||||||
|
# tests gated (dérivées du CI — zéro liste à la main · #5), prouve la
|
||||||
|
# couverture (disque + gate), et fournit — via `run` — le compte agrégé
|
||||||
|
# faisant autorité (« N tests verts »). Anti-invention (#6) : le plan ne
|
||||||
|
# contient AUCUN compteur de résultat ; ceux-ci ne naissent qu'à l'exécution.
|
||||||
|
# Ce job teste le harnais (parseur unittest, découverte, invariants) sans
|
||||||
|
# ré-exécuter les suites (le gate le fait déjà, job par job). stdlib pur.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
qa-regression-tests:
|
||||||
|
name: QA · Matrice de régression exhaustive (Sprint 8)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: 05_deliverables_mvp/qa/regression
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Tests harnais de régression
|
||||||
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
gate:
|
gate:
|
||||||
name: Gate qualité (agrégat)
|
name: Gate qualité (agrégat)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests]
|
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests, qa-regression-tests]
|
||||||
steps:
|
steps:
|
||||||
- name: Résultat
|
- name: Résultat
|
||||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
# Activity Log · 2026-07-30 · Claude Code DTP
|
# Activity Log · 2026-07-30 · Claude Code DTP
|
||||||
|
|
||||||
|
## Session `20260730_105732` (session 22)
|
||||||
|
|
||||||
|
**Tâche** : **Sprint 8 · QA** — Générateur de la **Matrice de régression
|
||||||
|
exhaustive** (roadmap Sprint 8 · QA « **Regression tests exhaustifs** »). Sprint 7
|
||||||
|
réalisable en repo étant clos (L68/L69 livrés · L67 polish `otov7.com` dépend du
|
||||||
|
site live · #8), on enchaîne sur le premier volet Sprint 8 réalisable en repo.
|
||||||
|
|
||||||
|
**Décision d'architecture** : harnais de **MÉTA-NIVEAU** + gate, pas un simple
|
||||||
|
runner. Il agrège l'exécution de **toutes** les suites gated en **une matrice +
|
||||||
|
verdict PASS/FAIL** et fournit le **compte agrégé faisant autorité** (« N tests
|
||||||
|
verts »). **Distinct de l'audit 4Big** (non redondant · #5) : l'audit note la
|
||||||
|
qualité **statique** par module ; ce harnais prouve que chaque suite **s'exécute
|
||||||
|
au vert**. Deux artefacts : `build` → plan déterministe commité (**aucun compteur
|
||||||
|
de résultat**) ; `run` → matrice live non déterministe (non commitée).
|
||||||
|
|
||||||
|
**Zéro invention (#6)** : périmètre **dérivé du CI** via `q4lib/registry.parse_ci`
|
||||||
|
(réutilisé · zéro duplication) ; les comptes de disque sont **recomputés** à la
|
||||||
|
validation (INV5) → un compte figé/fabriqué est détecté ; le nombre de tests verts
|
||||||
|
n'existe **qu'à l'exécution**. **Séparation des pouvoirs (ISA 315)** : le harnais
|
||||||
|
s'exclut lui-même (pas de récursion, pas d'auto-comptage).
|
||||||
|
|
||||||
|
**Fichiers créés** — `05_deliverables_mvp/qa/regression/` :
|
||||||
|
- `regression_spec.json` (contrat · planchers structurels · 0 chiffre métier)
|
||||||
|
- `reglib/{__init__,deps,discovery,runner,builder}.py` (réutilise `parse_ci` +
|
||||||
|
validateur Publiciste ; parseur PUR de sortie unittest ; plan + matrice)
|
||||||
|
- `regression_gen.py` (CLI `build`/`validate`/`run` · **8 invariants**)
|
||||||
|
- `regression.schema.json` (contrat de sortie draft-07)
|
||||||
|
- `out/{regression_plan,MANIFEST}.json` (hand-off) · `tests/test_regression.py`
|
||||||
|
(**24 tests** dont 3 exécutions réelles en tmpdir + 6 injections d'invariant) ·
|
||||||
|
`README.md` · `.gitignore` (exclut la sortie non déterministe de `run`)
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
- `.gitea/workflows/ci.yml` : job `qa-regression-tests` + ajout au `gate`.
|
||||||
|
- `qa/audit_4big/` : enregistrement du module `qa/regression` (couverture
|
||||||
|
bijective **18 → 19 modules** · PASS 19/19 à 100) ; `out/` régénéré ; test
|
||||||
|
rendu robuste (`len(spec['modules'])` au lieu du littéral `18`).
|
||||||
|
|
||||||
|
**Résultat `run` exhaustif** : **19/19 suites vertes · 474 tests passés · 0 échec ·
|
||||||
|
0 erreur** — compte agrégé reproductible en une commande.
|
||||||
|
|
||||||
|
**Vérifs** : 24/24 tests module ; 34/34 audit_4big ; gate CI local vert (guard +
|
||||||
|
JSON + docs + YAML) ; builds déterministes.
|
||||||
|
|
||||||
|
**Hors périmètre worker (VPS · #8)** : planifier `run` sur le runner CI/VPS +
|
||||||
|
publier le compte agrégé dans le desk ERPNext → agent QA / DevOps.
|
||||||
|
|
||||||
|
**Détail complet** : voir
|
||||||
|
[`05_deliverables_mvp/daily_reports/2026-07-30-session22.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session22.md).
|
||||||
|
|
||||||
|
**Auto-score 4Big** : 96/100.
|
||||||
|
|
||||||
## Session `20260730_102730` (session 21)
|
## Session `20260730_102730` (session 21)
|
||||||
|
|
||||||
**Tâche** : **Sprint 7 · CRM + Faisabilité** — Générateur des **Scénarios démo
|
**Tâche** : **Sprint 7 · CRM + Faisabilité** — Générateur des **Scénarios démo
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Rapport de session · 2026-07-30 · session 22
|
||||||
|
|
||||||
|
## Tâche
|
||||||
|
|
||||||
|
**Sprint 8 · QA — Générateur de la Matrice de régression exhaustive**
|
||||||
|
(roadmap Sprint 8 · QA « **Regression tests exhaustifs** »).
|
||||||
|
|
||||||
|
Sprint 7 réalisable en repo est clos (L69 audit 4Big + L68 scénarios démo livrés
|
||||||
|
sessions 20–21 ; L67 polish `otov7.com` dépend du site live · hors périmètre
|
||||||
|
worker · #8). On enchaîne donc sur le premier volet Sprint 8 réalisable en repo.
|
||||||
|
|
||||||
|
## Décision d'architecture
|
||||||
|
|
||||||
|
Harnais de **MÉTA-NIVEAU** + **gate**, pas un simple runner. Il agrège
|
||||||
|
l'exécution de **toutes** les suites de tests gated du mandat en **une matrice +
|
||||||
|
un verdict PASS/FAIL** et fournit le **compte agrégé faisant autorité** (« N tests
|
||||||
|
verts ») — celui que les rapports quotidiens citaient jusqu'ici à la main.
|
||||||
|
|
||||||
|
**Distinct de l'audit 4Big (non redondant · #5)** : l'audit note la **qualité
|
||||||
|
statique** par module (doc / schéma / CLI / hand-off) ; ce harnais prouve que
|
||||||
|
chaque suite **s'exécute au vert** et calcule le **total**. Deux axes
|
||||||
|
complémentaires.
|
||||||
|
|
||||||
|
**Deux artefacts, deux natures :**
|
||||||
|
- `build` → **plan** déterministe (`out/regression_plan.json`, commité) :
|
||||||
|
recensement exhaustif des suites (chemin, jobs, gate, fichiers/méthodes
|
||||||
|
`test_*`). **Aucun compteur de résultat** → diffable, re-générable.
|
||||||
|
- `run` → **matrice live** : exécute réellement chaque suite (subprocess
|
||||||
|
`unittest`, comme le CI), agrège les compteurs + verdict. **Non déterministe**
|
||||||
|
(temps machine) → **non commitée** (`.gitignore`).
|
||||||
|
|
||||||
|
## Zéro invention · zéro duplication (#5 · #6)
|
||||||
|
|
||||||
|
- **Périmètre dérivé du CI** : les suites proviennent de `.gitea/workflows/ci.yml`
|
||||||
|
via `q4lib/registry.parse_ci` (**réutilisé** de l'audit 4Big — une seule source
|
||||||
|
de vérité). Tout job de test ajouté au CI entre automatiquement dans la matrice.
|
||||||
|
- **Anti-invention** : le plan ne contient **aucun** compteur de résultat ; les
|
||||||
|
comptes de disque sont **recomputés** à la validation (INV5) → un compte figé /
|
||||||
|
fabriqué est détecté. Le nombre de tests verts n'existe **qu'à l'exécution**.
|
||||||
|
- **Séparation des pouvoirs (ISA 315)** : le harnais **s'exclut lui-même** — pas
|
||||||
|
d'auto-exécution (évite la récursion), pas de comptage de soi.
|
||||||
|
|
||||||
|
## Fichiers créés — `05_deliverables_mvp/qa/regression/`
|
||||||
|
|
||||||
|
- `regression_spec.json` (contrat · planchers structurels · 0 chiffre métier)
|
||||||
|
- `reglib/{__init__,deps,discovery,runner,builder}.py`
|
||||||
|
- `deps` : réutilise `parse_ci` (audit 4Big) + validateur maison Publiciste
|
||||||
|
- `discovery` : dérive les suites du CI + faits de disque + preuve de couverture
|
||||||
|
- `runner` : subprocess unittest + **parseur PUR** de la sortie (OK/FAILED/skip)
|
||||||
|
- `builder` : plan déterministe (`build`) + matrice live (`run`)
|
||||||
|
- `regression_gen.py` (CLI `build` / `validate` / `run` · **8 invariants**)
|
||||||
|
- `regression.schema.json` (contrat de sortie draft-07)
|
||||||
|
- `out/{regression_plan,MANIFEST}.json` (hand-off) · `tests/test_regression.py`
|
||||||
|
(**24 tests** dont 3 exécutions réelles en tmpdir + 6 injections d'invariant)
|
||||||
|
- `README.md` · `.gitignore` (exclut la sortie non déterministe de `run`)
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `.gitea/workflows/ci.yml` : job `qa-regression-tests` + ajout au `gate`.
|
||||||
|
- `qa/audit_4big/` : enregistrement du module `qa/regression` dans le registre
|
||||||
|
(`quality_spec.json`) → couverture bijective **18 → 19 modules** (verdict **PASS
|
||||||
|
19/19 à 100**) ; `out/` régénéré ; test `test_real_build_passes_gate` rendu
|
||||||
|
**robuste** (`len(spec['modules'])` au lieu du littéral `18`) — plus de casse à
|
||||||
|
chaque nouveau livrable.
|
||||||
|
|
||||||
|
## Vérifications
|
||||||
|
|
||||||
|
- **24/24** tests du module ; **34/34** audit_4big (compte dérivé du spec).
|
||||||
|
- **`run` exhaustif : 19/19 suites vertes · 474 tests passés · 0 échec · 0
|
||||||
|
erreur** — compte agrégé faisant autorité, reproductible en une commande.
|
||||||
|
- Gate CI local vert : `guard_constraints.sh` · `validate_json.sh` ·
|
||||||
|
`check_docs.sh` · `ci.yml` YAML valide.
|
||||||
|
- Builds déterministes (plan identique en double build).
|
||||||
|
|
||||||
|
## Hors périmètre worker (VPS · #8)
|
||||||
|
|
||||||
|
Planifier `run` sur le runner CI/VPS + publier le compte agrégé dans le desk
|
||||||
|
ERPNext / le pipeline de release → agent **QA / DevOps**. Le mode `run` reste
|
||||||
|
manuel (non déterministe · non gated push/PR), comme la baseline E2E.
|
||||||
|
|
||||||
|
## Auto-score 4Big
|
||||||
|
|
||||||
|
**96/100**.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"audit": "OTO QA · Audit 4Big Qualité",
|
"audit": "OTO QA · Audit 4Big Qualité",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"verdict": "PASS",
|
"verdict": "PASS",
|
||||||
"modules_audited": 18,
|
"modules_audited": 19,
|
||||||
"min_score": 100,
|
"min_score": 100,
|
||||||
"pass_score": 95,
|
"pass_score": 95,
|
||||||
"coverage_ok": true,
|
"coverage_ok": true,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"pass_score": 95,
|
"pass_score": 95,
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"ci_modules_count": 18,
|
"ci_modules_count": 19,
|
||||||
"registry_modules_count": 18,
|
"registry_modules_count": 19,
|
||||||
"missing_in_registry": [],
|
"missing_in_registry": [],
|
||||||
"missing_in_ci": [],
|
"missing_in_ci": [],
|
||||||
"not_in_gate": [],
|
"not_in_gate": [],
|
||||||
@@ -759,11 +759,53 @@
|
|||||||
"evidence": "MANIFEST + 1 artefact(s) JSON valides"
|
"evidence": "MANIFEST + 1 artefact(s) JSON valides"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qa-regression",
|
||||||
|
"path": "qa/regression",
|
||||||
|
"sprint": "S8",
|
||||||
|
"archetype": "generator",
|
||||||
|
"applicable_weight": 100,
|
||||||
|
"earned_weight": 100,
|
||||||
|
"score": 100,
|
||||||
|
"verdict": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"criterion": "DOC",
|
||||||
|
"weight": 20,
|
||||||
|
"passed": true,
|
||||||
|
"evidence": "README.md (3710 octets)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "CONTRAT",
|
||||||
|
"weight": 20,
|
||||||
|
"passed": true,
|
||||||
|
"evidence": "regression.schema.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "TESTS",
|
||||||
|
"weight": 25,
|
||||||
|
"passed": true,
|
||||||
|
"evidence": "24 méthodes test_* dans 1 fichier(s) (seuil 8)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "CLI",
|
||||||
|
"weight": 15,
|
||||||
|
"passed": true,
|
||||||
|
"evidence": "regression_gen.py (argparse + __main__)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "HANDOFF",
|
||||||
|
"weight": 20,
|
||||||
|
"passed": true,
|
||||||
|
"evidence": "MANIFEST + 1 artefact(s) JSON valides"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"totals": {
|
"totals": {
|
||||||
"modules": 18,
|
"modules": 19,
|
||||||
"pass": 18,
|
"pass": 19,
|
||||||
"fail": 0,
|
"fail": 0,
|
||||||
"min_score": 100,
|
"min_score": 100,
|
||||||
"max_score": 100,
|
"max_score": 100,
|
||||||
|
|||||||
@@ -78,7 +78,8 @@
|
|||||||
{ "id": "qa-audit-5d", "path": "qa/audit_5d", "sprint": "S5", "archetype": "generator", "source": "roadmap Sprint 5 · Audit 5D conformité · ci.yml qa-audit-5d-tests" },
|
{ "id": "qa-audit-5d", "path": "qa/audit_5d", "sprint": "S5", "archetype": "generator", "source": "roadmap Sprint 5 · Audit 5D conformité · ci.yml qa-audit-5d-tests" },
|
||||||
{ "id": "seo", "path": "seo", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · SEO trilingue mots-clés/schema.org/hreflang · ci.yml seo-tests" },
|
{ "id": "seo", "path": "seo", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · SEO trilingue mots-clés/schema.org/hreflang · ci.yml seo-tests" },
|
||||||
{ "id": "frontend-chat-otoia", "path": "frontend/chat_otoia", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · Chat OTOIA embarqué (Custom Block) · ci.yml chat-otoia-tests" },
|
{ "id": "frontend-chat-otoia", "path": "frontend/chat_otoia", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · Chat OTOIA embarqué (Custom Block) · ci.yml chat-otoia-tests" },
|
||||||
{ "id": "demo-scenarios", "path": "demo/scenarios", "sprint": "S7", "archetype": "generator", "source": "roadmap Sprint 7 · Scénarios démo (P07 banquier / P05 client) · ci.yml demo-scenario-tests" }
|
{ "id": "demo-scenarios", "path": "demo/scenarios", "sprint": "S7", "archetype": "generator", "source": "roadmap Sprint 7 · Scénarios démo (P07 banquier / P05 client) · ci.yml demo-scenario-tests" },
|
||||||
|
{ "id": "qa-regression", "path": "qa/regression", "sprint": "S8", "archetype": "generator", "source": "roadmap Sprint 8 · Regression tests exhaustifs (matrice méta-niveau) · ci.yml qa-regression-tests" }
|
||||||
],
|
],
|
||||||
"notes": [
|
"notes": [
|
||||||
"Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).",
|
"Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).",
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ class BuildTest(unittest.TestCase):
|
|||||||
def test_real_build_passes_gate(self):
|
def test_real_build_passes_gate(self):
|
||||||
self.assertEqual(self.report["verdict"], "PASS")
|
self.assertEqual(self.report["verdict"], "PASS")
|
||||||
self.assertEqual(self.report["totals"]["fail"], 0)
|
self.assertEqual(self.report["totals"]["fail"], 0)
|
||||||
self.assertEqual(self.report["totals"]["modules"], 18)
|
self.assertEqual(self.report["totals"]["modules"], len(self.spec["modules"]))
|
||||||
self.assertGreaterEqual(self.report["totals"]["min_score"], 95)
|
self.assertGreaterEqual(self.report["totals"]["min_score"], 95)
|
||||||
|
|
||||||
def test_invariants_clean_on_real_report(self):
|
def test_invariants_clean_on_real_report(self):
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
# Sortie du mode `run` : non déterministe (temps machine) → jamais commitée.
|
||||||
|
out/regression_run.json
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# QA · Matrice de régression exhaustive (Sprint 8)
|
||||||
|
|
||||||
|
> Roadmap **Sprint 8 · QA** : « **Regression tests exhaustifs** ».
|
||||||
|
> Harnais de **méta-niveau** : agrège l'exécution de **toutes** les suites de
|
||||||
|
> tests gated du mandat en **une matrice unique + un verdict PASS/FAIL**, et
|
||||||
|
> fournit le **compte agrégé faisant autorité** (« N tests verts ») — celui que
|
||||||
|
> les rapports quotidiens citaient jusqu'ici à la main.
|
||||||
|
|
||||||
|
## Pourquoi ce livrable
|
||||||
|
|
||||||
|
Le mandat compte des dizaines de suites `tests/` (une par module). Le gate CI les
|
||||||
|
exécute **job par job** ; mais aucun artefact ne prouvait, **en une commande**,
|
||||||
|
que **l'ensemble** tourne au vert, ni ne produisait le **total** de tests verts de
|
||||||
|
façon reproductible. Ce harnais formalise cette régression exhaustive.
|
||||||
|
|
||||||
|
Il est **complémentaire**, pas redondant, avec l'**audit 4Big**
|
||||||
|
([`../audit_4big`](../audit_4big/README.md)) :
|
||||||
|
|
||||||
|
| | Audit 4Big | Matrice de régression |
|
||||||
|
|---|---|---|
|
||||||
|
| Axe | **Qualité statique** par module (doc, schéma, CLI, hand-off) | **Exécution** : chaque suite tourne-t-elle au **vert** ? |
|
||||||
|
| Sortie | Note ≥ 95/100 par module | Compte agrégé « N tests verts » + verdict |
|
||||||
|
| Nature | Lecture du système de fichiers | `build` (statique) + `run` (exécution réelle) |
|
||||||
|
|
||||||
|
## Zéro invention · zéro duplication (CLAUDE.md #5 · #6)
|
||||||
|
|
||||||
|
- **Périmètre dérivé du CI, pas listé à la main** : les suites proviennent de
|
||||||
|
`.gitea/workflows/ci.yml` via `q4lib/registry.parse_ci` (réutilisé de l'audit
|
||||||
|
4Big — une seule source de vérité). Tout job de test ajouté au CI entre
|
||||||
|
automatiquement dans la matrice ; toute suite retirée en sort.
|
||||||
|
- **Anti-invention** : le `build` (plan) ne contient **aucun compteur de
|
||||||
|
résultat** — seulement des **faits de disque** (fichiers / méthodes `test_*`) et
|
||||||
|
de CI (job, gate). Le nombre de tests verts n'existe **qu'à l'exécution** (`run`),
|
||||||
|
parsé de la sortie réelle de `unittest`. Un invariant refuse tout compteur figé.
|
||||||
|
- **Séparation des pouvoirs (ISA 315)** : le harnais **s'exclut lui-même** — il ne
|
||||||
|
s'auto-exécute pas (évite la récursion) et ne se compte pas dans le total.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 05_deliverables_mvp/qa/regression
|
||||||
|
|
||||||
|
# Recensement déterministe des suites gated (commité) :
|
||||||
|
python3 regression_gen.py build # → out/regression_plan.json + MANIFEST
|
||||||
|
|
||||||
|
# Gate : schéma + invariants + couverture prouvée (ce que fait le CI) :
|
||||||
|
python3 regression_gen.py validate
|
||||||
|
|
||||||
|
# Régression EXHAUSTIVE (exécute réellement toutes les suites) — local/DevOps :
|
||||||
|
python3 regression_gen.py run # → out/regression_run.json (non commité)
|
||||||
|
```
|
||||||
|
|
||||||
|
`run` renvoie un code de sortie ≠ 0 si **une seule** suite est rouge → utilisable
|
||||||
|
comme garde de release. Sa sortie est **non déterministe** (temps machine) donc
|
||||||
|
**non commitée** (voir `.gitignore`).
|
||||||
|
|
||||||
|
## Contrat de sortie (plan)
|
||||||
|
|
||||||
|
- `regression.schema.json` — draft-07 (sous-ensemble), validé par le validateur
|
||||||
|
maison Publiciste (zéro pip).
|
||||||
|
- Invariants (`regression_gen.py:check_invariants`) : schéma · anti-invention
|
||||||
|
(INV2) · self exclu (INV3) · couverture prouvée (INV4) · recompute disque
|
||||||
|
(INV5) · planchers structurels (INV6) · totaux recomputés (INV7) · verdict
|
||||||
|
gate (INV8).
|
||||||
|
|
||||||
|
## Hand-off VPS (hors périmètre worker · #8)
|
||||||
|
|
||||||
|
Planifier `run` sur le runner CI/VPS et publier le compte agrégé dans le desk
|
||||||
|
ERPNext / le pipeline de release → agent **QA / DevOps**. Le mode `run` reste
|
||||||
|
**manuel** (non déterministe · non gated push/PR), comme la baseline E2E.
|
||||||
|
|
||||||
|
## Auto-score 4Big
|
||||||
|
|
||||||
|
**96/100** — recensement exhaustif prouvé (couverture dérivée du CI), anti-invention
|
||||||
|
strict (aucun compteur figé), déterminisme, réutilisation sans duplication.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"generated_from": "regression_spec.json",
|
||||||
|
"generator": "regression_gen.py",
|
||||||
|
"matrix": "OTO QA · Matrice de régression exhaustive",
|
||||||
|
"version": "1.0",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"suites": 19,
|
||||||
|
"test_methods": 474,
|
||||||
|
"coverage_ok": true,
|
||||||
|
"artifacts": [
|
||||||
|
"regression_plan.json"
|
||||||
|
],
|
||||||
|
"roadmap": "Sprint 8 · QA · Regression tests exhaustifs",
|
||||||
|
"hors_perimetre_worker": "Exécution planifiée de `run` sur le runner CI/VPS + publication du compte agrégé dans le desk ERPNext / le pipeline de release → agent QA / DevOps (#8). Le mode `run` reste manuel (non déterministe)."
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"matrix": "OTO QA · Matrice de régression exhaustive",
|
||||||
|
"version": "1.0",
|
||||||
|
"reference_cadre": "Roadmap Sprint 8 · QA « Regression tests exhaustifs » · CLAUDE.md #5 (zéro régression silencieuse · niveau 4Big)",
|
||||||
|
"self_module": "qa/regression",
|
||||||
|
"thresholds": {
|
||||||
|
"min_test_files": 1,
|
||||||
|
"min_methods_per_suite": 8
|
||||||
|
},
|
||||||
|
"discover_cmd": [
|
||||||
|
"python3",
|
||||||
|
"-m",
|
||||||
|
"unittest",
|
||||||
|
"discover",
|
||||||
|
"-s",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"coverage": {
|
||||||
|
"ok": true,
|
||||||
|
"suites_count": 19,
|
||||||
|
"gated_in_ci": 20,
|
||||||
|
"self_module_excluded": "qa/regression",
|
||||||
|
"self_module_gated": true,
|
||||||
|
"missing_tests_dir": [],
|
||||||
|
"not_in_gate": []
|
||||||
|
},
|
||||||
|
"suites": [
|
||||||
|
{
|
||||||
|
"id": "crm.commissions",
|
||||||
|
"path": "crm/commissions",
|
||||||
|
"jobs": [
|
||||||
|
"crm-commissions-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "crm.dossier_vente",
|
||||||
|
"path": "crm/dossier_vente",
|
||||||
|
"jobs": [
|
||||||
|
"crm-dossier-vente-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "crm.workflow_vente",
|
||||||
|
"path": "crm/workflow_vente",
|
||||||
|
"jobs": [
|
||||||
|
"crm-workflow-vente-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "demo.scenarios",
|
||||||
|
"path": "demo/scenarios",
|
||||||
|
"jobs": [
|
||||||
|
"demo-scenario-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "faisabilite.bancable",
|
||||||
|
"path": "faisabilite/bancable",
|
||||||
|
"jobs": [
|
||||||
|
"bancable-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "faisabilite.generator",
|
||||||
|
"path": "faisabilite/generator",
|
||||||
|
"jobs": [
|
||||||
|
"faisabilite-gen-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fiscal.ecf_dgii",
|
||||||
|
"path": "fiscal/ecf_dgii",
|
||||||
|
"jobs": [
|
||||||
|
"fiscal-ecf-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend.chat_otoia",
|
||||||
|
"path": "frontend/chat_otoia",
|
||||||
|
"jobs": [
|
||||||
|
"chat-otoia-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend.portails",
|
||||||
|
"path": "frontend/portails",
|
||||||
|
"jobs": [
|
||||||
|
"frontend-portails-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "legal.confotur",
|
||||||
|
"path": "legal/confotur",
|
||||||
|
"jobs": [
|
||||||
|
"legal-confotur-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "publiciste",
|
||||||
|
"path": "publiciste",
|
||||||
|
"jobs": [
|
||||||
|
"publiciste-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qa.audit_4big",
|
||||||
|
"path": "qa/audit_4big",
|
||||||
|
"jobs": [
|
||||||
|
"qa-audit-4big-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qa.audit_5d",
|
||||||
|
"path": "qa/audit_5d",
|
||||||
|
"jobs": [
|
||||||
|
"qa-audit-5d-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rbac",
|
||||||
|
"path": "rbac",
|
||||||
|
"jobs": [
|
||||||
|
"rbac-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rbac.apply_plan",
|
||||||
|
"path": "rbac/apply_plan",
|
||||||
|
"jobs": [
|
||||||
|
"rbac-applyplan-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rbac.fixtures_gen",
|
||||||
|
"path": "rbac/fixtures_gen",
|
||||||
|
"jobs": [
|
||||||
|
"rbac-fixtures-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rbac.roleprofile_gen",
|
||||||
|
"path": "rbac/roleprofile_gen",
|
||||||
|
"jobs": [
|
||||||
|
"rbac-roleprofile-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rbac.userperm_gen",
|
||||||
|
"path": "rbac/userperm_gen",
|
||||||
|
"jobs": [
|
||||||
|
"rbac-userperm-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seo",
|
||||||
|
"path": "seo",
|
||||||
|
"jobs": [
|
||||||
|
"seo-tests"
|
||||||
|
],
|
||||||
|
"in_gate": true,
|
||||||
|
"has_tests_dir": true,
|
||||||
|
"test_files": 1,
|
||||||
|
"test_methods": 36
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"under_threshold": [],
|
||||||
|
"totals": {
|
||||||
|
"suites": 19,
|
||||||
|
"test_files": 19,
|
||||||
|
"test_methods": 474,
|
||||||
|
"min_methods": 10,
|
||||||
|
"under_threshold": 0
|
||||||
|
},
|
||||||
|
"verdict": "PASS",
|
||||||
|
"notes": [
|
||||||
|
"Harnais de MÉTA-NIVEAU : agrège l'exécution de TOUTES les suites de tests gated du mandat en une seule matrice + un verdict PASS/FAIL. Sprint 8 · QA « Regression tests exhaustifs ».",
|
||||||
|
"Périmètre PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de .gitea/workflows/ci.yml (réutilise q4lib/registry.parse_ci — zéro duplication · #5), jamais listées à la main. Tout job de test ajouté au CI entre automatiquement dans la matrice.",
|
||||||
|
"Séparation des pouvoirs (ISA 315) : le harnais s'exclut lui-même (self_module) — il ne s'auto-exécute pas (évite la récursion) et ne se compte pas dans le total régression.",
|
||||||
|
"ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultats (0 test vert écrit à la main) ; seuls des FAITS de disque (fichiers/méthodes test_*) et de CI (job, gate) y figurent. Les compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie réelle de unittest.",
|
||||||
|
"Distinct de l'audit 4Big : audit_4big note la QUALITÉ statique par module (doc/schema/CLI/hand-off) ; ce harnais prouve que chaque suite S'EXÉCUTE AU VERT et fournit le compte agrégé faisant autorité (« N tests verts »). Axes complémentaires, non redondants."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""reglib · briques de la matrice de régression exhaustive (Sprint 8 · QA).
|
||||||
|
|
||||||
|
Harnais de MÉTA-NIVEAU : il agrège l'exécution de TOUTES les suites de tests
|
||||||
|
gated du mandat en une matrice unique + un verdict PASS/FAIL, et fournit le
|
||||||
|
compte agrégé faisant autorité (« N tests verts ») — celui que les rapports
|
||||||
|
citaient jusqu'ici à la main.
|
||||||
|
|
||||||
|
deps → réutilise q4lib/registry.parse_ci + le validateur maison
|
||||||
|
Publiciste (zéro pip · zéro duplication · #5)
|
||||||
|
discovery → dérive les suites depuis le CI + faits de disque (fichiers /
|
||||||
|
méthodes test_*) + preuve de couverture (disque + gate)
|
||||||
|
runner → exécute une suite (subprocess unittest) + parseur PUR de la
|
||||||
|
sortie unittest (Ran/OK/FAILED) → compteurs réels (#6)
|
||||||
|
builder → assemble le plan déterministe (build) et la matrice live (run)
|
||||||
|
"""
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Assemblage : plan déterministe (`build`) et matrice live (`run`).
|
||||||
|
|
||||||
|
Deux artefacts, deux natures :
|
||||||
|
|
||||||
|
build → PLAN (out/regression_plan.json) : recensement EXHAUSTIF et
|
||||||
|
DÉTERMINISTE des suites gated (chemin, jobs, gate, fichiers/méthodes
|
||||||
|
test_*). Aucun compteur de résultat → diffable, re-générable, commité.
|
||||||
|
Verdict PASS ssi couverture prouvée + chaque suite ≥ planchers.
|
||||||
|
|
||||||
|
run → MATRICE live : exécute réellement chaque suite et agrège les compteurs
|
||||||
|
(ran/passed/failures/errors/skipped) + verdict global. Non déterministe
|
||||||
|
(dépend de la machine) → non commité (voir .gitignore), c'est la sortie
|
||||||
|
qui fait autorité sur « N tests verts ».
|
||||||
|
|
||||||
|
Aucune date/horodatage → build reproductible (gate CI stable)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import discovery, runner
|
||||||
|
from .deps import DELIVERABLES_ROOT
|
||||||
|
|
||||||
|
MATRIX_NAME = "OTO QA · Matrice de régression exhaustive"
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(spec: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Plan déterministe des suites gated (sans exécution)."""
|
||||||
|
suites = discovery.discover_suites(spec)
|
||||||
|
coverage = discovery.coverage_report(spec, suites)
|
||||||
|
th = spec["thresholds"]
|
||||||
|
min_files = th["min_test_files"]
|
||||||
|
min_methods = th["min_methods_per_suite"]
|
||||||
|
|
||||||
|
under = [
|
||||||
|
s["path"] for s in suites
|
||||||
|
if s["test_files"] < min_files or s["test_methods"] < min_methods
|
||||||
|
]
|
||||||
|
totals = {
|
||||||
|
"suites": len(suites),
|
||||||
|
"test_files": sum(s["test_files"] for s in suites),
|
||||||
|
"test_methods": sum(s["test_methods"] for s in suites),
|
||||||
|
"min_methods": min((s["test_methods"] for s in suites), default=0),
|
||||||
|
"under_threshold": len(under),
|
||||||
|
}
|
||||||
|
verdict = "PASS" if (coverage["ok"] and not under) else "FAIL"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"matrix": MATRIX_NAME,
|
||||||
|
"version": spec["version"],
|
||||||
|
"reference_cadre": spec["reference_cadre"],
|
||||||
|
"self_module": spec["self_module"],
|
||||||
|
"thresholds": {
|
||||||
|
"min_test_files": min_files,
|
||||||
|
"min_methods_per_suite": min_methods,
|
||||||
|
},
|
||||||
|
"discover_cmd": list(spec["discover_cmd"]),
|
||||||
|
"coverage": coverage,
|
||||||
|
"suites": suites,
|
||||||
|
"under_threshold": under,
|
||||||
|
"totals": totals,
|
||||||
|
"verdict": verdict,
|
||||||
|
"notes": spec.get("notes", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_matrix(spec: dict[str, Any], *, python: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Exécute réellement toutes les suites du plan et agrège les compteurs.
|
||||||
|
|
||||||
|
Non déterministe (temps, machine) → destiné à un artefact non commité."""
|
||||||
|
suites = discovery.discover_suites(spec)
|
||||||
|
discover_cmd = list(spec["discover_cmd"])
|
||||||
|
# `python3 -m unittest ...` → on retire le binaire, on garde les args.
|
||||||
|
cmd_args = discover_cmd[1:] if discover_cmd and discover_cmd[0].startswith("python") \
|
||||||
|
else discover_cmd
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for s in suites:
|
||||||
|
abs_path = os.path.join(DELIVERABLES_ROOT, s["path"])
|
||||||
|
res = runner.run_suite(abs_path, python=python, discover_cmd=cmd_args)
|
||||||
|
results.append({
|
||||||
|
"id": s["id"],
|
||||||
|
"path": s["path"],
|
||||||
|
"expected_methods": s["test_methods"],
|
||||||
|
**res,
|
||||||
|
})
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"suites": len(results),
|
||||||
|
"green": sum(1 for r in results if r["ok"]),
|
||||||
|
"red": sum(1 for r in results if not r["ok"]),
|
||||||
|
"ran": sum(r["ran"] for r in results),
|
||||||
|
"passed": sum(r["passed"] for r in results),
|
||||||
|
"failures": sum(r["failures"] for r in results),
|
||||||
|
"errors": sum(r["errors"] for r in results),
|
||||||
|
"skipped": sum(r["skipped"] for r in results),
|
||||||
|
}
|
||||||
|
verdict = "PASS" if totals["red"] == 0 and totals["suites"] > 0 else "FAIL"
|
||||||
|
return {
|
||||||
|
"matrix": MATRIX_NAME,
|
||||||
|
"version": spec["version"],
|
||||||
|
"self_module": spec["self_module"],
|
||||||
|
"suites": results,
|
||||||
|
"totals": totals,
|
||||||
|
"verdict": verdict,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
|
||||||
|
|
||||||
|
Le harnais de régression ne redéfinit RIEN qui existe ailleurs :
|
||||||
|
|
||||||
|
- `parse_ci` : le parseur de `.gitea/workflows/ci.yml` de l'auditeur 4Big
|
||||||
|
(`qa/audit_4big/q4lib/registry.py`) — la SEULE autorité sur les
|
||||||
|
jobs de test réellement gated. On le réutilise pour DÉRIVER
|
||||||
|
l'ensemble des suites de la matrice (jamais une liste à la main)
|
||||||
|
et pour prouver que chaque suite alimente bien le job `gate`.
|
||||||
|
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
|
||||||
|
sous-ensemble) — le plan est validé SANS pip, car le gate CI
|
||||||
|
Gitea Actions tourne sans réseau (CLAUDE.md #2).
|
||||||
|
|
||||||
|
Import par manipulation de `sys.path` (idiome des modules voisins :
|
||||||
|
`q4lib/deps.py`, `scenlib/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__))
|
||||||
|
# qa/regression/reglib → 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
|
||||||
|
parse_ci = registry.parse_ci
|
||||||
|
|
||||||
|
# Racine des livrables, exposée aux autres modules reglib.
|
||||||
|
DELIVERABLES_ROOT = _DELIVERABLES
|
||||||
|
|
||||||
|
__all__ = ["validate", "parse_ci", "DELIVERABLES_ROOT"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Découverte des suites de tests + preuve de couverture (faits, pas déclaration).
|
||||||
|
|
||||||
|
Les suites de la matrice sont DÉRIVÉES du CI Gitea (`parse_ci`), jamais listées à
|
||||||
|
la main : le périmètre « exhaustif » est donc auto-prouvé — tout job de test
|
||||||
|
ajouté au CI entre dans la matrice, toute suite retirée en sort. Pour chaque suite
|
||||||
|
on lit ensuite des FAITS de disque (fichiers `test_*.py`, méthodes `def test_`) —
|
||||||
|
aucun compteur de résultat n'est inventé ici (#6).
|
||||||
|
|
||||||
|
La couverture est PROUVÉE : chaque suite gated doit (a) exister sur disque avec un
|
||||||
|
répertoire `tests/`, (b) alimenter le job d'agrégat `gate`. Le harnais lui-même
|
||||||
|
(`self_module`) est exclu — séparation des pouvoirs (ISA 315), et il ne
|
||||||
|
s'auto-exécute pas (évite la récursion).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .deps import DELIVERABLES_ROOT, parse_ci
|
||||||
|
|
||||||
|
_TEST_FILE_RE = re.compile(r"^test_.*\.py$")
|
||||||
|
_TEST_METHOD_RE = re.compile(r"^\s*def\s+(test_\w+)\s*\(")
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(path: str) -> str:
|
||||||
|
"""Identifiant stable d'une suite depuis son chemin (déterministe)."""
|
||||||
|
return path.replace("/", ".")
|
||||||
|
|
||||||
|
|
||||||
|
def count_tests(tests_dir: str) -> tuple[int, int]:
|
||||||
|
"""(nb fichiers test_*.py, nb méthodes def test_*) dans `tests_dir`.
|
||||||
|
|
||||||
|
Faits de disque purs — base du recensement exhaustif. Zéro exécution."""
|
||||||
|
if not os.path.isdir(tests_dir):
|
||||||
|
return (0, 0)
|
||||||
|
files = 0
|
||||||
|
methods = 0
|
||||||
|
for name in os.listdir(tests_dir):
|
||||||
|
if not _TEST_FILE_RE.match(name):
|
||||||
|
continue
|
||||||
|
files += 1
|
||||||
|
with open(os.path.join(tests_dir, name), encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if _TEST_METHOD_RE.match(line):
|
||||||
|
methods += 1
|
||||||
|
return (files, methods)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_suites(spec: dict) -> list[dict]:
|
||||||
|
"""Liste ordonnée des suites gated (hors self), enrichie des faits de disque.
|
||||||
|
|
||||||
|
Ordonné par `path` → sortie déterministe et diffable."""
|
||||||
|
self_module = spec["self_module"]
|
||||||
|
ci = parse_ci()
|
||||||
|
job_to_path = ci["job_to_path"]
|
||||||
|
gate_needs = set(ci["gate_needs"])
|
||||||
|
|
||||||
|
path_to_jobs: dict[str, list[str]] = {}
|
||||||
|
for job, path in job_to_path.items():
|
||||||
|
path_to_jobs.setdefault(path, []).append(job)
|
||||||
|
|
||||||
|
suites: list[dict] = []
|
||||||
|
for path in sorted(p for p in job_to_path.values() if p != self_module):
|
||||||
|
jobs = sorted(path_to_jobs[path])
|
||||||
|
abs_path = os.path.join(DELIVERABLES_ROOT, path)
|
||||||
|
tests_dir = os.path.join(abs_path, "tests")
|
||||||
|
files, methods = count_tests(tests_dir)
|
||||||
|
suites.append({
|
||||||
|
"id": _slug(path),
|
||||||
|
"path": path,
|
||||||
|
"jobs": jobs,
|
||||||
|
"in_gate": all(j in gate_needs for j in jobs),
|
||||||
|
"has_tests_dir": os.path.isdir(tests_dir),
|
||||||
|
"test_files": files,
|
||||||
|
"test_methods": methods,
|
||||||
|
})
|
||||||
|
return suites
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_report(spec: dict, suites: list[dict]) -> dict:
|
||||||
|
"""Diagnostic de couverture sérialisable. `ok=True` ssi chaque suite gated a
|
||||||
|
un répertoire `tests/` sur disque ET alimente le gate, et que le harnais
|
||||||
|
lui-même est bien exclu du périmètre."""
|
||||||
|
self_module = spec["self_module"]
|
||||||
|
ci = parse_ci()
|
||||||
|
ci_paths = set(ci["job_to_path"].values())
|
||||||
|
|
||||||
|
missing_tests_dir = sorted(s["path"] for s in suites if not s["has_tests_dir"])
|
||||||
|
not_in_gate = sorted(s["path"] for s in suites if not s["in_gate"])
|
||||||
|
self_present = any(s["path"] == self_module for s in suites)
|
||||||
|
|
||||||
|
ok = (
|
||||||
|
not missing_tests_dir
|
||||||
|
and not not_in_gate
|
||||||
|
and not self_present
|
||||||
|
and self_module in ci_paths # le harnais DOIT être gated lui aussi
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"suites_count": len(suites),
|
||||||
|
"gated_in_ci": len(ci_paths),
|
||||||
|
"self_module_excluded": self_module,
|
||||||
|
"self_module_gated": self_module in ci_paths,
|
||||||
|
"missing_tests_dir": missing_tests_dir,
|
||||||
|
"not_in_gate": not_in_gate,
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Exécution réelle des suites + parseur PUR de la sortie unittest.
|
||||||
|
|
||||||
|
C'est le cœur de la régression EXÉCUTÉE (mode `run`) : on lance chaque suite dans
|
||||||
|
un sous-processus isolé (`python -m unittest discover -s tests`, exactement comme
|
||||||
|
le CI Gitea), on parse sa sortie réelle, et on agrège les compteurs. Aucun chiffre
|
||||||
|
n'est fabriqué — tout provient de la sortie de unittest (#6 anti-invention).
|
||||||
|
|
||||||
|
Isolation par subprocess (et non import in-process) : les modules du mandat ont
|
||||||
|
des fichiers `tests/test_*.py` aux noms qui se recouvrent → un discover global
|
||||||
|
in-process provoquerait des collisions de modules. Le subprocess reproduit
|
||||||
|
fidèlement l'environnement du CI.
|
||||||
|
|
||||||
|
`parse_unittest_output` est PUR (str → dict) donc testable sans exécuter quoi que
|
||||||
|
ce soit — on lui injecte des sorties unittest synthétiques (OK / FAILED / skipped).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_RAN_RE = None # compilé paresseusement pour rester importable sans `re` en tête
|
||||||
|
|
||||||
|
|
||||||
|
def _res():
|
||||||
|
import re
|
||||||
|
return (
|
||||||
|
re.compile(r"Ran (\d+) tests? in"),
|
||||||
|
re.compile(r"failures=(\d+)"),
|
||||||
|
re.compile(r"errors=(\d+)"),
|
||||||
|
re.compile(r"skipped=(\d+)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_unittest_output(text: str, returncode: int) -> dict:
|
||||||
|
"""Extrait les compteurs de la sortie texte de unittest.
|
||||||
|
|
||||||
|
unittest écrit son résumé sur stderr :
|
||||||
|
« Ran 12 tests in 0.03s » puis « OK » | « OK (skipped=2) »
|
||||||
|
| « FAILED (failures=1, errors=2, skipped=3) ».
|
||||||
|
`ok` reflète le code de retour du processus (source de vérité), pas un parse
|
||||||
|
fragile du mot « OK »."""
|
||||||
|
ran_re, fail_re, err_re, skip_re = _res()
|
||||||
|
ran_m = ran_re.search(text)
|
||||||
|
fail_m = fail_re.search(text)
|
||||||
|
err_m = err_re.search(text)
|
||||||
|
skip_m = skip_re.search(text)
|
||||||
|
ran = int(ran_m.group(1)) if ran_m else 0
|
||||||
|
failures = int(fail_m.group(1)) if fail_m else 0
|
||||||
|
errors = int(err_m.group(1)) if err_m else 0
|
||||||
|
skipped = int(skip_m.group(1)) if skip_m else 0
|
||||||
|
passed = max(ran - failures - errors - skipped, 0)
|
||||||
|
return {
|
||||||
|
"ran": ran,
|
||||||
|
"passed": passed,
|
||||||
|
"failures": failures,
|
||||||
|
"errors": errors,
|
||||||
|
"skipped": skipped,
|
||||||
|
"ok": returncode == 0 and ran > 0,
|
||||||
|
"returncode": returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_suite(abs_path: str, *, python: str | None = None,
|
||||||
|
discover_cmd: list[str] | None = None,
|
||||||
|
timeout: int = 600) -> dict:
|
||||||
|
"""Exécute une suite dans son propre répertoire et renvoie ses compteurs.
|
||||||
|
|
||||||
|
`abs_path` = répertoire du module (contenant `tests/`). Aucune sortie n'est
|
||||||
|
imprimée ici — l'agrégateur décide de l'affichage."""
|
||||||
|
py = python or sys.executable
|
||||||
|
cmd = list(discover_cmd or ["-m", "unittest", "discover", "-s", "tests"])
|
||||||
|
tests_dir = os.path.join(abs_path, "tests")
|
||||||
|
if not os.path.isdir(tests_dir):
|
||||||
|
return {
|
||||||
|
"ran": 0, "passed": 0, "failures": 0, "errors": 0, "skipped": 0,
|
||||||
|
"ok": False, "returncode": -1, "note": "tests/ absent",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[py, *cmd], cwd=abs_path, capture_output=True, text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"ran": 0, "passed": 0, "failures": 0, "errors": 0, "skipped": 0,
|
||||||
|
"ok": False, "returncode": -2, "note": f"timeout {timeout}s",
|
||||||
|
}
|
||||||
|
# unittest écrit son résumé sur stderr ; on garde stdout en secours.
|
||||||
|
return parse_unittest_output((proc.stderr or "") + (proc.stdout or ""),
|
||||||
|
proc.returncode)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://oto-enterprise-os-dtp/qa/regression.schema.json",
|
||||||
|
"title": "Plan de regression exhaustive genere depuis regression_spec.json + les faits du depot",
|
||||||
|
"description": "Contrat de sortie du generateur regression_gen.py (sous-commande build). Recensement deterministe des suites de tests gated, sans compteur de resultat. Valide par le validateur maison Publiciste (zero pip) dans le job CI qa-regression-tests.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["matrix", "version", "reference_cadre", "self_module", "thresholds", "discover_cmd", "coverage", "suites", "under_threshold", "totals", "verdict", "notes"],
|
||||||
|
"properties": {
|
||||||
|
"matrix": { "type": "string", "const": "OTO QA · Matrice de régression exhaustive" },
|
||||||
|
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
|
||||||
|
"reference_cadre": { "type": "string", "minLength": 1 },
|
||||||
|
"self_module": { "type": "string", "minLength": 1 },
|
||||||
|
"thresholds": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["min_test_files", "min_methods_per_suite"],
|
||||||
|
"properties": {
|
||||||
|
"min_test_files": { "type": "integer", "minimum": 1 },
|
||||||
|
"min_methods_per_suite": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discover_cmd": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["ok", "suites_count", "gated_in_ci", "self_module_excluded", "self_module_gated", "missing_tests_dir", "not_in_gate"],
|
||||||
|
"properties": {
|
||||||
|
"ok": { "type": "boolean" },
|
||||||
|
"suites_count": { "type": "integer", "minimum": 0 },
|
||||||
|
"gated_in_ci": { "type": "integer", "minimum": 0 },
|
||||||
|
"self_module_excluded": { "type": "string", "minLength": 1 },
|
||||||
|
"self_module_gated": { "type": "boolean" },
|
||||||
|
"missing_tests_dir": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||||
|
"not_in_gate": { "type": "array", "items": { "type": "string", "minLength": 1 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suites": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "path", "jobs", "in_gate", "has_tests_dir", "test_files", "test_methods"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"path": { "type": "string", "minLength": 1 },
|
||||||
|
"jobs": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
||||||
|
"in_gate": { "type": "boolean" },
|
||||||
|
"has_tests_dir": { "type": "boolean" },
|
||||||
|
"test_files": { "type": "integer", "minimum": 0 },
|
||||||
|
"test_methods": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"under_threshold": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||||
|
"totals": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["suites", "test_files", "test_methods", "min_methods", "under_threshold"],
|
||||||
|
"properties": {
|
||||||
|
"suites": { "type": "integer", "minimum": 1 },
|
||||||
|
"test_files": { "type": "integer", "minimum": 0 },
|
||||||
|
"test_methods": { "type": "integer", "minimum": 0 },
|
||||||
|
"min_methods": { "type": "integer", "minimum": 0 },
|
||||||
|
"under_threshold": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verdict": { "type": "string", "enum": ["PASS", "FAIL"] },
|
||||||
|
"notes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Générateur de la matrice de régression exhaustive · Sprint 8 · QA.
|
||||||
|
|
||||||
|
Roadmap Sprint 8 · QA : « Regression tests exhaustifs ». Harnais de MÉTA-NIVEAU :
|
||||||
|
il agrège l'exécution de TOUTES les suites de tests gated du mandat en une matrice
|
||||||
|
unique + un verdict PASS/FAIL, et fournit le compte agrégé faisant autorité
|
||||||
|
(« N tests verts ») — celui que les rapports citaient jusqu'ici à la main.
|
||||||
|
|
||||||
|
Le périmètre « exhaustif » est PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de
|
||||||
|
`.gitea/workflows/ci.yml` (réutilise `q4lib/registry.parse_ci` · zéro duplication
|
||||||
|
· #5), jamais listées à la main. Le harnais s'exclut lui-même (séparation des
|
||||||
|
pouvoirs · ISA 315 · évite la récursion).
|
||||||
|
|
||||||
|
ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultat —
|
||||||
|
seuls des faits de disque (fichiers/méthodes `test_*`) et de CI (job, gate). Les
|
||||||
|
compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie
|
||||||
|
réelle de unittest.
|
||||||
|
|
||||||
|
Sous-commandes :
|
||||||
|
build [-o OUT] → écrit regression_plan.json + MANIFEST.json (déterministe)
|
||||||
|
validate → (re)génère le plan en mémoire, valide schéma + invariants ;
|
||||||
|
sort en erreur si la couverture n'est pas prouvée ou si une
|
||||||
|
suite passe sous les planchers structurels.
|
||||||
|
run [-o OUT] → exécute réellement toutes les suites, agrège la matrice
|
||||||
|
live + verdict ; sort en erreur (code ≠ 0) si une suite est
|
||||||
|
rouge. Sortie NON déterministe → non commitée.
|
||||||
|
|
||||||
|
Le `build`/`validate` est déterministe (tri stable, aucun horodatage) → diffable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 reglib import builder, discovery # noqa: E402
|
||||||
|
from reglib.deps import validate as maison_validate # noqa: E402
|
||||||
|
|
||||||
|
_SPEC_PATH = os.path.join(_HERE, "regression_spec.json")
|
||||||
|
_SCHEMA_PATH = os.path.join(_HERE, "regression.schema.json")
|
||||||
|
_DEFAULT_OUT = os.path.join(_HERE, "out")
|
||||||
|
|
||||||
|
|
||||||
|
def _eprint(*args) -> None:
|
||||||
|
print(*args, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: str) -> dict:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: str, data) -> None:
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(spec: dict, plan: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"generated_from": "regression_spec.json",
|
||||||
|
"generator": "regression_gen.py",
|
||||||
|
"matrix": plan["matrix"],
|
||||||
|
"version": plan["version"],
|
||||||
|
"verdict": plan["verdict"],
|
||||||
|
"suites": plan["totals"]["suites"],
|
||||||
|
"test_methods": plan["totals"]["test_methods"],
|
||||||
|
"coverage_ok": plan["coverage"]["ok"],
|
||||||
|
"artifacts": ["regression_plan.json"],
|
||||||
|
"roadmap": "Sprint 8 · QA · Regression tests exhaustifs",
|
||||||
|
"hors_perimetre_worker": (
|
||||||
|
"Exécution planifiée de `run` sur le runner CI/VPS + publication du "
|
||||||
|
"compte agrégé dans le desk ERPNext / le pipeline de release → agent "
|
||||||
|
"QA / DevOps (#8). Le mode `run` reste manuel (non déterministe)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_invariants(plan: dict, spec: dict) -> list[str]:
|
||||||
|
"""Invariants de cohérence de la matrice. Retourne la liste des violations."""
|
||||||
|
errs: list[str] = []
|
||||||
|
|
||||||
|
# 1. Schéma de sortie.
|
||||||
|
schema = _load(_SCHEMA_PATH)
|
||||||
|
errs += [f"schema: {e}" for e in maison_validate(plan, schema)]
|
||||||
|
|
||||||
|
# 2. Anti-invention : le plan ne DOIT contenir aucun compteur de résultat.
|
||||||
|
# (verdict vert/rouge, tests passés) — ceux-ci n'existent qu'en mode run.
|
||||||
|
forbidden = {"green", "red", "passed", "ran", "failures", "errors"}
|
||||||
|
leaked = forbidden & set(plan["totals"].keys())
|
||||||
|
if leaked:
|
||||||
|
errs.append(f"INV2 compteurs de résultat dans le plan (invention ?) : "
|
||||||
|
f"{sorted(leaked)}")
|
||||||
|
|
||||||
|
# 3. Le harnais ne s'auto-recense pas (séparation des pouvoirs · récursion).
|
||||||
|
self_mod = spec["self_module"]
|
||||||
|
if any(s["path"] == self_mod for s in plan["suites"]):
|
||||||
|
errs.append(f"INV3 harnais {self_mod} présent dans les suites exécutées")
|
||||||
|
|
||||||
|
# 4. Couverture prouvée (disque + gate + self gated + self exclu).
|
||||||
|
cov = plan["coverage"]
|
||||||
|
if not cov["ok"]:
|
||||||
|
errs.append(
|
||||||
|
f"INV4 couverture non prouvée : missing_tests_dir="
|
||||||
|
f"{cov['missing_tests_dir']} not_in_gate={cov['not_in_gate']} "
|
||||||
|
f"self_module_gated={cov['self_module_gated']}")
|
||||||
|
if cov["self_module_excluded"] != self_mod:
|
||||||
|
errs.append("INV4 self_module_excluded incohérent avec le spec")
|
||||||
|
|
||||||
|
# 5. Recompute des suites depuis le disque (aucun compte figé/fabriqué).
|
||||||
|
fresh = discovery.discover_suites(spec)
|
||||||
|
if [s["id"] for s in fresh] != [s["id"] for s in plan["suites"]]:
|
||||||
|
errs.append("INV5 ensemble/ordre des suites non reproductible")
|
||||||
|
fresh_by_id = {s["id"]: s for s in fresh}
|
||||||
|
for s in plan["suites"]:
|
||||||
|
f = fresh_by_id.get(s["id"])
|
||||||
|
if f and (f["test_files"], f["test_methods"]) != (
|
||||||
|
s["test_files"], s["test_methods"]):
|
||||||
|
errs.append(f"INV5 {s['id']} comptes disque {s['test_files']}/"
|
||||||
|
f"{s['test_methods']} ≠ recomputés {f['test_files']}/"
|
||||||
|
f"{f['test_methods']} (compte figé ?)")
|
||||||
|
|
||||||
|
# 6. Planchers structurels : chaque suite ≥ seuils, cohérent avec la liste.
|
||||||
|
th = spec["thresholds"]
|
||||||
|
under = [s["path"] for s in plan["suites"]
|
||||||
|
if s["test_files"] < th["min_test_files"]
|
||||||
|
or s["test_methods"] < th["min_methods_per_suite"]]
|
||||||
|
if under != plan["under_threshold"]:
|
||||||
|
errs.append(f"INV6 under_threshold {plan['under_threshold']} ≠ "
|
||||||
|
f"recomputé {under}")
|
||||||
|
|
||||||
|
# 7. Totaux cohérents (recomputés depuis les suites).
|
||||||
|
t = plan["totals"]
|
||||||
|
suites = plan["suites"]
|
||||||
|
exp = {
|
||||||
|
"suites": len(suites),
|
||||||
|
"test_files": sum(s["test_files"] for s in suites),
|
||||||
|
"test_methods": sum(s["test_methods"] for s in suites),
|
||||||
|
"min_methods": min((s["test_methods"] for s in suites), default=0),
|
||||||
|
"under_threshold": len(under),
|
||||||
|
}
|
||||||
|
for k, v in exp.items():
|
||||||
|
if t.get(k) != v:
|
||||||
|
errs.append(f"INV7 totals[{k}]={t.get(k)} ≠ recomputé {v}")
|
||||||
|
|
||||||
|
# 8. Verdict global cohérent (GATE : couverture prouvée ET zéro sous-plancher).
|
||||||
|
expect = "PASS" if (cov["ok"] and not under) else "FAIL"
|
||||||
|
if plan["verdict"] != expect:
|
||||||
|
errs.append(f"INV8 verdict {plan['verdict']} ≠ attendu {expect}")
|
||||||
|
|
||||||
|
return errs
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_build(args: argparse.Namespace) -> int:
|
||||||
|
spec = _load(_SPEC_PATH)
|
||||||
|
plan = builder.build_plan(spec)
|
||||||
|
errs = check_invariants(plan, spec)
|
||||||
|
if errs:
|
||||||
|
_eprint("❌ Invariants violés — build refusé :")
|
||||||
|
for e in errs:
|
||||||
|
_eprint(f" · {e}")
|
||||||
|
return 1
|
||||||
|
out_dir = args.out or _DEFAULT_OUT
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
_write_json(os.path.join(out_dir, "regression_plan.json"), plan)
|
||||||
|
_write_json(os.path.join(out_dir, "MANIFEST.json"), _manifest(spec, plan))
|
||||||
|
print(f"✅ Plan de régression écrit dans {out_dir}/ — verdict "
|
||||||
|
f"{plan['verdict']} · {plan['totals']['suites']} suites · "
|
||||||
|
f"{plan['totals']['test_methods']} méthodes test_* recensées.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(args: argparse.Namespace) -> int:
|
||||||
|
spec = _load(_SPEC_PATH)
|
||||||
|
plan = builder.build_plan(spec)
|
||||||
|
errs = check_invariants(plan, spec)
|
||||||
|
if errs:
|
||||||
|
_eprint("❌ Validation échouée :")
|
||||||
|
for e in errs:
|
||||||
|
_eprint(f" · {e}")
|
||||||
|
return 1
|
||||||
|
if plan["verdict"] != "PASS":
|
||||||
|
_eprint(f"❌ Verdict régression = {plan['verdict']} "
|
||||||
|
f"(couverture={plan['coverage']['ok']} · "
|
||||||
|
f"sous-plancher={plan['under_threshold']})")
|
||||||
|
return 1
|
||||||
|
print(f"✅ Matrice de régression valide — {plan['totals']['suites']} suites "
|
||||||
|
f"gated · {plan['totals']['test_methods']} méthodes recensées · "
|
||||||
|
f"couverture prouvée.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run(args: argparse.Namespace) -> int:
|
||||||
|
spec = _load(_SPEC_PATH)
|
||||||
|
print("▶ Exécution exhaustive des suites gated (peut prendre un moment)…",
|
||||||
|
file=sys.stderr)
|
||||||
|
matrix = builder.run_matrix(spec)
|
||||||
|
out_dir = args.out or _DEFAULT_OUT
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
_write_json(os.path.join(out_dir, "regression_run.json"), matrix)
|
||||||
|
t = matrix["totals"]
|
||||||
|
for r in matrix["suites"]:
|
||||||
|
mark = "✅" if r["ok"] else "❌"
|
||||||
|
print(f" {mark} {r['path']:32s} ran={r['ran']:3d} "
|
||||||
|
f"fail={r['failures']} err={r['errors']} skip={r['skipped']}")
|
||||||
|
print(f"\n{'✅' if matrix['verdict'] == 'PASS' else '❌'} Régression "
|
||||||
|
f"{matrix['verdict']} — {t['green']}/{t['suites']} suites vertes · "
|
||||||
|
f"{t['passed']} tests passés · {t['failures']} échecs · "
|
||||||
|
f"{t['errors']} erreurs.")
|
||||||
|
return 0 if matrix["verdict"] == "PASS" else 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
ap = argparse.ArgumentParser(description="Matrice de régression exhaustive "
|
||||||
|
"(Sprint 8 · QA).")
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
b = sub.add_parser("build", help="écrit le plan déterministe + MANIFEST")
|
||||||
|
b.add_argument("-o", "--out", help="répertoire de sortie")
|
||||||
|
b.set_defaults(func=cmd_build)
|
||||||
|
v = sub.add_parser("validate", help="valide schéma + invariants (gate)")
|
||||||
|
v.set_defaults(func=cmd_validate)
|
||||||
|
r = sub.add_parser("run", help="exécute réellement toutes les suites (live)")
|
||||||
|
r.add_argument("-o", "--out", help="répertoire de sortie")
|
||||||
|
r.set_defaults(func=cmd_run)
|
||||||
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"matrix": "OTO QA · Matrice de régression exhaustive",
|
||||||
|
"version": "1.0",
|
||||||
|
"reference_cadre": "Roadmap Sprint 8 · QA « Regression tests exhaustifs » · CLAUDE.md #5 (zéro régression silencieuse · niveau 4Big)",
|
||||||
|
"self_module": "qa/regression",
|
||||||
|
"thresholds": {
|
||||||
|
"min_test_files": 1,
|
||||||
|
"min_methods_per_suite": 8
|
||||||
|
},
|
||||||
|
"seuil_note": "min_test_files / min_methods_per_suite sont des planchers STRUCTURELS (une suite gated doit exister sur disque et couvrir un minimum) — repris de audit_4big (min_test_methods=8). Aucun chiffre métier / projet n'est défini ici : le nombre de tests verts N'EST PAS écrit dans le spec, il est MESURÉ par `run` (#6 anti-invention).",
|
||||||
|
"discover_cmd": ["python3", "-m", "unittest", "discover", "-s", "tests"],
|
||||||
|
"notes": [
|
||||||
|
"Harnais de MÉTA-NIVEAU : agrège l'exécution de TOUTES les suites de tests gated du mandat en une seule matrice + un verdict PASS/FAIL. Sprint 8 · QA « Regression tests exhaustifs ».",
|
||||||
|
"Périmètre PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de .gitea/workflows/ci.yml (réutilise q4lib/registry.parse_ci — zéro duplication · #5), jamais listées à la main. Tout job de test ajouté au CI entre automatiquement dans la matrice.",
|
||||||
|
"Séparation des pouvoirs (ISA 315) : le harnais s'exclut lui-même (self_module) — il ne s'auto-exécute pas (évite la récursion) et ne se compte pas dans le total régression.",
|
||||||
|
"ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultats (0 test vert écrit à la main) ; seuls des FAITS de disque (fichiers/méthodes test_*) et de CI (job, gate) y figurent. Les compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie réelle de unittest.",
|
||||||
|
"Distinct de l'audit 4Big : audit_4big note la QUALITÉ statique par module (doc/schema/CLI/hand-off) ; ce harnais prouve que chaque suite S'EXÉCUTE AU VERT et fournit le compte agrégé faisant autorité (« N tests verts »). Axes complémentaires, non redondants."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests du harnais de matrice de régression exhaustive (QA · Sprint 8).
|
||||||
|
|
||||||
|
Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea
|
||||||
|
(CLAUDE.md #2). Couvre : le parseur PUR de sortie unittest (OK / skipped /
|
||||||
|
FAILED / vide), l'exécution réelle d'une suite synthétique en tmpdir (verte +
|
||||||
|
rouge + tests/ absent), la découverte des suites depuis le CI + faits de disque,
|
||||||
|
la preuve de couverture, l'assemblage du plan, les invariants du générateur
|
||||||
|
(dont anti-invention #6 : aucun compteur de résultat dans le plan · recompute des
|
||||||
|
comptes depuis le disque), le déterminisme et le schéma.
|
||||||
|
|
||||||
|
Ce job NE ré-exécute PAS les suites du mandat (le gate le fait déjà job par job) —
|
||||||
|
il teste le HARNAIS lui-même.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_MOD = os.path.normpath(os.path.join(_HERE, ".."))
|
||||||
|
sys.path.insert(0, _MOD)
|
||||||
|
|
||||||
|
from reglib import builder, discovery, runner # noqa: E402
|
||||||
|
import regression_gen as gen # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _spec():
|
||||||
|
with open(os.path.join(_MOD, "regression_spec.json"), encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_suite(root: str, *, methods: int, failing: bool = False,
|
||||||
|
with_tests: bool = True) -> str:
|
||||||
|
"""Arbre de module synthétique avec une suite unittest ; renvoie son chemin."""
|
||||||
|
mod = os.path.join(root, "mod")
|
||||||
|
os.makedirs(mod, exist_ok=True)
|
||||||
|
if with_tests:
|
||||||
|
tdir = os.path.join(mod, "tests")
|
||||||
|
os.makedirs(tdir, exist_ok=True)
|
||||||
|
body = "".join(
|
||||||
|
f" def test_case_{i}(self):\n"
|
||||||
|
f" self.assertTrue({'False' if (failing and i == 0) else 'True'})\n"
|
||||||
|
for i in range(methods)
|
||||||
|
)
|
||||||
|
with open(os.path.join(tdir, "test_mod.py"), "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("import unittest\nclass T(unittest.TestCase):\n" +
|
||||||
|
(body or " pass\n"))
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Parseur PUR de la sortie unittest (aucune exécution).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class TestParseOutput(unittest.TestCase):
|
||||||
|
def test_ok(self):
|
||||||
|
out = "....\n----\nRan 4 tests in 0.01s\n\nOK\n"
|
||||||
|
r = runner.parse_unittest_output(out, 0)
|
||||||
|
self.assertEqual(r["ran"], 4)
|
||||||
|
self.assertEqual(r["passed"], 4)
|
||||||
|
self.assertEqual((r["failures"], r["errors"], r["skipped"]), (0, 0, 0))
|
||||||
|
self.assertTrue(r["ok"])
|
||||||
|
|
||||||
|
def test_ok_with_skipped(self):
|
||||||
|
out = "Ran 5 tests in 0.02s\n\nOK (skipped=2)\n"
|
||||||
|
r = runner.parse_unittest_output(out, 0)
|
||||||
|
self.assertEqual(r["ran"], 5)
|
||||||
|
self.assertEqual(r["skipped"], 2)
|
||||||
|
self.assertEqual(r["passed"], 3)
|
||||||
|
self.assertTrue(r["ok"])
|
||||||
|
|
||||||
|
def test_failed(self):
|
||||||
|
out = "Ran 10 tests in 0.03s\n\nFAILED (failures=1, errors=2, skipped=3)\n"
|
||||||
|
r = runner.parse_unittest_output(out, 1)
|
||||||
|
self.assertEqual(r["ran"], 10)
|
||||||
|
self.assertEqual((r["failures"], r["errors"], r["skipped"]), (1, 2, 3))
|
||||||
|
self.assertEqual(r["passed"], 4)
|
||||||
|
self.assertFalse(r["ok"])
|
||||||
|
|
||||||
|
def test_empty_output_is_not_ok(self):
|
||||||
|
r = runner.parse_unittest_output("", 0)
|
||||||
|
self.assertEqual(r["ran"], 0)
|
||||||
|
self.assertFalse(r["ok"]) # ran==0 → jamais vert (une suite doit tourner)
|
||||||
|
|
||||||
|
def test_returncode_overrides_ok(self):
|
||||||
|
out = "Ran 3 tests in 0.01s\n\nOK\n"
|
||||||
|
self.assertFalse(runner.parse_unittest_output(out, 1)["ok"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Exécution réelle d'une suite (subprocess).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class TestRunSuite(unittest.TestCase):
|
||||||
|
def test_green_suite(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mod = _make_suite(tmp, methods=3)
|
||||||
|
r = runner.run_suite(mod)
|
||||||
|
self.assertTrue(r["ok"])
|
||||||
|
self.assertEqual(r["ran"], 3)
|
||||||
|
self.assertEqual(r["failures"], 0)
|
||||||
|
|
||||||
|
def test_red_suite(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mod = _make_suite(tmp, methods=3, failing=True)
|
||||||
|
r = runner.run_suite(mod)
|
||||||
|
self.assertFalse(r["ok"])
|
||||||
|
self.assertGreaterEqual(r["failures"], 1)
|
||||||
|
|
||||||
|
def test_missing_tests_dir(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mod = _make_suite(tmp, methods=0, with_tests=False)
|
||||||
|
r = runner.run_suite(mod)
|
||||||
|
self.assertFalse(r["ok"])
|
||||||
|
self.assertEqual(r["returncode"], -1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Découverte + comptage disque.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class TestDiscovery(unittest.TestCase):
|
||||||
|
def test_count_tests(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mod = _make_suite(tmp, methods=7)
|
||||||
|
files, methods = discovery.count_tests(os.path.join(mod, "tests"))
|
||||||
|
self.assertEqual(files, 1)
|
||||||
|
self.assertEqual(methods, 7)
|
||||||
|
|
||||||
|
def test_count_tests_absent(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.assertEqual(discovery.count_tests(os.path.join(tmp, "nope")),
|
||||||
|
(0, 0))
|
||||||
|
|
||||||
|
def test_discover_excludes_self_and_is_sorted(self):
|
||||||
|
spec = _spec()
|
||||||
|
suites = discovery.discover_suites(spec)
|
||||||
|
paths = [s["path"] for s in suites]
|
||||||
|
self.assertNotIn(spec["self_module"], paths) # séparation des pouvoirs
|
||||||
|
self.assertEqual(paths, sorted(paths)) # déterministe
|
||||||
|
self.assertGreater(len(suites), 1)
|
||||||
|
for s in suites: # faits de disque réels
|
||||||
|
self.assertTrue(s["has_tests_dir"], s["path"])
|
||||||
|
self.assertGreaterEqual(s["test_methods"], 1, s["path"])
|
||||||
|
|
||||||
|
def test_coverage_ok_on_repo(self):
|
||||||
|
spec = _spec()
|
||||||
|
suites = discovery.discover_suites(spec)
|
||||||
|
cov = discovery.coverage_report(spec, suites)
|
||||||
|
self.assertTrue(cov["ok"], cov)
|
||||||
|
self.assertTrue(cov["self_module_gated"]) # le harnais est gated
|
||||||
|
self.assertEqual(cov["missing_tests_dir"], [])
|
||||||
|
self.assertEqual(cov["not_in_gate"], [])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Assemblage du plan + invariants du générateur.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class TestPlanAndInvariants(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.spec = _spec()
|
||||||
|
self.plan = builder.build_plan(self.spec)
|
||||||
|
|
||||||
|
def test_plan_passes_and_is_clean(self):
|
||||||
|
self.assertEqual(self.plan["verdict"], "PASS")
|
||||||
|
self.assertEqual(gen.check_invariants(self.plan, self.spec), [])
|
||||||
|
|
||||||
|
def test_plan_has_no_result_counters(self):
|
||||||
|
# #6 : le plan (statique) ne doit contenir AUCUN compteur d'exécution.
|
||||||
|
forbidden = {"green", "red", "passed", "ran", "failures", "errors"}
|
||||||
|
self.assertEqual(forbidden & set(self.plan["totals"].keys()), set())
|
||||||
|
|
||||||
|
def test_totals_match_suites(self):
|
||||||
|
s = self.plan["suites"]
|
||||||
|
self.assertEqual(self.plan["totals"]["test_methods"],
|
||||||
|
sum(x["test_methods"] for x in s))
|
||||||
|
self.assertEqual(self.plan["totals"]["suites"], len(s))
|
||||||
|
|
||||||
|
def test_determinism(self):
|
||||||
|
self.assertEqual(builder.build_plan(self.spec), self.plan)
|
||||||
|
|
||||||
|
def test_schema_valid(self):
|
||||||
|
with open(os.path.join(_MOD, "regression.schema.json"),
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
schema = json.load(fh)
|
||||||
|
from reglib.deps import validate as v
|
||||||
|
self.assertEqual(v(self.plan, schema), [])
|
||||||
|
|
||||||
|
def test_inv_self_in_suites(self):
|
||||||
|
bad = copy.deepcopy(self.plan)
|
||||||
|
bad["suites"].append({
|
||||||
|
"id": "self", "path": self.spec["self_module"], "jobs": ["x"],
|
||||||
|
"in_gate": True, "has_tests_dir": True, "test_files": 1,
|
||||||
|
"test_methods": 9,
|
||||||
|
})
|
||||||
|
errs = gen.check_invariants(bad, self.spec)
|
||||||
|
self.assertTrue(any("INV3" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_inv_forbidden_counter_leak(self):
|
||||||
|
bad = copy.deepcopy(self.plan)
|
||||||
|
bad["totals"]["green"] = 19
|
||||||
|
errs = gen.check_invariants(bad, self.spec)
|
||||||
|
self.assertTrue(any("INV2" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_inv_frozen_count_detected(self):
|
||||||
|
# Un compte de disque figé/fabriqué (≠ recompute) doit être détecté (#6).
|
||||||
|
bad = copy.deepcopy(self.plan)
|
||||||
|
bad["suites"][0]["test_methods"] += 100
|
||||||
|
bad["totals"]["test_methods"] += 100
|
||||||
|
errs = gen.check_invariants(bad, self.spec)
|
||||||
|
self.assertTrue(any("INV5" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_inv_totals_tampered(self):
|
||||||
|
bad = copy.deepcopy(self.plan)
|
||||||
|
bad["totals"]["suites"] += 1
|
||||||
|
errs = gen.check_invariants(bad, self.spec)
|
||||||
|
self.assertTrue(any("INV7" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_inv_verdict_tampered(self):
|
||||||
|
bad = copy.deepcopy(self.plan)
|
||||||
|
bad["verdict"] = "FAIL" if self.plan["verdict"] == "PASS" else "PASS"
|
||||||
|
errs = gen.check_invariants(bad, self.spec)
|
||||||
|
self.assertTrue(any("INV8" in e for e in errs), errs)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# CLI (entrypoints reproductibles).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class TestCLI(unittest.TestCase):
|
||||||
|
def test_validate_ok(self):
|
||||||
|
self.assertEqual(gen.main(["validate"]), 0)
|
||||||
|
|
||||||
|
def test_build_writes_artifacts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.assertEqual(gen.main(["build", "-o", tmp]), 0)
|
||||||
|
for f in ("regression_plan.json", "MANIFEST.json"):
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(tmp, f)), f)
|
||||||
|
man = json.load(open(os.path.join(tmp, "MANIFEST.json"),
|
||||||
|
encoding="utf-8"))
|
||||||
|
self.assertEqual(man["verdict"], "PASS")
|
||||||
|
self.assertEqual(man["generator"], "regression_gen.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user