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>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-11 15:27:14 +01:00
commit 776325c680
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 1524 additions and 199 deletions

View file

@ -70,10 +70,11 @@ def hjem(tmp_path: Path) -> Path:
@pytest.fixture
def kopi(tmp_path: Path) -> Path:
"""En kopi av modell/, fasit/ og eksempler/ som testen kan endre. Returnerer kopiens rot."""
"""En kopi av modell/, fasit/ og eksempler/ som testen kan endre. Returnerer kopiens rot.
Kopien er ikke merket som arbeidskopi (ingen LES-MEG.md), som en klone av repoet.
"""
rot = tmp_path / "kopi"
for navn in ("modell", "fasit", "eksempler"):
shutil.copytree(ROT / navn, rot / navn, ignore=shutil.ignore_patterns("__pycache__"))
for navn in ("scenario.py", "arbeidskopi.py"):
(rot / "modell" / navn).unlink(missing_ok=True)
return rot

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import arbeidskopi as arbeidskopi_modul
import pytest
from conftest import EKSEMPLER, FASIT, ROT, kjor
@ -100,3 +101,28 @@ def test_endring_i_kopiens_kjerne_brukes(arbeidskopi: Path, tmp_path: Path) -> N
plugin = kjor("scenario", str(KJEDE_S2), "--ut", str(tmp_path / "plugin"))
assert plugin.returncode == 0, plugin.stderr
assert (tmp_path / "plugin" / "kjede_s2.dat").read_bytes() == (FASIT / "data" / "kjede_s2.dat").read_bytes()
def test_nekter_mappe_inne_i_pluginen(tmp_path: Path) -> None:
mal = ROT / "nyttsted-test-arbeidskopi"
assert not mal.exists()
prosess = kjor("arbeidskopi", str(mal))
assert prosess.returncode == 2, prosess.stdout
assert "ligger inne i" in prosess.stderr
assert not mal.exists()
assert not (tmp_path / "x").exists()
def test_lisenssetningen(arbeidskopi: Path) -> None:
assert "Alt i pluginen er under MIT-lisensen (se `LICENSE`)." in (arbeidskopi / "LES-MEG.md").read_text(
encoding="utf-8")
def test_kommandoene_skrives_for_windows(tmp_path: Path) -> None:
mal = tmp_path / "Kari Nordmann" / "rettsstat-modell"
tekst = arbeidskopi_modul.les_meg(mal, [], windows=True)
assert f'python "{mal / "modell" / "kjor.py"}" scenario' in tekst
assert "python3" not in tekst
assert "'" not in tekst.split("## Slik kjører du kopien", 1)[1].split("## Slik kontrollerer", 1)[0]
posix = arbeidskopi_modul.les_meg(mal, [], windows=False)
assert f"python3 '{mal / 'modell' / 'kjor.py'}' scenario" in posix

View file

