915fc5194a
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>
225 lines
8.9 KiB
Python
225 lines
8.9 KiB
Python
"""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()
|