"""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) == []