Files
Claude Code DTP Worker 915fc5194a [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>
2026-07-30 05:08:02 +00:00

225 lines
8.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()