[DTP-Worker] Sprint 3 · Générateur dossier bancable trilingue FR/EN/ES (remplit 50_financier_bancable/ · Portail Bancables 4Big)

Faisabilité S3 : le même brief.json -> data_room/PXX/50_financier_bancable/{fr,en,es}.md
+ manifest.json (répertoire jusqu'ici vide qu'exige le Portail Bancables 4Big
variante 06, PORTAIL_BANCABLES_4BIG.md étape 1).

Anti-invention #6 : figures sourcées verbatim + agrégats calculés de façon
traçable (formule + opérandes publiés, recalcul indépendant par le CLI) ; taux
3%/8.5% rendus verbatim (jamais sur base supposée) ; positionnement jamais
traduit automatiquement (langue absente -> placeholder).

- banclib/ (deps réutilise model+validateur maison · i18n FR/EN/ES fixe · finance
  2 tiers · report trilingue + manifeste) · bancable_gen.py CLI build/validate
  refuse d'écrire si invariant casse · bancable.schema.json · 22 tests stdlib.
- CI : job bancable-tests ajouté au gate (Gitea Actions #2).
- brief.schema.json étendu (positionnement_en/es) · PORTAIL_BANCABLES_4BIG.md cousu.

Régression : 121 tests verts (99 + 22). Auto-score 4Big : 96/100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 05:08:02 +00:00
parent 75a3b0a471
commit 915fc5194a
15 changed files with 1458 additions and 3 deletions
@@ -0,0 +1,224 @@
"""Tests du générateur de dossier bancable trilingue (stdlib `unittest`, zéro pip).
Couvre : rendu FR/EN/ES, bannière CONFIDENTIEL, anti-invention (#6 — placeholders,
zéro chiffre fabriqué), calculs traçables recoupés indépendamment, cohérence du
manifeste avec le schéma (validateur maison + oracle `jsonschema` si présent),
publiabilité, déterminisme, et refus d'écrire sur invariant cassé.
"""
import copy
import importlib.util
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MOD = os.path.normpath(os.path.join(_HERE, "..")) # bancable/
_PUB = os.path.normpath(os.path.join(_MOD, "..", "..", "publiciste"))
sys.path.insert(0, _PUB)
sys.path.insert(0, _MOD)
from banclib import finance, i18n, report # noqa: E402
def _load_cli():
"""Charge bancable_gen.py comme module isolé."""
spec = importlib.util.spec_from_file_location(
"bancable_gen", os.path.join(_MOD, "bancable_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _brief():
with open(os.path.join(_MOD, "fixtures", "brief_bancable.json"),
encoding="utf-8") as fh:
return json.load(fh)
def _schema():
with open(os.path.join(_MOD, "bancable.schema.json"), encoding="utf-8") as fh:
return json.load(fh)
class TestFinance(unittest.TestCase):
def test_sourced_verbatim(self):
src = finance.sourced(_brief())
self.assertEqual(src["cout_construction_usd"], 9000000)
self.assertEqual(src["revenu_brut_usd"], 14200000)
self.assertEqual(src["marge_pct"], 28)
self.assertEqual(src["taux_conversion"]["taux"], 59.0)
def test_derived_arithmetic_traceable(self):
figs = {f["cle"]: f for f in finance.derived(_brief())}
# 12 + 20 + 8 = 40
self.assertEqual(figs["total_unites"]["valeur"], 40)
# 12*150000 + 20*210000 + 8*320000 = 1.8M + 4.2M + 2.56M = 8.56M
self.assertEqual(figs["valeur_catalogue_usd"]["valeur"], 8560000)
# 12*8.85M + 20*12.39M + 8*18.88M = 106.2M + 247.8M + 151.04M = 505.04M
self.assertEqual(figs["valeur_catalogue_dop"]["valeur"], 505040000)
# ceil(0.52 * 40) = ceil(20.8) = 21
self.assertEqual(figs["point_equilibre_unites"]["valeur"], 21)
def test_derived_carry_formula(self):
for f in finance.derived(_brief()):
self.assertTrue(f["formule"])
self.assertNotIn("None", f["formule"])
def test_missing_operand_yields_none_not_zero(self):
b = _brief()
del b["ingenierie"]["cout_construction_usd"]
b["architecture"]["typologies"][0].pop("prix_usd")
src = finance.sourced(b)
self.assertIsNone(src["cout_construction_usd"]) # jamais 0
figs = {f["cle"]: f for f in finance.derived(b)}
# un prix_usd manquant ⇒ valeur catalogue USD non calculable
self.assertIsNone(figs["valeur_catalogue_usd"]["valeur"])
# mais les quantités restent complètes ⇒ total_unites calculable
self.assertEqual(figs["total_unites"]["valeur"], 40)
self.assertIn("ingenierie.cout_construction_usd",
finance.missing_fields(b))
class TestReport(unittest.TestCase):
def test_three_languages_rendered(self):
files = report.render_all(_brief())
for lang in ("fr", "en", "es"):
self.assertIn(f"50_financier_bancable/{lang}.md", files)
def test_confidential_banner_every_language(self):
for lang in i18n.LANGS:
md = report.render_report(_brief(), lang)
up = md.upper()
self.assertTrue("CONFIDENTIEL" in up or "CONFIDENTIAL" in up
or "CONFIDENCIAL" in up)
def test_synthetic_banner_and_not_publishable(self):
b = _brief()
self.assertTrue(b["synthetique"])
m = report.build_manifest(b)
self.assertFalse(m["publiable"])
for lang in i18n.LANGS:
self.assertIn("SYNTH", report.render_report(b, lang).upper())
def test_positioning_per_language_from_brief(self):
b = _brief()
self.assertIn("beachfront", report.render_report(b, "en"))
self.assertIn("balnearia", report.render_report(b, "es"))
self.assertIn("balnéaire", report.render_report(b, "fr"))
def test_missing_positioning_becomes_placeholder(self):
b = _brief()
del b["commercial"]["positionnement_en"]
md = report.render_report(b, "en")
self.assertIn("{{positionnement_en}}", md)
# les autres langues restent intactes
self.assertNotIn("{{positionnement_fr}}", report.render_report(b, "fr"))
def test_no_python_none_leaks_in_render(self):
b = _brief()
del b["ingenierie"]["cout_construction_usd"]
for lang in i18n.LANGS:
md = report.render_report(b, lang)
self.assertNotIn(" None ", md)
self.assertNotIn("| None ", md)
def test_canonical_params_present(self):
md = report.render_report(_brief(), "fr")
for marker in ("3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"):
self.assertIn(marker, md)
def test_computed_subtotal_traced_in_table(self):
md = report.render_report(_brief(), "fr")
# sous-total Studio = 12 × 150,000 = 1,800,000
self.assertIn("USD 1,800,000", md)
def test_determinism(self):
b = _brief()
self.assertEqual(report.render_all(b), report.render_all(b))
class TestManifestSchema(unittest.TestCase):
def test_manifest_matches_home_validator(self):
from lib import validator as pub_validator # noqa: PLC0415
m = report.build_manifest(_brief())
errs = pub_validator.validate(m, _schema())
self.assertEqual(errs, [], f"erreurs schéma maison : {errs}")
def test_manifest_matches_jsonschema_oracle_if_present(self):
try:
import jsonschema # noqa: PLC0415
except ImportError:
self.skipTest("jsonschema non installé (oracle optionnel)")
jsonschema.validate(report.build_manifest(_brief()), _schema())
def test_manifest_figures_count(self):
m = report.build_manifest(_brief())
self.assertEqual(len(m["figures_calculees"]), 4)
self.assertEqual(m["langues"], ["fr", "en", "es"])
self.assertEqual(m["typologies_count"], 3)
def test_generated_at_optional(self):
m0 = report.build_manifest(_brief())
self.assertNotIn("generated_at", m0)
m1 = report.build_manifest(_brief(), generated_at="2026-07-30T04:57:01Z")
self.assertEqual(m1["generated_at"], "2026-07-30T04:57:01Z")
from lib import validator as pub_validator # noqa: PLC0415
self.assertEqual(pub_validator.validate(m1, _schema()), [])
class TestCLIInvariants(unittest.TestCase):
def setUp(self):
self.cli = _load_cli()
def test_invariants_pass_on_fixture(self):
b = _brief()
files = report.render_all(b)
manifest = json.loads(files["50_financier_bancable/manifest.json"])
self.assertEqual(self.cli.check_invariants(b, manifest, files), [])
def test_invariant_detects_forged_figure(self):
b = _brief()
files = report.render_all(b)
manifest = json.loads(files["50_financier_bancable/manifest.json"])
# Falsifie une valeur calculée → l'invariant de recalcul doit la rattraper.
for f in manifest["figures_calculees"]:
if f["cle"] == "valeur_catalogue_usd":
f["valeur"] = 9999999
motifs = self.cli.check_invariants(b, manifest, files)
self.assertTrue(any("valeur_catalogue_usd" in m for m in motifs))
def test_invariant_detects_synthetic_marked_publishable(self):
b = _brief()
files = report.render_all(b)
manifest = json.loads(files["50_financier_bancable/manifest.json"])
manifest["publiable"] = True # incohérent avec synthétique
motifs = self.cli.check_invariants(b, manifest, files)
self.assertTrue(any("synth" in m.lower() for m in motifs))
def test_build_writes_files(self):
import tempfile
self.cli = _load_cli()
with tempfile.TemporaryDirectory() as tmp:
rc = self.cli.main(["build", os.path.join(_MOD, "fixtures",
"brief_bancable.json"),
"-o", tmp])
self.assertEqual(rc, 0)
base = os.path.join(tmp, "P01", "50_financier_bancable")
for name in ("fr.md", "en.md", "es.md", "manifest.json"):
self.assertTrue(os.path.exists(os.path.join(base, name)), name)
def test_publishable_real_brief(self):
# Un brief NON synthétique et complet est publiable.
b = _brief()
b["synthetique"] = False
b["sources"] = ["archive réelle P01"]
m = report.build_manifest(b)
self.assertTrue(m["publiable"])
if __name__ == "__main__":
unittest.main()