jobbsok/tests/test_sak_status_transitions.py
Kjell Tore Guttormsen 4669cc363d feat(m2): implement event enum and status transition table
Status derives from logg.jsonl alone; sak.md frontmatter is a cache and the
log wins where they disagree (risk H3). --oppdater is the only writer of the
four cached keys, and it leaves a key alone when the derivation is None --
neste_frist in vurderer/soker is the operator's application deadline and no
refresh may eat it.

Two rows of the plan's table are not 5.3 events. Decision ja and operator
close reach the case log as the decision-log records build-brief 5.7 already
mirrors there, so the event enum stays closed at eleven.

Co-Authored-By: Claude <claude-opus-5>
2026-09-05 21:34:04 +02:00

229 lines
9.7 KiB
Python

"""The status machine's event enum and transition table (plan Step 17).
Status is derived from `logg.jsonl` and from nothing else. `sak.md`
frontmatter is a cache, and where the two disagree the log wins and the
disagreement is reported rather than repaired in silence (risk H3). That rule
is only worth anything if a test proves the cache is refreshed, so the two
`--oppdater` cases here assert both halves: that the write-back changes
exactly the four cached keys, and that leaving it out is detectable.
Two triggers in the plan's table are deliberately NOT events. "Decision `ja`"
and "Operator close" are written that way in the plan, and neither is in the
build-brief 5.3 enum, which is closed at eleven. They reach the case log as
the decision-log records build-brief 5.7 already mirrors there -- a
`type: beslutning` line with `beslutning: ja`, and a `type: utfall` line with
`utfall: avsluttet`. That is the one design decision this step had to make and
it is stated in `scripts/sak_status.py`'s docstring rather than smuggled.
The exit-code form of `--check` and `--oppdater` arrives with the CLI in Step
20; here they are the functions underneath, so "exits zero" is spelled "no
divergences".
Style note: this file follows tests/test_jsonl.py.
"""
import datetime
import json
import os
import pytest
from helpers import workspace as workspace_helper
import sak_status
from jobbsok_lib import frontmatter as frontmatter_lib
from jobbsok_lib import jsonl
TODAY = workspace_helper.FROZEN_TODAY
def beslutning_ja(days_ago=0):
"""The sourcing decision, mirrored into the case log (build-brief 5.7)."""
moment = workspace_helper.FROZEN_NOON - datetime.timedelta(days=days_ago)
return {"ts": moment.isoformat(), "type": "beslutning", "beslutning": "ja",
"skjema": jsonl.SKJEMA}
def operator_close(days_ago=0):
"""The operator closing the case: an outcome line, not a 5.3 event."""
moment = workspace_helper.FROZEN_NOON - datetime.timedelta(days=days_ago)
return {"ts": moment.isoformat(), "type": "utfall", "utfall": "avsluttet",
"skjema": jsonl.SKJEMA}
#: The log prefix that leaves a case in each state, newest last. Written as
#: prefixes rather than as one long stream so a row's test says only what it
#: is testing.
VEIEN_TIL = {
"vurderer": lambda: [workspace_helper.event("opprettet", days_ago=60)],
"soker": lambda: VEIEN_TIL["vurderer"]() + [beslutning_ja(days_ago=59)],
"sendt": lambda: VEIEN_TIL["soker"]() + [workspace_helper.event("soknad_sendt", days_ago=58)],
"dialog": lambda: VEIEN_TIL["sendt"]() + [workspace_helper.event("henvendelse_mottatt", days_ago=57)],
"intervju": lambda: VEIEN_TIL["dialog"]() + [workspace_helper.event("intervju_avtalt", days_ago=56)],
"tilbud": lambda: VEIEN_TIL["intervju"]() + [workspace_helper.event("tilbud_mottatt", days_ago=55)],
}
#: Every row of the plan's Step 17 table: (trigger, from-state, to-state,
#: waiting party). A row that is not here is a row nothing proves.
RADER = (
("opprettet", "vurderer", "vurderer", "meg"),
("beslutning_ja", "vurderer", "soker", "meg"),
("soknad_sendt", "soker", "sendt", "dem"),
("bekreftelse_mottatt", "sendt", "sendt", "dem"),
("henvendelse_mottatt", "sendt", "dialog", "meg"),
("svar_sendt", "dialog", "dialog", "dem"),
("intervju_avtalt", "dialog", "intervju", "ingen"),
("intervju_gjennomfort", "intervju", "intervju", "dem"),
("tilbud_mottatt", "intervju", "tilbud", "meg"),
("avsluttet", "tilbud", "avsluttet", "ingen"),
("avslag", "sendt", "avslag", "ingen"),
("trukket", "sendt", "trukket", "ingen"),
("stille", "sendt", "sendt", "dem"),
)
def utlos(trigger, days_ago=1):
if trigger == "beslutning_ja":
return beslutning_ja(days_ago=days_ago)
if trigger == "avsluttet":
return operator_close(days_ago=days_ago)
return workspace_helper.event(trigger, days_ago=days_ago)
@pytest.mark.parametrize("trigger,fra,til,ventende", RADER, ids=[r[0] + "-fra-" + r[1] for r in RADER])
def test_every_row_of_the_transition_table(trigger, fra, til, ventende):
records = VEIEN_TIL[fra]() + [utlos(trigger)]
resultat = sak_status.avgjor(records, TODAY)
assert resultat["status"] == til
assert resultat["ventende_part"] == ventende
def test_an_empty_log_is_vurderer_waiting_on_me():
resultat = sak_status.avgjor([], TODAY)
assert resultat["status"] == "vurderer"
assert resultat["ventende_part"] == "meg"
assert resultat["sist_aktivitet"] is None
def test_duplicate_events_are_idempotent():
once = VEIEN_TIL["sendt"]()
twice = once + [workspace_helper.event("soknad_sendt", days_ago=57)]
assert sak_status.avgjor(once, TODAY)["status"] == sak_status.avgjor(twice, TODAY)["status"]
assert sak_status.avgjor(twice, TODAY)["ventende_part"] == "dem"
def test_shuffled_timestamps_yield_the_same_result():
ordered = VEIEN_TIL["intervju"]()
shuffled = list(reversed(ordered))
assert sak_status.avgjor(shuffled, TODAY) == sak_status.avgjor(ordered, TODAY)
def test_an_off_enum_event_errors_and_names_the_legal_set():
records = VEIEN_TIL["sendt"]() + [workspace_helper.event("purring_sendt", days_ago=1)]
with pytest.raises(sak_status.StatusError) as feil:
sak_status.avgjor(records, TODAY)
melding = str(feil.value)
assert "purring_sendt" in melding
for hendelse in sak_status.HENDELSER:
assert hendelse in melding, "the refusal must name the whole legal enum"
def test_an_illegal_transition_errors_listing_what_is_legal_here():
records = VEIEN_TIL["vurderer"]() + [workspace_helper.event("tilbud_mottatt", days_ago=1)]
with pytest.raises(sak_status.StatusError) as feil:
sak_status.avgjor(records, TODAY)
melding = str(feil.value)
assert "tilbud_mottatt" in melding and "vurderer" in melding
assert "beslutning_ja" in melding, "the refusal must list the triggers that ARE legal"
def test_an_event_after_a_terminal_state_errors():
records = VEIEN_TIL["sendt"]() + [
workspace_helper.event("avslag", days_ago=5),
workspace_helper.event("henvendelse_mottatt", days_ago=1),
]
with pytest.raises(sak_status.StatusError) as feil:
sak_status.avgjor(records, TODAY)
assert "avslag" in str(feil.value)
def test_a_malformed_log_line_fails_loudly_with_its_line_number(tmp_path, empty_workspace):
sak_id = workspace_helper.make_case(
empty_workspace, "Nordlys Data AS", "AI-radgiver", "2026-09",
events=VEIEN_TIL["sendt"](),
)
logg = os.path.join(empty_workspace, "saker", sak_id, "logg.jsonl")
with open(logg, "r", encoding="utf-8") as handle:
linjer = handle.readlines()
linjer.insert(1, "{ this is not json }\n")
with open(logg, "w", encoding="utf-8") as handle:
handle.writelines(linjer)
with pytest.raises(jsonl.JsonlError) as feil:
sak_status.les_sak(empty_workspace, sak_id)
assert "line 2" in str(feil.value)
def test_frontmatter_that_disagrees_with_the_log_loses_and_is_reported(empty_workspace):
sak_id = workspace_helper.make_case(
empty_workspace, "Havbris Energi AS", "Losningsarkitekt", "2026-09",
events=VEIEN_TIL["sendt"](), status="dialog", ventende_part="meg",
)
resultat = sak_status.status_for_sak(empty_workspace, sak_id, TODAY)
assert resultat["status"] == "sendt", "the log is the truth, not the cache"
nokler = [d["nokkel"] for d in resultat["divergens"]]
assert "status" in nokler and "ventende_part" in nokler
for avvik in resultat["divergens"]:
if avvik["nokkel"] == "status":
assert avvik["hurtigbuffer"] == "dialog"
assert avvik["utledet"] == "sendt"
def test_oppdater_refreshes_exactly_the_four_cached_keys(empty_workspace):
sak_id = workspace_helper.make_case(
empty_workspace, "Havbris Energi AS", "Losningsarkitekt", "2026-09",
events=VEIEN_TIL["sendt"](), status="dialog", ventende_part="meg",
kilde="manuell", url="https://jobb.example/1", score=71,
)
sti = os.path.join(empty_workspace, "saker", sak_id, "sak.md")
with open(sti, "r", encoding="utf-8") as handle:
for_meta, for_body = frontmatter_lib.parse(handle.read())
resultat = sak_status.status_for_sak(empty_workspace, sak_id, TODAY)
endret = sak_status.oppdater(empty_workspace, sak_id, resultat)
with open(sti, "r", encoding="utf-8") as handle:
etter_meta, etter_body = frontmatter_lib.parse(handle.read())
assert etter_body == for_body, "the body is not the cache and must not move"
assert set(endret) <= set(sak_status.CACHE_KEYS)
for nokkel in for_meta:
if nokkel in sak_status.CACHE_KEYS:
continue
assert etter_meta[nokkel] == for_meta[nokkel], "%r is not a cached key" % nokkel
assert etter_meta["status"] == "sendt"
assert etter_meta["ventende_part"] == "dem"
def test_check_is_clean_after_oppdater_and_dirty_without_it(empty_workspace):
sak_id = workspace_helper.make_case(
empty_workspace, "Storelva Kommune", "Fagleder", "2026-09",
events=VEIEN_TIL["soker"](), status="soker", ventende_part="meg",
)
sak_status.oppdater(
empty_workspace, sak_id, sak_status.status_for_sak(empty_workspace, sak_id, TODAY)
)
assert sak_status.divergenser(empty_workspace, TODAY) == []
logg = os.path.join(empty_workspace, "saker", sak_id, "logg.jsonl")
with open(logg, "a", encoding="utf-8") as handle:
handle.write(json.dumps(workspace_helper.event("soknad_sendt", days_ago=1)) + "\n")
# Without the write-back the cache is now stale, and that has to be visible.
assert sak_status.divergenser(empty_workspace, TODAY) != []
sak_status.oppdater(
empty_workspace, sak_id, sak_status.status_for_sak(empty_workspace, sak_id, TODAY)
)
assert sak_status.divergenser(empty_workspace, TODAY) == []