@ -3,16 +3,21 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
import kjor as kjor_modul
import pytest
from conftest import DOMSTOL, EKSEMPLER, FASIT, MODELL, NORGE, ROT, fasit, kjor, lag_venv_omvei
if TYPE_CHECKING:
from collections.abc import Callable
UTEN_SITE = [sys.executable, "-S"] # samme Python uten site-packages: numpy og scipy mangler
@ -119,6 +124,74 @@ def test_ingen_pycache_i_kjernen(kopi: Path) -> None:
assert not (kopi / "modell" / "kjerne" / "__pycache__").exists()
# ---------------- ukjente feltnavn (funn 5) ----------------
def skriv_variant(tmp_path: Path, endre: Callable[[dict[str, Any]], None], navn: str = "variant.json") -> Path:
"""En kopi av eksempel_domstol.json med en endring."""
data = json.loads(DOMSTOL.read_text(encoding="utf-8"))
endre(data)
fil = tmp_path / navn
fil.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return fil
def skrivefeil_i_ledd(data: dict[str, Any]) -> None:
domstol = data["ledd"][2]
domstol["prognose_innstrømning"] = domstol.pop("prognose_innstromning")
@pytest.mark.parametrize("kommando", ["diagnose", "forlop"])
def test_skrivefeil_i_ledd_avvises(kommando: str, tmp_path: Path) -> None:
fil = skriv_variant(tmp_path, skrivefeil_i_ledd)
prosess = kjor(kommando, str(fil))
assert prosess.returncode == 2
assert prosess.stdout == ""
assert prosess.stderr.startswith(
'Feil i inndata, ledd 3 (Domstol): feltet "prognose_innstrømning" finnes ikke. '
'Mente du "prognose_innstromning"? Gyldige felt: navn, innstromning, kapasitet, restanse, maal_tid,')
def test_skrivefeil_i_styring_avvises(tmp_path: Path) -> None:
def endre(data: dict[str, Any]) -> None:
data["styring"]["reaktivitett"] = data["styring"].pop("reaktivitet")
prosess = kjor("forlop", str(skriv_variant(tmp_path, endre)))
assert prosess.returncode == 2
assert prosess.stderr == ('Feil i inndata, styring: feltet "reaktivitett" finnes ikke. Mente du "reaktivitet"? '
"Gyldige felt: forsinkelse, reaktivitet, merknad, kilde, kilder.\n")
def test_merknadsfelt_og_ukjente_toppfelt_er_tillatt(tmp_path: Path) -> None:
def endre(data: dict[str, Any]) -> None:
data["ledd"][2].update(merknad="fri tekst", kilde="SSB", kilder=["a", "b"])
data["styring"]["merknad"] = "regime A"
data["noe_annet"] = {"fritt": True}
prosess = kjor("diagnose", str(skriv_variant(tmp_path, endre)))
assert prosess.returncode == 0, prosess.stderr
assert prosess.stdout == fasit("diagnose_eksempel_domstol.txt")
def test_feltene_leses_fra_kjernen_i_modellen(kopi: Path, tmp_path: Path) -> None:
diagnose = kopi / "modell" / "kjerne" / "diagnose.py"
tekst = diagnose.read_text(encoding="utf-8")
assert '"innstromning_for_okning")' in tekst
diagnose.write_text(tekst.replace('"innstromning_for_okning")', '"innstromning_for_okning", "nytt_felt")', 1),
encoding="utf-8")
fil = skriv_variant(tmp_path, lambda data: data["ledd"][0].update(nytt_felt=1))
assert kjor("diagnose", str(fil)).returncode == 2
prosess = kjor("--modell", str(kopi), "diagnose", str(fil))
assert prosess.returncode == 0, prosess.stderr
def test_feil_form_overlates_til_kjernen(tmp_path: Path) -> None:
fil = tmp_path / "feil.json"
fil.write_text('{"ledd": [1, {"navn": "A"}]}', encoding="utf-8")
prosess = kjor("diagnose", str(fil))
assert prosess.returncode == 2
assert prosess.stderr == "Feil i inndata, ledd 1: hvert ledd må være et objekt med felt og verdier.\n"
# ---------------- --data og valg av Python ----------------
@pytest.mark.parametrize("verdi", [None, "", " ", "${CLAUDE_PLUGIN_DATA}", "/a/${CLAUDE_PLUGIN_DATA}/b"])
@ -229,6 +302,18 @@ def test_velg_tolk(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
assert kjor_modul.velg_tolk(tmp_path).hvor == "mangler"
@pytest.mark.parametrize(("system", "start", "sitert"), [
("nt", "python ", '"/data med mellomrom"'), ("posix", "python3 ", "'/data med mellomrom'"),
])
def test_foreslatt_kommando_per_system(system: str, start: str, sitert: str) -> None:
valg = kjor_modul.Valg(argv=[], data=Path("/data med mellomrom"), data_oppgitt=True, modell=MODELL,
kommando="forlop", args=[])
tekst = kjor_modul.kommando_tekst(valg, "miljo", "--opprett", system=system)
assert tekst.startswith(start)
assert f"--data {sitert} miljo --opprett" in tekst
assert kjor_modul.python_navn(system) == start.strip()
# ---------------- miljo --opprett (uten å installere) ----------------
def test_opprett_proever_laaste_versjoner_forst(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@ -302,6 +387,7 @@ def test_modell_i_arbeidskopi(kopi: Path, undermappe: str) -> None:
@pytest.mark.parametrize("kommando", ["scenario", "arbeidskopi"])
def test_modul_som_ikke_finnes_ennaa(kopi: Path, kommando: str) -> None:
(kopi / "modell" / f"{kommando}.py").unlink()
prosess = kjor("--modell", str(kopi), kommando, "x")
assert prosess.returncode == 2
assert f"Kommandoen «{kommando}» er ikke på plass i denne modellen ennå" in prosess.stderr
@ -359,6 +445,29 @@ def test_notatet_nekter_ut_mappe_i_modellen() -> None:
assert not (MODELL / "kjerne" / "data").exists()
@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="skrivebeskyttelse virker ikke for root")
def test_notatet_i_mappe_som_ikke_kan_skrives(tmp_path: Path) -> None:
sperret = tmp_path / "sperret"
sperret.mkdir()
sperret.chmod(0o555)
try:
prosess = kjor("notatet", "--ut", str(sperret / "ut"))
finally:
sperret.chmod(0o755)
assert prosess.returncode == 2
assert prosess.stderr.startswith(f"Kan ikke skrive i {(sperret / 'ut').resolve()}: ")
assert prosess.stderr.endswith(". Velg en annen mappe med --ut.\n")
assert "Traceback" not in prosess.stderr
def test_notatet_naar_sim_stopper(kopi: Path, tmp_path: Path) -> None:
(kopi / "modell" / "kjerne" / "sim.py").write_text("print('start')\nraise SystemExit(5)\n", encoding="utf-8")
prosess = kjor("--modell", str(kopi), "notatet", "--ut", str(tmp_path / "ut"))
assert prosess.returncode == 2
assert "Kjøringen av sim.py stoppet underveis (exit-kode 5)" in prosess.stderr
assert "start\n" in prosess.stdout
@pytest.mark.treg
def test_notatet_gjenskaper_datafilene(tmp_path: Path) -> None:
ut = tmp_path / "ut"

View file

@ -3,12 +3,15 @@
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from typing import TYPE_CHECKING
import kontroll
import pytest
from conftest import kjor
from conftest import FASIT, kjor
if TYPE_CHECKING:
from pathlib import Path
@ -70,23 +73,53 @@ def test_dat_ulik_form(naa: str) -> None:
# ---------------- 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_gjor_det_den_kan(hjem: Path) -> None:
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 "Nivå (bare det som er kjørt): bit for bit som notatet." 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:
@ -97,32 +130,91 @@ def endre_diagnose(kopi: Path) -> None:
fil.write_text(tekst.replace("UPS_STAR = 0.95", "UPS_STAR = 0.90"), encoding="utf-8")
def test_endret_kjerne_i_pluginen_er_en_feil(kopi: Path, hjem: Path) -> None:
endre_diagnose(kopi)
prosess = kjor("--modell", str(kopi), "kontroll", "--rask", hjem=hjem)
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 "Pluginens modell skal regne som notatet, så dette er en feil." 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(kopi: Path, hjem: Path) -> None:
endre_diagnose(kopi)
(kopi / "LES-MEG.md").write_text("Arbeidskopi\n", encoding="utf-8")
prosess = kjor("--modell", str(kopi), "kontroll", "--rask", hjem=hjem)
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_manglende_fasit(kopi: Path, hjem: Path) -> None:
(kopi / "fasit" / "diagnose_norge_2025.txt").unlink()
prosess = kjor("--modell", str(kopi), "kontroll", "--rask", hjem=hjem)
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 "norge_2025: fant ikke" in prosess.stdout
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:
@ -130,36 +222,41 @@ def lag_utvidelser(kopi: Path, filer: dict[str, str]) -> None:
mappe = kopi / "modell" / "utvidelser"
mappe.mkdir(exist_ok=True)
for gammel in mappe.glob("*.py"):
gammel.unlink()
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(kopi: Path, hjem: Path) -> None:
lag_utvidelser(kopi, {
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(kopi), "kontroll", "--rask", hjem=hjem)
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(kopi: Path, hjem: Path) -> None:
lag_utvidelser(kopi, {
"avvik": "def grensetilfelle():\n return [('p3 med trappetrinn', 1e-12), ('p6', 0.0)]\n",
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",
})
prosess = kjor("--modell", str(kopi), "kontroll", "--rask", hjem=hjem)
(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: grensetilfellet gjenskaper ikke kjernen eksakt (avvik, odelagt, tom; se under)." in (
prosess.stdout)
assert "avvik: p3 med trappetrinn: avvik 1,0e-12, skal være eksakt 0" in prosess.stdout
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
@ -167,4 +264,5 @@ 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

View file

@ -302,3 +302,271 @@ def test_alle_eksempelscenarioer_er_gyldige() -> None:
assert sc.kjoringer, fil.name
if fil.parent == NYE:
assert sc.beskrivelse, f"{fil.name} mangler beskrivelse"
# ---------------- startlikevekten (gjennomgang E, funn 1) ----------------
def test_endret_grunnparameter_stopper_med_to_veier_ut(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "parametere": {"P": {"gamma": 0.15}}, "sjokk": {"faktor": 1.0}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
for t in ("startlikevekt: startpunktet er ikke en likevekt", "pådraget Omega0 (0,12)",
"ville drevet bort fra 1,00 også uten sjokk", "passer ikke lenger til strømmen",
'"startlikevekt": "kalibrer"', "(P.Omega0, CH0.Z, CH0.m)", '"startlikevekt": "ubalanse"'):
assert t in err, err
assert not (tmp_path / "ut").exists()
@pytest.mark.parametrize(("p", "modell"), [({"Lam0": 0.6}, "simulate"), ({"delta": 0.03}, "simulate"),
({"T0": 1.2}, "simulate"), ({"eta": 2.0}, "simulate"),
({"Omega0": 0.13}, "simulate"), ({"Kstar": 1.1}, "simulate"),
({"Tprop": 0.9}, "simulate"), ({"Tmax": 0.9}, "simulate"),
({"gamma": 0.1}, "simulate_soc"), ({"Lam0": 0.7}, "simulate_chain2")])
def test_hver_grunnparameter_i_likevekten_sjekkes(p: dict[str, float], modell: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": modell, "parametere": {"P": p}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "startpunktet er ikke en likevekt" in err, err
@pytest.mark.parametrize(("modell", "par"), [("simulate", {"P": {"ksat": 1.5}}),
("simulate_soc", {"SOC": {"eps": 0.5}}),
("simulate_chain", {"CH0": {"Z": 0.12}}),
("simulate_chain", {"CH0": {"varsigma": 0.02}})])
def test_parametere_kjernen_selv_rekalibrerer_stopper_ikke(
modell: str, par: dict[str, object], tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": modell, "parametere": par})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
def test_kalibrer_gir_ro_for_sjokket(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"navn": "k", "modell": "simulate", "startlikevekt": "kalibrer",
"parametere": {"P": {"Lam0": 0.6, "gamma": 0.2}}, "sjokk": {"faktor": 1.0}})
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
assert "Referansetilstanden er kalibrert på nytt" in ut
assert "P.Omega0 0,14 (var 0,12)" in ut
assert "tilliten er 0,60 der" in ut
rader = (tmp_path / "ut" / "k.csv").read_text(encoding="utf-8").splitlines()[1:]
assert all(abs(float(r.split(",")[1]) - 1.0) < 1e-9 for r in rader) # K står i ro
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
kalibrert = data["kjoringer"][0]["startlikevekt"]["kalibrert"]
assert kalibrert["P.Omega0"]["for"] == 0.12
assert data["kjoringer"][0]["brukt"]["parametere"]["P"]["Omega0"] == kalibrert["P.Omega0"]["etter"]
def test_kalibrer_kjeden_regner_om_z_og_m(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "startlikevekt": "kalibrer",
"parametere": {"P": {"gamma": 0.15}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
brukt = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))["kjoringer"][0]["brukt"]
j0 = 0.15 * 0.8 * 1.0
assert brukt["parametere"]["CH0"]["Z"] == j0 / 0.95 # sim.py:151
assert brukt["parametere"]["CH0"]["m"] == 3.0 - j0 / (j0 / 0.95 * (1 / 3.0 + 0.01)) # sim.py:155
assert brukt["parametere"]["P"]["Omega0"] == pytest.approx(j0 + 0.02, abs=1e-15)
@pytest.mark.parametrize("p", [{"Kstar": 1.1}, {"Omega0": 0.13}, {"Tprop": 0.9}])
def test_kalibrer_retter_ikke_det_som_er_satt_eller_ligger_fast(
p: dict[str, float], tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate", "startlikevekt": "kalibrer", "parametere": {"P": p}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert '"kalibrer" kan ikke gjøre starten til en likevekt' in err
def test_ubalanse_kjorer_og_viser_driften(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate", "startlikevekt": "ubalanse", "parametere": {"P": {"Lam0": 0.6}},
"sjokk": {"faktor": 1.0}})
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0, err
assert "1,00 er nivået i referanselikevekten" not in ut
assert 'startpunktet er ikke en likevekt ("startlikevekt": "ubalanse"): pådraget Omega0' in ut
assert "Fram til måned 12 har K gått fra 1,000 til 1,0" in ut
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
assert data["kjoringer"][0]["startlikevekt"]["ubalanse"]
def test_ubalanse_med_sjokk_viser_driften_til_sjokkmaaneden(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "startlikevekt": "ubalanse",
"parametere": {"P": {"gamma": 0.15}}, "sjokk": {"fra": 18}})
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0
assert "Fram til måned 18 har K gått fra 1,000 til" in ut
assert "saksbehandlingstiden (mnd) fra 15,7 til" in ut
@pytest.mark.parametrize("valg", ["krev", "kalibrer", "ubalanse"])
def test_simulate_endo_med_annen_strom_ved_start_stopper_alltid(
valg: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_endo", "regel": "koord", "startlikevekt": valg,
"parametere": {"P": {"gamma": 0.11}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "sim.py:588" in err
assert "endring i kjernen" in err
def test_eta_i_kjeden_kan_ikke_kalibreres(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain", "startlikevekt": "kalibrer", "parametere": {"P": {"eta": 2.0}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "uten eta" in err
# ---------------- funn 2, 7, 8, 12, 13, 14 og kapasitet i simulate_chain2 ----------------
def test_kjoring_som_eksploderer_stopper_for_noe_skrives(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "utvidelse:paadrag_tidsserie",
"utvidelse": {"punkter": [[12, 1e308], [13, 1e308]]}})
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert ut == ""
assert "ikke er endelige tall" in err
assert "Ingenting er skrevet" in err
assert not (tmp_path / "ut").exists()
def test_absurd_k_gir_merknad(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate", "sjokk": {"faktor": 1e6}})
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0
assert "## Merknader (kjøringen er ikke stoppet)" in ut
assert "utenfor området kjernen selv regner på" in ut
def test_notatets_vippekjoring_gir_ingen_merknad(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
assert kjor_fil(NOTATET / "vippe.json", tmp_path / "ut", capsys)[1].count("Merknader") == 0
def test_mappe_som_ikke_kan_skrives(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate"})
laast = tmp_path / "laast"
laast.mkdir()
laast.chmod(0o500)
try:
kode, _, err = kjor_fil(fil, laast / "ut", capsys)
finally:
laast.chmod(0o700)
assert kode == 2
assert err.startswith(f"Kan ikke skrive i {laast / 'ut'}: ")
assert err.rstrip().endswith("Velg en annen mappe med --ut.")
assert "Traceback" not in err
def test_koen_har_enhet(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NOTATET / "kjede_s2.json", tmp_path / "ut", capsys)
assert kode == 0
assert "Kø i domstolen ved slutt (mnd innstrømning før sjokket)" in ut
assert "Køen er målt i måneder av innstrømningen til domstolen før sjokket" in ut
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
assert data["kjoringer"][0]["nokkeltall"]["Q_slutt_mnd"] == pytest.approx(56.4, abs=0.05) # Qend 5,64 / 0,1
@pytest.mark.parametrize(("periode", "tekst"), [(0.07, "minst 1"), (1.5, "helt antall måneder")])
def test_budsjettsyklus_er_hele_maaneder(periode: float, tekst: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_endo", "regel": "koord", "parametere": {"ENDO": {"period": periode}}})
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 2
assert "parametere.ENDO.period" in err
assert tekst in err
@pytest.mark.parametrize(("data", "tekst"), [
({"modell": "simulate", "slutt": 0.04, "styring": {"forsinkelse": 0}}, "slutt: må være et tall minst 1"),
({"modell": "simulate", "slutt": 2}, "slutt: horisonten (2 mnd) er kortere enn forsinkelsen"),
({"modell": "simulate", "sveip": {"parameter": "slutt", "verdier": [2]}}, "slutt: horisonten (2 mnd)"),
])
def test_for_kort_horisont_navngir_slutt(data: dict[str, object], tekst: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
kode, _, err = kjor_fil(skriv(tmp_path, data), tmp_path / "ut", capsys)
assert kode == 2
assert tekst in err, err
@pytest.mark.parametrize(("data", "tekst"), [
({"modell": "simulate_chain", "slutt": 6}, "Sjokket kommer i måned 12, ikke før slutt (6)"),
({"modell": "simulate_soc", "slutt": 30}, "Sjokket varer til måned 36, etter slutt (30)"),
])
def test_sjokk_utenfor_horisonten_gir_merknad(data: dict[str, object], tekst: str, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(skriv(tmp_path, data), tmp_path / "ut", capsys)
assert kode == 0
assert tekst in ut
@pytest.mark.parametrize("innhold", [{"kjoringer": [{"fil": "viktig.docx"}]},
{"scenario": "x", "scenariofil_sha256": "", "kjerne_sha256": {},
"kjoringer": [{"fil": "viktig.docx"}, {"fil": "../utenfor.csv"}]}])
def test_sletter_aldri_andres_filer(innhold: dict[str, object], tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
ut = tmp_path / "ut"
ut.mkdir()
(ut / "resultat.json").write_text(json.dumps(innhold), encoding="utf-8")
(ut / "viktig.docx").write_text("x", encoding="utf-8")
(tmp_path / "utenfor.csv").write_text("x", encoding="utf-8")
kjor_fil(skriv(tmp_path, {"navn": "n", "modell": "simulate"}), ut, capsys)
assert (ut / "viktig.docx").is_file()
assert (tmp_path / "utenfor.csv").is_file()
def test_kapasitet_i_simulate_chain2_vises(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
fil = skriv(tmp_path, {"modell": "simulate_chain2", "tiltak": {"kapasitet": {"faktor": 1.34},
"andel_utenom": {"andel": 0.2}}})
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
assert kode == 0
tall = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))["kjoringer"][0]["nokkeltall"]
assert tall["Z_slutt"] == pytest.approx(1.34)
assert tall["utenom_slutt_pst"] == pytest.approx(20.0)
assert "Kapasitet, ved slutt (1,00 = ved start)" in ut
def test_bruk_nevner_riktig_python() -> None:
assert scenario.BRUK.startswith(f"Bruk: {scenario.PYTHON} modell/kjor.py scenario")
# ---------------- modeller med kø driver sakte før sjokket ----------------
def test_modell_med_ko_viser_driften_for_sjokket(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NOTATET / "kjede_s2.json", tmp_path / "ut", capsys)
assert kode == 0
assert "1,00 er nivået ved start for kriminaliteten K og politiets innsats T (notatets referansetilstand)" in ut
assert "nesten, men ikke helt, en likevekt" in ut
assert "referanselikevekten" not in ut
assert "i ro" not in ut
assert "Fram til måned 12 har K gått fra 1,000 til 1,007, innsatsen T fra 1,00 til 1,02" in ut
def test_modell_med_ko_uten_sjokk_sier_at_den_kryper(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NYE / "uten_sjokk_kjede.json", tmp_path / "ut", capsys)
assert kode == 0
assert "Fram til måned" not in ut
assert 'kryper modellen med kø sakte oppover av seg selv; en kjøring uten sjokk ("sjokk": {"faktor": 1})' in ut
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
tall = data["kjoringer"][0]["nokkeltall"]
assert round(tall["K_slutt"], 3) == 1.018
assert round(tall["tauD_slutt"], 1) == 4.6
@pytest.mark.parametrize("modell", ["simulate_chain2", "simulate_endo", "utvidelse:paadrag_tidsserie"])
def test_alle_modeller_med_ko_far_driftlinjen(modell: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
data: dict[str, object] = {"modell": modell}
if modell == "simulate_endo":
data["regel"] = "koord"
kode, ut, _ = kjor_fil(skriv(tmp_path, data), tmp_path / "ut", capsys)
assert kode == 0
assert "nesten, men ikke helt, en likevekt" in ut
assert "Fram til måned 12 har K gått fra 1,000 til" in ut
@pytest.mark.parametrize("fil", ["p3", "vippe"])
def test_modell_uten_ko_har_ingen_driftlinje(fil: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
kode, ut, _ = kjor_fil(NOTATET / f"{fil}.json", tmp_path / "ut", capsys)
assert kode == 0
assert "Slik leses tallene: 1,00 er nivået i referanselikevekten før sjokket" in ut
assert "Fram til måned" not in ut
assert "nesten, men ikke helt" not in ut

View file

@ -156,3 +156,42 @@ def test_malen_kjorer_med_scenariofil(modell_med_mal: Path, tmp_path: Path) -> N
assert prosess.returncode == 0, prosess.stderr
assert (tmp_path / "en" / "en.dat").read_bytes() == (FASIT / "data" / "p3.dat").read_bytes()
assert (tmp_path / "annen" / "annen.dat").read_bytes() != (FASIT / "data" / "p3.dat").read_bytes()
# ---------------- uavhengig av utvidelsens egen beregning: gjennom kjøreren mot fasit ----------------
NOTATFILER = [(navn, fil) for navn in UTVIDELSER
for fil in getattr(scenario.last_utvidelse(navn), "GJENSKAPER_NOTATET", ())]
@pytest.mark.parametrize("navn", UTVIDELSER)
def test_utvidelsen_oppgir_notatfiler(navn: str) -> None:
filer = getattr(scenario.last_utvidelse(navn), "GJENSKAPER_NOTATET", None)
if filer is None:
pytest.skip(f"{navn} oppgir ikke GJENSKAPER_NOTATET; grensetilfellet er bare sjekket med egen beregning")
assert filer, f"{navn}: GJENSKAPER_NOTATET er tom"
for fil in filer:
assert (ROT / "eksempler" / "scenarioer" / "notatet" / f"{fil}.json").is_file(), fil
assert (FASIT / "data" / f"{fil}.dat").is_file(), fil
@pytest.mark.parametrize(("navn", "fil"), NOTATFILER)
def test_standardverdiene_gjenskaper_notatfilene_gjennom_kjoreren(
navn: str, fil: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
data = json.loads((ROT / "eksempler" / "scenarioer" / "notatet" / f"{fil}.json").read_text(encoding="utf-8"))
data["modell"] = f"utvidelse:{navn}"
kode, err = kjor_fil(data, tmp_path, capsys)
assert kode == 0, err
assert (tmp_path / "ut" / f"{fil}.dat").read_bytes() == (FASIT / "data" / f"{fil}.dat").read_bytes()
def test_utvidelsen_stoppes_ved_ubalanse_og_kan_kalibreres(tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
data: dict[str, object] = {"navn": "u", "modell": "utvidelse:paadrag_tidsserie",
"parametere": {"P": {"gamma": 0.15}}}
kode, err = kjor_fil(data, tmp_path, capsys)
assert kode == 2
assert "startpunktet er ikke en likevekt" in err
data["startlikevekt"] = "kalibrer"
kode, err = kjor_fil(data, tmp_path, capsys)
assert kode == 0, err