# 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 def test_mange_forskjeller_kuttes_og_alt_viser_resten(monkeypatch: pytest.MonkeyPatch) -> None: """Cowork-prøven 2026-11-11: 274 linjer ble kuttet uten at brukeren kunne få dem fram.""" fasit = [f"linje {i}" for i in range(60)] naa = [f"rad {i}" for i in range(60)] kort = kontroll.forskjeller(fasit, naa) assert len(kort) == kontroll.MAKS_LINJER + 1 assert kort[-1].endswith("linjer til; «kontroll --alt» viser alle)") monkeypatch.setattr(kontroll, "VISNING", {"alt": True}) alle = kontroll.forskjeller(fasit, naa) assert "-linje 59" in alle assert "+rad 59" in alle assert not any("linjer til" in linje for linje in alle) 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_kontroll_avviser_ukjent_valg(hjem: Path) -> None: prosess = kjor("kontroll", "--alle", hjem=hjem) assert prosess.returncode == 2 assert "Bruk: kjor.py kontroll [--rask] [--alt]" in prosess.stderr def test_kontroll_med_alt_gir_samme_dom(lett_kopi: Path, hjem: Path) -> None: prosess = kjor("--modell", str(lett_kopi), "kontroll", "--rask", "--alt", hjem=hjem) assert prosess.returncode == 0, prosess.stdout + prosess.stderr assert "Nivå (uten full kjøring, --rask): bit for bit som notatet." 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