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