dynamisk-rettsstat/tests/test_kontroll.py
Kjell Tore Guttormsen 776325c680
fix: findings from the independent review
Scenario runner: stops when overridden base parameters leave the start out
of balance (new field startlikevekt: krev, kalibrer, ubalanse); stops on
non-finite results; says that models with a court queue drift slowly even
without a shock and prints the drift before the shock; queue in months of
inflow; never deletes files it did not write; stricter limits.
Check: anchored on the note's own checksums for core and reference files,
runs the scenario files too, exact comparison for the diagnosis, honest
wording when parts were skipped or an extension is broken.
Launcher: unknown field names in input are rejected with a suggestion;
unwritable folders give a message, not a traceback.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:27:14 +01:00

268 lines
13 KiB
Python

# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""kontroll.py: sammenligningene, dommene og nivået."""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from typing import TYPE_CHECKING
import kontroll
import pytest
from conftest import FASIT, kjor
if TYPE_CHECKING:
from pathlib import Path
# ---------------- sammenligningene ----------------
@pytest.mark.parametrize(("tall", "forventet"), [("19", 1.0), ("1,28", 0.01), ("7.21", 0.01),
("0.00000", 1e-5), ("1.11e-15", 1e-17), ("-3.0E+02", 10.0)])
def test_enhet_i_siste_siffer(tall: str, forventet: float) -> None:
assert kontroll.enhet(tall) == pytest.approx(forventet)
def test_tekst_lik() -> None:
assert kontroll.sammenlign_tekst("a 1,23\n", "a 1,23\n").status == kontroll.LIK
def test_tekst_innenfor_toleranse() -> None:
resultat = kontroll.sammenlign_tekst("utnyttelse 1,23 og 7.21\n", "utnyttelse 1,24 og 7.20\n")
assert resultat.status == kontroll.TOLERANSE
assert resultat.avvik == pytest.approx(0.01)
assert "-utnyttelse 1,23 og 7.21" in resultat.detaljer
assert "+utnyttelse 1,24 og 7.20" in resultat.detaljer
@pytest.mark.parametrize(("fasit", "naa"), [("a 1,23\n", "a 1,25\n"), ("a 1,23\n", "b 1,23\n"),
("a 1,23\n", "a 1,23\nb\n"), ("19 %\n", "21 %\n")])
def test_tekst_endret(fasit: str, naa: str) -> None:
resultat = kontroll.sammenlign_tekst(fasit, naa)
assert resultat.status == kontroll.ENDRET
assert resultat.detaljer
DAT = "t K Q\n0.00000 1.00000 0.29100\n0.50000 1.10000 0.30000\n"
def test_dat_lik() -> None:
assert kontroll.sammenlign_dat(DAT, DAT).status == kontroll.LIK
def test_dat_innenfor_toleranse() -> None:
resultat = kontroll.sammenlign_dat(DAT, DAT.replace("1.10000", "1.10001"))
assert resultat.status == kontroll.TOLERANSE
assert resultat.detaljer == ["største absolutte avvik per kolonne: K 1,0e-05"]
def test_dat_endret_viser_avvik_per_kolonne() -> None:
resultat = kontroll.sammenlign_dat(DAT, DAT.replace("1.10000", "1.10030").replace("0.30000", "0.30200"))
assert resultat.status == kontroll.ENDRET
assert resultat.detaljer == ["største absolutte avvik per kolonne: K 3,0e-04, Q 2,0e-03"]
assert resultat.avvik == pytest.approx(0.002)
@pytest.mark.parametrize("naa", ["t K X\n0.00000 1.00000 0.29100\n0.50000 1.10000 0.30000\n",
"t K Q\n0.00000 1.00000 0.29100\n"])
def test_dat_ulik_form(naa: str) -> None:
assert kontroll.sammenlign_dat(DAT, naa).status == kontroll.ENDRET
# ---------------- kontrollen som helhet ----------------
FALSK_SCENARIO = """\
import shutil
from pathlib import Path
def main(argv):
fil, ut = Path(argv[0]), Path(argv[2])
ut.mkdir(parents=True, exist_ok=True)
fasit = Path(__file__).resolve().parent.parent / "fasit" / "data" / (fil.stem + ".dat")
shutil.copyfile(fasit, ut / (fil.stem + ".dat"))
return 0
"""
@pytest.fixture
def lett_kopi(kopi: Path) -> Path:
"""En kopi der scenario.py er byttet mot en som kopierer fasit, så kontroll 6 går fort.
Brukes av testene som gjelder andre kontroller; scenariokjøreren selv prøves på pluginen og i egne tester.
"""
(kopi / "modell" / "scenario.py").write_text(FALSK_SCENARIO, encoding="utf-8")
return kopi
def test_rask_kontroll_av_pluginen(hjem: Path) -> None:
prosess = kjor("kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 0, prosess.stdout + prosess.stderr
assert "1. Kjernen: uendret fra notatet (3 filer," in prosess.stdout
assert "1. Fasit: uendret fra notatet (27 filer)." in prosess.stdout
assert "2. diagnose: lik notatet for eksempel_domstol og norge_2025." in prosess.stdout
assert "3. forlop: lik notatet for eksempel_domstol og norge_2025." in prosess.stdout
assert "4. Full kjøring: hoppet over (--rask)." in prosess.stdout
assert ("6. Scenariokjøreren: 13 av 13 av notatets datafiler gjenskapt byte for byte fra "
"eksempler/scenarioer/notatet/ (mpc11 bare i full kjøring).") in prosess.stdout
assert "Nivå (uten full kjøring, --rask): bit for bit som notatet." in prosess.stdout
def test_kontroll_uten_numpy_skryter_ikke(hjem: Path) -> None:
prosess = kjor("kontroll", python=[sys.executable, "-S"], hjem=hjem)
assert prosess.returncode == 3
assert "2. diagnose: lik notatet" in prosess.stdout
assert "3. forlop: ikke kjørt, numpy og scipy mangler." in prosess.stdout
assert "4. Full kjøring: ikke kjørt, numpy og scipy mangler." in prosess.stdout
assert "6. Scenariokjøreren: ikke kjørt, numpy og scipy mangler." in prosess.stdout
assert ("Nivå: ingen avvik i det som ble kjørt (Kjernen, Fasit, diagnose). Ikke kontrollert: forlop, full "
"kjøring av sim.py, utvidelsenes grensetilfeller, scenariokjøreren.") in prosess.stdout
assert "bit for bit som notatet" not in prosess.stdout
def endre_diagnose(kopi: Path) -> None:
"""Endre målutnyttelsen i kopiens diagnose.py fra 0,95 til 0,90."""
fil = kopi / "modell" / "kjerne" / "diagnose.py"
tekst = fil.read_text(encoding="utf-8")
assert "UPS_STAR = 0.95" in tekst
fil.write_text(tekst.replace("UPS_STAR = 0.95", "UPS_STAR = 0.90"), encoding="utf-8")
def test_endret_kjerne_utenfor_arbeidskopi_er_noytral(lett_kopi: Path, hjem: Path) -> None:
endre_diagnose(lett_kopi)
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1
assert "diagnose.py er endret" in prosess.stdout
assert "2. diagnose: endret (se under)." in prosess.stdout
assert "- Kapasitet: (0,80 x 134,3 + 273/24) / 0,95 = 125,1 saker/mnd, det vil si 19 % mer." in prosess.stdout
assert "Nivå (uten full kjøring, --rask): endret: Kjernen, diagnose, forlop." in prosess.stdout
assert "Er dette pluginen slik den er installert, er det en feil" in prosess.stdout
assert "Er modellen endret med vilje (for eksempel i en klone av repoet), er det en opplysning" in prosess.stdout
assert "så dette er en feil." not in prosess.stdout
def test_endret_kjerne_i_arbeidskopi_er_en_opplysning(lett_kopi: Path, hjem: Path) -> None:
endre_diagnose(lett_kopi)
(lett_kopi / "LES-MEG.md").write_text("Arbeidskopi\n", encoding="utf-8")
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1
assert "(arbeidskopi)" in prosess.stdout
assert "det er en opplysning, ikke en feil" in prosess.stdout
assert "+ Kapasitet: (0,80 x 134,3 + 273/24) / 0,90" in prosess.stdout
def test_endret_kjerne_med_ny_fasit_og_kilde_er_fortsatt_endret(lett_kopi: Path, hjem: Path) -> None:
"""Funn 3: kontrollen ankres i notatet, ikke i KILDE.json og fasit/ i samme mappe."""
endre_diagnose(lett_kopi)
kjerne = lett_kopi / "modell" / "kjerne"
for eksempel in ("eksempel_domstol", "norge_2025"):
ny = subprocess.run([sys.executable, str(kjerne / "diagnose.py"), str(lett_kopi / "eksempler" /
f"{eksempel}.json")], capture_output=True, text=True, encoding="utf-8", check=True)
(lett_kopi / "fasit" / f"diagnose_{eksempel}.txt").write_text(ny.stdout, encoding="utf-8")
kilde = json.loads((kjerne / "KILDE.json").read_text(encoding="utf-8"))
kilde["filer"]["diagnose.py"]["sha256"] = hashlib.sha256((kjerne / "diagnose.py").read_bytes()).hexdigest()
(kjerne / "KILDE.json").write_text(json.dumps(kilde), encoding="utf-8")
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1
assert "1. Kjernen: endret fra notatet" in prosess.stdout
assert "diagnose.py er endret" in prosess.stdout
assert "1. Fasit: fasit er endret i forhold til notatet (2 av 27 filer)." in prosess.stdout
assert "fasit/diagnose_eksempel_domstol.txt: har ikke notatets sha256" in prosess.stdout
assert "2. diagnose: endret (se under)." in prosess.stdout
assert "er endret i forhold til notatet, så sammenligningen sier ingenting om notatet" in prosess.stdout
assert "bit for bit" not in prosess.stdout.split("Nivå")[-1]
def test_manglende_fasit(lett_kopi: Path, hjem: Path) -> None:
(lett_kopi / "fasit" / "diagnose_norge_2025.txt").unlink()
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1
assert "fasit/diagnose_norge_2025.txt: mangler" in prosess.stdout
assert "norge_2025: fant ikke fasit/diagnose_norge_2025.txt" in prosess.stdout
def test_diagnose_krever_eksakt_likhet(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Funn 10: én enhet i siste siffer i diagnosen er endret, ikke innenfor toleranse."""
fasit = tmp_path / "fasit"
fasit.mkdir()
for navn in ("eksempel_domstol", "norge_2025"):
tekst = (FASIT / f"diagnose_{navn}.txt").read_text(encoding="utf-8")
(fasit / f"diagnose_{navn}.txt").write_text(tekst.replace("1,28", "1,29", 1), encoding="utf-8")
monkeypatch.setattr(kontroll, "FASIT", fasit)
assert kontroll.kontroll_utskrift("diagnose", tmp_path, set(), toleranse=False).status == kontroll.ENDRET
assert kontroll.kontroll_utskrift("diagnose", tmp_path, set(), toleranse=True).status == kontroll.TOLERANSE
def test_scenariokjoreren_som_ikke_gjenskaper_notatet(kopi: Path, hjem: Path) -> None:
"""Funn 4: en endring i det scenariokjøreren regner, blir oppdaget av kontroll --rask."""
fil = kopi / "eksempler" / "scenarioer" / "notatet" / "p3.json"
data = json.loads(fil.read_text(encoding="utf-8"))
data["styring"]["reaktivitet"] = 4
fil.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
prosess = kjor("--modell", str(kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1, prosess.stdout + prosess.stderr
assert "6. Scenariokjøreren: 12 av 13 av notatets datafiler gjenskapt byte for byte" in prosess.stdout
assert "p3.dat (endret): største absolutte avvik per kolonne:" in prosess.stdout
assert "endret: Scenariokjøreren." in prosess.stdout
@pytest.mark.parametrize("fjern", ["modell/scenario.py", "eksempler/scenarioer/notatet/endo_ko.json"])
def test_scenariokjoreren_som_mangler_sies_fra(lett_kopi: Path, hjem: Path, fjern: str) -> None:
(lett_kopi / fjern).unlink()
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1, prosess.stdout + prosess.stderr
assert "så den er ikke kontrollert." in prosess.stdout
assert "endret: Scenariokjøreren." in prosess.stdout
def lag_utvidelser(kopi: Path, filer: dict[str, str]) -> None:
"""Legg utvidelser i kopiens modell/utvidelser/ (og fjern dem som lå der)."""
mappe = kopi / "modell" / "utvidelser"
mappe.mkdir(exist_ok=True)
for gammel in mappe.glob("*.py"):
if gammel.name != "__init__.py":
gammel.unlink()
for navn, innhold in filer.items():
(mappe / f"{navn}.py").write_text(innhold, encoding="utf-8")
def test_utvidelse_som_gjenskaper_kjernen(lett_kopi: Path, hjem: Path) -> None:
lag_utvidelser(lett_kopi, {
"eksakt": "import sim\n\ndef grensetilfelle():\n return [('p3 med trappetrinn', 0.0)]\n",
"_hjelper": "raise RuntimeError('filer som begynner med _ skal ikke kjøres')\n",
})
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 0, prosess.stdout + prosess.stderr
assert "5. Utvidelser: grensetilfellene gjenskaper kjernen eksakt (eksakt)." in prosess.stdout
def test_utvidelser_som_ikke_gjenskaper_kjernen(lett_kopi: Path, hjem: Path) -> None:
lag_utvidelser(lett_kopi, {
"avvik": "def grensetilfelle():\n return [('avvik: trapp lik notatets sjokk', 1e-12), ('p6', 0.0)]\n",
"tom": "def grensetilfelle():\n return []\n",
"odelagt": "import sim\nraise RuntimeError('i stykker')\n",
})
(lett_kopi / "LES-MEG.md").write_text("Arbeidskopi\n", encoding="utf-8")
prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", hjem=hjem)
assert prosess.returncode == 1, prosess.stdout + prosess.stderr
assert ("5. Utvidelser: avvik, odelagt, tom gjenskaper ikke kjernen i grensetilfellet. Det er en feil i "
"utvidelsen, og resultatene den gir, kan ikke brukes (se under).") in prosess.stdout
assert " avvik: trapp lik notatets sjokk: avvik 1,0e-12, skal være eksakt 0" in prosess.stdout
assert "avvik: avvik:" not in prosess.stdout
assert "tom: grensetilfelle() ga ingen tilfeller" in prosess.stdout
assert "odelagt: grensetilfelle() kunne ikke kjøres" in prosess.stdout
assert "RuntimeError: i stykker" in prosess.stdout
assert "Nivå (uten full kjøring, --rask): endret: Utvidelser." in prosess.stdout
assert "har en feil. Resultatene den gir, kan ikke brukes før den er rettet." in prosess.stdout
assert "opplysning" not in prosess.stdout
@pytest.mark.treg
def test_full_kontroll(hjem: Path) -> None:
prosess = kjor("kontroll", hjem=hjem)
assert prosess.returncode == 0, prosess.stdout + prosess.stderr
assert "4. Full kjøring: 22 av 22 datafiler like byte for byte med notatet; utskriften lik." in prosess.stdout
assert "6. Scenariokjøreren: 14 av 14 av notatets datafiler gjenskapt byte for byte" in prosess.stdout
assert "\nNivå: bit for bit som notatet.\n" in prosess.stdout