The operator tested v0.1.0 in Cowork on 2026-11-11. README now says what was measured there and what was not. - miljo: no longer claims that kontroll compares "with tolerance, not bit for bit" when numpy or scipy differ from the note's versions. kontroll always compares byte for byte first, and numpy 2.5.3 gave bit for bit. - kontroll --alt: show every differing line. The cut-off line now names the option instead of only counting the lines left out. - skill simuler: the question before a run must be answerable with yes or no. In the test it became a choice between variants and the run stalled. - skill forbedre: offer the working copy as a zip when the user cannot open the folder it was made in, as in Cowork. 263 tests pass; full kontroll: bit for bit with the note. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
294 lines
14 KiB
Python
294 lines
14 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
|
|
|
|
|
|
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
|