"""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()