portfolio-optimiser/tests/test_costsim.py
Kjell Tore Guttormsen 0670b2a6d8 test(s36): value-level guard for _ore_to_kr_str money formatter
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
2026-07-15 10:25:05 +02:00

121 lines
4.5 KiB
Python
Raw 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.

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