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>
268 lines
13 KiB
Python
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
|