From 0020776b2684dad3f19257c07c53a1f2cb14bd9e Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 12:13:44 +0200 Subject: [PATCH] test(fase3): SC1 new project via config-only + no-hardcoded-id src guard --- .../data/docs/SKOLE-VVS-OPPGR/notes.txt | 5 +++ .../data/reference_projects.json | 15 +++++++++ tests/test_portfolio.py | 33 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR/notes.txt diff --git a/src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR/notes.txt b/src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR/notes.txt new file mode 100644 index 0000000..39314e0 --- /dev/null +++ b/src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR/notes.txt @@ -0,0 +1,5 @@ +SYNTHETIC fixture (D4) — AI-authored, not verified domain content. + +Cost saving measure candidate for Skole VVS-oppgradering: +Reducing the scope on the ventilation duct network (code 63.2) lowers the cost. Scope +reduction on the ventilation works is the candidate cost-saving measure to validate. diff --git a/src/portfolio_optimiser/data/reference_projects.json b/src/portfolio_optimiser/data/reference_projects.json index f99cf94..455a9a7 100644 --- a/src/portfolio_optimiser/data/reference_projects.json +++ b/src/portfolio_optimiser/data/reference_projects.json @@ -47,6 +47,21 @@ {"code": "87.3", "description": "Fuktisolering membran", "quantity": 640, "unit": "m2", "unit_cost": 980}, {"code": "75.1", "description": "Rekkverk stal galvanisert", "quantity": 210, "unit": "m", "unit_cost": 2650} ] + }, + { + "id": "SKOLE-VVS-OPPGR", + "name": "Skole VVS-oppgradering", + "description": "Oppgradering av ventilasjon, sanitær og varme på eksisterende skole. Fiktivt referanseprosjekt (lagt til via config — SC1).", + "currency": "NOK", + "docs_dir": "docs/SKOLE-VVS-OPPGR", + "verdict_input": {"decision": "approved", "rationale": "Ventilation duct scope reduction is feasible within the estimate range (SYNTHETIC / not verified)."}, + "cost_items": [ + {"code": "01.1", "description": "Rigg og drift", "quantity": 1, "unit": "rs", "unit_cost": 480000}, + {"code": "62.1", "description": "Ventilasjonsaggregat", "quantity": 4, "unit": "stk", "unit_cost": 185000}, + {"code": "63.2", "description": "Kanalnett ventilasjon", "quantity": 1200, "unit": "m", "unit_cost": 1450}, + {"code": "64.3", "description": "Sanitæranlegg bad og wc", "quantity": 1, "unit": "rs", "unit_cost": 920000}, + {"code": "65.1", "description": "Varmeanlegg vannbåren", "quantity": 1, "unit": "rs", "unit_cost": 760000} + ] } ] } diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 89b4d54..7380162 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -178,3 +178,36 @@ async def test_d_both_profiles_run_offline( # Profile-dependent teeth (offline, no egress): each profile resolves its own model. assert resolve_model(profile, "proposer") assert resolve_model("local", "proposer") != resolve_model("azure", "proposer") + + +async def test_e_new_project_flows_through_via_config_only( + make_portfolio_client_factory, fresh_store +) -> None: + """SC1: a brand-new project (SKOLE-VVS-OPPGR) added by CONFIG ONLY — a JSON entry + a bundled + docs folder, zero ``src/*.py`` change — flows end-to-end through run_portfolio. Its id is not + in REPLIES, so the default reply (which omits project_id) inherits the new project's id via + ``_parse_ir`` setdefault, proving the loader wired the config-only project through.""" + result = await run_portfolio( + ["SKOLE-VVS-OPPGR"], + "local", + store=fresh_store, + client_factory=make_portfolio_client_factory(REPLIES), + ) + assert len(result.runs) == 1 + assert result.runs[0].outcome.proposal.project_id == "SKOLE-VVS-OPPGR" + + +def test_f_no_hardcoded_project_ids_in_src() -> None: + """SC1 load-bearing guard: reference-project ids live ONLY in the data JSON, never in code. + If a future project is onboarded via a code-side id->path/verdict mapping, this reddens — + backing the 'config-only' claim. Pattern: test_budget.py:88-99 (src anti-pattern grep).""" + from pathlib import Path + + ids = ("FV42-GSV-E1", "RV13-RAS-TP", "BRU-LAKS-REHAB", "SKOLE-VVS-OPPGR") + offenders = [ + f"{py.name}: {pid}" + for py in Path("src/portfolio_optimiser").rglob("*.py") + for pid in ids + if pid in py.read_text(encoding="utf-8") + ] + assert offenders == [], f"hardcoded project ids in src: {offenders}"