feat(m2): add append-only decision log and beslutning skill
This commit is contained in:
parent
4496c19045
commit
06b45e9168
4 changed files with 761 additions and 0 deletions
163
tests/test_beslutninger_log.py
Normal file
163
tests/test_beslutninger_log.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""The append-only sourcing-decision store (plan Step 22).
|
||||
|
||||
Build-brief 5.4 is a schema, and this file is what keeps it one. The store
|
||||
cannot be migrated -- that is the whole consequence of append-only (risk H10) --
|
||||
so every rule it will ever have has to hold on the first line written, and the
|
||||
fixture corpus under `tests/fixtures/beslutninger/` is the fasit the writer is
|
||||
measured against rather than a sample it may drift from.
|
||||
|
||||
Four of the six rules below exist because the field they guard is read much
|
||||
later by something that cannot ask again:
|
||||
|
||||
* **`score_da` is an integer.** M6 reads it as a value on a weight-dependent
|
||||
scale (risk M8r). A string that looks like a number sorts as text and
|
||||
averages as nothing.
|
||||
* **One to three reasons, from the closed twelve.** `laering` proposes only on
|
||||
"the same `arsak` in at least four of twenty". A thirteenth reason invented
|
||||
at write time is a category that can never reach that threshold and never be
|
||||
counted against it either.
|
||||
* **A note is at most fifteen words.** The cap is what keeps the field a label
|
||||
rather than a second body, and build-brief 5.4 states it as a number.
|
||||
* **A correction names a decision that exists.** `jsonl.fold` raises when it
|
||||
meets a correction pointing at nothing; catching it at write time is the
|
||||
difference between a refusal and a log that cannot be read afterwards.
|
||||
|
||||
Style note: this file follows tests/test_jsonl.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import beslutninger
|
||||
from jobbsok_lib import jsonl
|
||||
|
||||
|
||||
def beslutning(**overrides):
|
||||
record = {
|
||||
"id": "b-0001",
|
||||
"dato": "2026-09-01T09:00:00+02:00",
|
||||
"kilde": "manuell",
|
||||
"url": "https://stillinger.example/sak-0001",
|
||||
"tittel": "Dataingeniør",
|
||||
"arbeidsgiver": "Værøy Sjømat AS",
|
||||
"beslutning": "ja",
|
||||
"arsak": ["fagomrade", "teknologi"],
|
||||
"notat": "treffer kjernen",
|
||||
"score_da": 78,
|
||||
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68, "selskapstype": 63},
|
||||
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20, "selskapstype": 10},
|
||||
"vekt_hash": "sha256:" + "e4" * 32,
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def test_an_append_adds_one_line_and_leaves_prior_bytes_unchanged(empty_workspace):
|
||||
sti = beslutninger.sti(empty_workspace)
|
||||
beslutninger.legg_til(empty_workspace, beslutning(id="b-0001"))
|
||||
with open(sti, "rb") as handle:
|
||||
forst = handle.read()
|
||||
|
||||
beslutninger.legg_til(empty_workspace, beslutning(id="b-0002", beslutning="nei",
|
||||
arsak=["lonn"]))
|
||||
with open(sti, "rb") as handle:
|
||||
etter = handle.read()
|
||||
|
||||
# Not "the file still parses" -- the earlier bytes must be the same bytes.
|
||||
assert etter.startswith(forst)
|
||||
assert etter[len(forst):].count(b"\n") == 1
|
||||
assert [rec["id"] for rec in beslutninger.les(empty_workspace)] == ["b-0001", "b-0002"]
|
||||
|
||||
# And the line carries the two fields no later migration can add.
|
||||
forste = json.loads(forst.decode("utf-8"))
|
||||
assert forste["type"] == "beslutning" and forste["skjema"] == jsonl.SKJEMA
|
||||
assert list(forste) == list(beslutninger.FELTREKKEFOLGE), (
|
||||
"the record is laid out as %r; the fixture corpus is the fasit and it "
|
||||
"lays it out as %r" % (list(forste), list(beslutninger.FELTREKKEFOLGE))
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_three_reasons_from_the_enum_are_accepted_and_nothing_else_is(
|
||||
empty_workspace,
|
||||
):
|
||||
for arsak in (["lonn"], ["lonn", "geografi"], ["lonn", "geografi", "annet"]):
|
||||
record = beslutninger.legg_til(
|
||||
empty_workspace,
|
||||
beslutning(id="b-%d" % len(arsak) + "-".join(arsak), arsak=arsak),
|
||||
)
|
||||
assert record["arsak"] == arsak
|
||||
|
||||
for ugyldig in ([], ["lonn", "geografi", "annet", "tidspunkt"], ["pendling"],
|
||||
["lonn", "lonn"], "lonn"):
|
||||
with pytest.raises(beslutninger.BeslutningError) as fanget:
|
||||
beslutninger.legg_til(empty_workspace, beslutning(id="b-avvist", arsak=ugyldig))
|
||||
assert "arsak" in str(fanget.value)
|
||||
|
||||
# The refusal leaves nothing behind: validation runs before the file opens.
|
||||
assert [rec["id"] for rec in beslutninger.les(empty_workspace)] == [
|
||||
"b-1lonn", "b-2lonn-geografi", "b-3lonn-geografi-annet"
|
||||
]
|
||||
|
||||
|
||||
def test_a_note_longer_than_fifteen_words_is_refused(empty_workspace):
|
||||
akkurat = " ".join("ord%d" % n for n in range(beslutninger.MAKS_NOTAT_ORD))
|
||||
record = beslutninger.legg_til(empty_workspace, beslutning(notat=akkurat))
|
||||
assert record["notat"] == akkurat
|
||||
|
||||
ett_for_mye = akkurat + " overflodig"
|
||||
with pytest.raises(beslutninger.BeslutningError) as fanget:
|
||||
beslutninger.legg_til(empty_workspace, beslutning(id="b-lang", notat=ett_for_mye))
|
||||
assert "15" in str(fanget.value), (
|
||||
"the refusal must name the cap it enforced: %r" % (str(fanget.value),)
|
||||
)
|
||||
|
||||
|
||||
def test_score_da_must_be_an_integer(empty_workspace):
|
||||
for ugyldig in ("78", 78.5, None, True):
|
||||
with pytest.raises(beslutninger.BeslutningError) as fanget:
|
||||
beslutninger.legg_til(
|
||||
empty_workspace, beslutning(id="b-score", score_da=ugyldig)
|
||||
)
|
||||
assert "score_da" in str(fanget.value)
|
||||
|
||||
record = beslutninger.legg_til(empty_workspace, beslutning(score_da=0))
|
||||
assert record["score_da"] == 0 and isinstance(record["score_da"], int)
|
||||
|
||||
|
||||
def test_a_correction_must_reference_a_decision_that_exists(empty_workspace):
|
||||
beslutninger.legg_til(empty_workspace, beslutning(id="b-0001", arsak=["lonn"]))
|
||||
|
||||
with pytest.raises(beslutninger.BeslutningError) as fanget:
|
||||
beslutninger.korriger(
|
||||
empty_workspace,
|
||||
{"id": "k-0001", "dato": "2026-09-02T09:00:00+02:00",
|
||||
"korrigerer": "b-finnes-ikke", "arsak": ["geografi"]},
|
||||
)
|
||||
assert "b-finnes-ikke" in str(fanget.value)
|
||||
|
||||
rettelse = beslutninger.korriger(
|
||||
empty_workspace,
|
||||
{"id": "k-0001", "dato": "2026-09-02T09:00:00+02:00",
|
||||
"korrigerer": "b-0001", "arsak": ["geografi"],
|
||||
"notat": "reisevei var den egentlige grunnen"},
|
||||
)
|
||||
assert rettelse["type"] == "korrigering"
|
||||
|
||||
# A correction is a new line, never an edit: both are still on disk, and
|
||||
# the folded stream shows the corrected decision rather than two rows.
|
||||
linjer = beslutninger.les(empty_workspace)
|
||||
assert [rec["id"] for rec in linjer] == ["b-0001", "k-0001"]
|
||||
strom = beslutninger.stromme(empty_workspace)
|
||||
assert len(strom) == 1
|
||||
assert strom[0]["id"] == "b-0001" and strom[0]["arsak"] == ["geografi"]
|
||||
|
||||
|
||||
def test_the_case_identifier_from_a_norwegian_employer_is_pure_ascii():
|
||||
sak_id = beslutninger.sak_id_for(beslutning())
|
||||
assert sak_id == "2026-09-vaeroy-sjomat-as-dataingenior"
|
||||
sak_id.encode("ascii") # raises if a macOS NFD byte survived (risk H8)
|
||||
|
||||
# And the hand-off only happens on a yes: a `nei` creates no case at all.
|
||||
assert beslutninger.sak_id_for(beslutning(beslutning="nei")) is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue