fire-planner/tests/test_col.py
Viktor Barzin e72fd22a17 col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan
The Monte Carlo used to compare jurisdictions at a flat London-equivalent
spend, which silently overstated the cost-of-living for any move to a
cheaper region. Now every cross-jurisdiction simulation auto-scales
spending_gbp by the real Numbeo/Expatistan ratio between the user's
baseline city and the target city.

Architecture:
- fire_planner/col/baseline.py — 22 cities with headline Numbeo data
  (source URLs + snapshot dates embedded) — fallback when scraper fails
- col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed,
  polite 1.1s rate-limit, EUR/USD anchored
- col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL)
- col/service.py — sync compute_col_ratio() for the simulator; async
  lookup_city_cached() with source reconciliation for the refresh CronJob
- alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name)

Simulator wiring:
- SimulateRequest gains col_auto_adjust=True (default), col_baseline_city,
  col_target_city. Defaults pick the jurisdiction's representative city.
- _resolve_col_adjustment scales spending_gbp before path-building.
- SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp.

CLIs:
- python -m fire_planner col-seed — loads BASELINES into col_snapshot
  (post-migration seed step)
- python -m fire_planner col-refresh-stale --within-days 7 — used by the
  weekly fire-planner-col-refresh CronJob

268 tests pass. Mypy strict + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00

104 lines
4.1 KiB
Python

"""Tests for the COL module — baseline lookup + ratio + simulator wiring."""
from __future__ import annotations
from decimal import Decimal
import pytest
from fire_planner.col import (
JURISDICTION_REPRESENTATIVE_CITY,
compute_col_ratio,
lookup_city,
representative_city_for,
)
from fire_planner.col.baseline import BASELINES
from fire_planner.col.models import CityCostIndex
class TestBaselineCoverage:
"""Every jurisdiction with a representative city must have a baseline."""
def test_all_representative_cities_have_baselines(self) -> None:
missing = [
city for city in JURISDICTION_REPRESENTATIVE_CITY.values() if city not in BASELINES
]
assert missing == [], (
f"jurisdiction map points at city(s) without baselines: {missing}"
)
def test_baselines_have_positive_totals(self) -> None:
for slug, idx in BASELINES.items():
assert idx.total_single_no_rent_gbp > 0, f"{slug} no_rent must be positive"
assert idx.total_single_with_rent_gbp > idx.total_single_no_rent_gbp, (
f"{slug} with_rent must exceed no_rent — rent should be a positive add"
)
def test_baseline_source_provenance_present(self) -> None:
for slug, idx in BASELINES.items():
assert idx.source.name in {"numbeo", "expatistan", "baseline", "manual"}
assert idx.source.url is not None, f"{slug} baseline missing source URL"
assert idx.source.url.startswith("https://"), f"{slug} URL must be https"
class TestLookup:
def test_lookup_known_city(self) -> None:
london = lookup_city("london")
assert isinstance(london, CityCostIndex)
assert london.city == "London"
assert london.country == "United Kingdom"
def test_lookup_normalises_input(self) -> None:
# mixed case, spaces → slug
assert lookup_city("Kuala Lumpur").city == "Kuala Lumpur"
assert lookup_city(" Bangkok ").city == "Bangkok"
def test_lookup_unknown_raises(self) -> None:
with pytest.raises(KeyError, match="No COL baseline"):
lookup_city("atlantis")
class TestColRatio:
def test_identity_returns_one(self) -> None:
assert compute_col_ratio("london", "london") == Decimal("1")
def test_sofia_cheaper_than_london(self) -> None:
ratio = compute_col_ratio("london", "sofia")
assert ratio < Decimal("1"), "Sofia must be cheaper than London"
assert ratio > Decimal("0.2"), "Sofia ratio looks implausibly low"
# Real Numbeo number is ~0.41
assert Decimal("0.35") < ratio < Decimal("0.50")
def test_dubai_cheaper_than_london(self) -> None:
# Dubai is *cheaper* than London on Numbeo's headline because
# London rent dominates. This was a surprise — flag it in the
# baseline note for future-us.
ratio = compute_col_ratio("london", "dubai")
assert ratio < Decimal("1")
assert Decimal("0.70") < ratio < Decimal("0.95")
def test_bangkok_far_cheaper_than_london(self) -> None:
ratio = compute_col_ratio("london", "bangkok")
assert ratio < Decimal("0.40")
def test_inverse_consistency(self) -> None:
# If london→sofia is X, sofia→london should be ~1/X within rounding.
l2s = compute_col_ratio("london", "sofia")
s2l = compute_col_ratio("sofia", "london")
assert abs(l2s * s2l - Decimal("1")) < Decimal("0.001")
class TestRepresentativeCity:
def test_known_jurisdictions(self) -> None:
assert representative_city_for("uk") == "london"
assert representative_city_for("cyprus") == "limassol"
assert representative_city_for("bulgaria") == "sofia"
assert representative_city_for("uae") == "dubai"
assert representative_city_for("malaysia") == "kuala-lumpur"
assert representative_city_for("thailand") == "bangkok"
def test_nomad_returns_none(self) -> None:
# Nomad mode is COL-invariant by design — auto-adjust skipped.
assert representative_city_for("nomad") is None
def test_unknown_returns_none(self) -> None:
assert representative_city_for("vulcan") is None