Closes the one MAJOR from /trekreview of S3.6 (verdict WARN): the user-facing NOK-string formatter had only an isinstance(...,str) check, so a regression (wrong divmod, dropped padding, øre/kr transposition, or non-breaking-space → ASCII) would ship a wrong money figure with the suite green. Adds a load-bearing value-level test (thousands grouping, sub-100-øre padding, negative-sign branch) pinning the actual Norwegian \xa0-separated formatting; verified red under a divmod(...,1000) mutation and reverted. Suite 333 passed / 4 skipped, ruff+mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0145ZKPLMVeqM47z2jxxokym
121 lines
4.5 KiB
Python
121 lines
4.5 KiB
Python
"""S3.6 costsim — unit tests: pricing load/fail-fast, deterministic estimate, table, CLI.
|
||
|
||
Load-bearing detach seams live in ``test_costsim_loadbearing.py``; these are the happy-path +
|
||
schema unit tests.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from portfolio_optimiser import costsim
|
||
|
||
_VALID_PRICING = {
|
||
"source": "unit-test prices",
|
||
"date": "2026-07-15",
|
||
"models": {
|
||
"m-cheap": {
|
||
"ore_per_1k_tokens": 10,
|
||
"quality_guidance": {"note": "cheap draft model", "source": "test-src 2026-07-15"},
|
||
},
|
||
"m-dear": {
|
||
"ore_per_1k_tokens": 50,
|
||
"quality_guidance": {"note": "stronger reasoning", "source": "test-src 2026-07-15"},
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _write(tmp_path: Path, data: dict) -> Path:
|
||
p = tmp_path / "pricing.json"
|
||
p.write_text(json.dumps(data), encoding="utf-8")
|
||
return p
|
||
|
||
|
||
def test_load_pricing_valid(tmp_path: Path) -> None:
|
||
pricing = costsim.load_pricing(_write(tmp_path, _VALID_PRICING))
|
||
assert pricing.models["m-cheap"].ore_per_1k_tokens == 10
|
||
assert pricing.source == "unit-test prices"
|
||
|
||
|
||
def test_load_pricing_bundled_default() -> None:
|
||
"""No path → the packaged data/pricing.example.json loads and prices the local model."""
|
||
pricing = costsim.load_pricing()
|
||
assert "qwen3:4b" in pricing.models
|
||
|
||
|
||
def test_load_pricing_missing_file_raises(tmp_path: Path) -> None:
|
||
with pytest.raises(FileNotFoundError):
|
||
costsim.load_pricing(tmp_path / "nope.json")
|
||
|
||
|
||
def test_load_pricing_missing_provenance_raises(tmp_path: Path) -> None:
|
||
bad = {"models": _VALID_PRICING["models"]} # no source/date
|
||
with pytest.raises(ValidationError):
|
||
costsim.load_pricing(_write(tmp_path, bad))
|
||
|
||
|
||
def test_estimate_equals_hand_computed() -> None:
|
||
"""Anchor the integer-øre math: m-cheap = 10 øre/1k, effort standard (100%), max_tokens 100_000
|
||
→ 100_000*100//100 * 10 // 1000 = 1000 øre/run; 3 projects → 3000 øre."""
|
||
pricing = costsim.PricingContract(**_VALID_PRICING)
|
||
assert costsim.estimate_run_ore("m-cheap", "standard", pricing, max_tokens=100_000) == 1000
|
||
assert (
|
||
costsim.estimate_portfolio_ore(
|
||
"m-cheap", "standard", pricing, n_projects=3, max_tokens=100_000
|
||
)
|
||
== 3000
|
||
)
|
||
|
||
|
||
def test_unknown_effort_raises() -> None:
|
||
pricing = costsim.PricingContract(**_VALID_PRICING)
|
||
with pytest.raises(ValueError, match="unknown effort"):
|
||
costsim.estimate_run_ore("m-cheap", "turbo", pricing)
|
||
|
||
|
||
def test_estimate_table_structure() -> None:
|
||
pricing = costsim.PricingContract(**_VALID_PRICING)
|
||
mm = {"prof": {"proposer": "m-cheap", "checker": "m-dear"}}
|
||
table = costsim.build_estimate_table(
|
||
"prof", ["low", "high"], pricing, n_projects=2, model_map=mm
|
||
)
|
||
assert len(table["rows"]) == 4 # 2 roles × 2 efforts
|
||
for row in table["rows"]:
|
||
assert row["estimat_type"] == "øvre grense"
|
||
assert isinstance(row["estimat_kr"], str) # edge-formatted string, never a float
|
||
assert table["kost_mot_verdi"]["kjoring_kostet_ore"] > 0
|
||
assert table["pricing_source"] == "unit-test prices"
|
||
|
||
|
||
def test_ore_to_kr_str_formats_money() -> None:
|
||
"""Value-level guard for the single money-display edge (``_ore_to_kr_str``): the integer-øre
|
||
total → user-facing NOK string. Pins the actual Norwegian formatting — non-breaking-space (\\xa0)
|
||
thousands separator + comma decimals — so a regression (wrong ``divmod``, dropped padding,
|
||
øre/kr transposition, or the \\xa0 → ASCII space) fails loudly instead of shipping a wrong figure.
|
||
A structural ``isinstance(..., str)`` check cannot catch any of those."""
|
||
# thousands grouping (docstring's own worked example)
|
||
assert costsim._ore_to_kr_str(123456) == "1\xa0234,56\xa0kr"
|
||
# sub-100-øre → two-digit zero-padded rest
|
||
assert costsim._ore_to_kr_str(5) == "0,05\xa0kr"
|
||
# negative-sign branch
|
||
assert costsim._ore_to_kr_str(-123456) == "-1\xa0234,56\xa0kr"
|
||
|
||
|
||
def test_cli_prints_estimate_table(capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""CLI smoke: ``main`` prints the estimate table + kost-mot-verdi + adoption footer, rc 0."""
|
||
rc = costsim.main(["--projects", "4", "--profile", "local"])
|
||
out = capsys.readouterr().out
|
||
assert rc == 0
|
||
assert "Kostnadsestimat" in out
|
||
assert "kost-mot-verdi" in out
|
||
assert "Adopsjonssti" in out
|
||
|
||
|
||
def test_cli_bad_config_returns_nonzero(capsys: pytest.CaptureFixture[str]) -> None:
|
||
rc = costsim.main(["--pricing", "/nonexistent/pricing.json"])
|
||
assert rc == 1
|