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>
This commit is contained in:
parent
dcd3eae534
commit
4669cc363d
2 changed files with 634 additions and 0 deletions
405
scripts/sak_status.py
Normal file
405
scripts/sak_status.py
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
"""The deterministic status machine behind every case (plan Steps 17-20).
|
||||
|
||||
Status is derived from `logg.jsonl` and from nothing else. `sak.md`
|
||||
frontmatter is a cache of that derivation, and where the two disagree the log
|
||||
wins and the divergence is reported rather than quietly repaired (risk H3).
|
||||
A cache nothing refreshes is only permanent divergence with better manners,
|
||||
so this module also owns the write-back: :func:`oppdater` rewrites exactly
|
||||
four frontmatter keys -- `status`, `ventende_part`, `sist_aktivitet`,
|
||||
`neste_frist` -- and touches no other key and no body line. It is the ONLY
|
||||
writer of those four keys, and every other path through this module is a pure
|
||||
reader.
|
||||
|
||||
Three decisions this step had to take, stated here rather than left implicit.
|
||||
|
||||
**Two rows of the transition table are not events.** The plan writes "Decision
|
||||
`ja`" and "Operator close" without the word *event*, and neither is in the
|
||||
build-brief 5.3 enum, which is closed at eleven and stays closed. They reach
|
||||
the case log as the decision-log records build-brief 5.7 already mirrors
|
||||
there: a `type: beslutning` line carrying `beslutning: ja`, and a
|
||||
`type: utfall` line carrying `utfall: avsluttet`. So the case log is a stream
|
||||
of *triggers* with two spellings -- `hendelse` for the eleven events, `type`
|
||||
for the two mirrored records -- and :func:`trigger_of` is the single place
|
||||
that decides which. A `beslutning: nei` in a case log is not a trigger: a no
|
||||
creates no case and advances none.
|
||||
|
||||
**`neste_frist` is the date this case goes silent.** Nothing in the brief
|
||||
derives it, and `--oppdater` is required to write it from derived truth, so it
|
||||
has to mean something the machine can compute. It is the earliest threshold
|
||||
date among the silence rules that apply to the case right now, and `null`
|
||||
where no rule applies.
|
||||
|
||||
**A derivation of `None` does not overwrite the cache.** `neste_frist` in
|
||||
`vurderer` and `soker` is the application deadline, which is the operator's
|
||||
own data and which no machine here can recompute. A refresh that emptied the
|
||||
one field the operator typed would be a defect wearing a refresh's clothes,
|
||||
so :func:`oppdater` writes a key only when the derivation has a value for it,
|
||||
and :func:`divergenser` likewise compares only those. This is a deliberate
|
||||
deviation from a literal reading of "rewrites the four keys"; it is the only
|
||||
one in this module.
|
||||
|
||||
Determinism is a property of the interface. The clock is injected as a date,
|
||||
never read off the wall, and nothing that reaches stdout is iterated out of a
|
||||
dict or a set -- two runs are byte-identical, including under different hash
|
||||
seeds.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from jobbsok_lib import frontmatter as frontmatter_lib
|
||||
from jobbsok_lib import jsonl, paths
|
||||
|
||||
#: The closed event vocabulary of build-brief 5.3. An event outside it is an
|
||||
#: error naming this whole set, never a silent skip.
|
||||
HENDELSER = (
|
||||
"opprettet",
|
||||
"soknad_sendt",
|
||||
"bekreftelse_mottatt",
|
||||
"henvendelse_mottatt",
|
||||
"svar_sendt",
|
||||
"intervju_avtalt",
|
||||
"intervju_gjennomfort",
|
||||
"tilbud_mottatt",
|
||||
"avslag",
|
||||
"trukket",
|
||||
"stille",
|
||||
)
|
||||
|
||||
#: The decision mirrored into the case log, and the operator's close. Not
|
||||
#: events; see the module docstring.
|
||||
BESLUTNING_JA = "beslutning_ja"
|
||||
AVSLUTTET = "avsluttet"
|
||||
|
||||
#: Everything the table can be keyed by.
|
||||
TRIGGERE = HENDELSER + (BESLUTNING_JA, AVSLUTTET)
|
||||
|
||||
#: build-brief 6: the main chain, then the two terminal side-states.
|
||||
TILSTANDER = (
|
||||
"vurderer", "soker", "sendt", "dialog", "intervju", "tilbud", "avsluttet",
|
||||
"avslag", "trukket",
|
||||
)
|
||||
|
||||
#: Nothing leaves these. An event after one of them is an error, not a
|
||||
#: reopening -- a case that starts again is a new case with a new sak-id.
|
||||
TERMINALE = ("avsluttet", "avslag", "trukket")
|
||||
|
||||
#: The state and waiting party each trigger lands on. `stille` maps to None:
|
||||
#: it is operator-appended, carries no transition, and this module never
|
||||
#: appends it (there is no autonomous scheduling).
|
||||
OVERGANGER = {
|
||||
"opprettet": ("vurderer", "meg"),
|
||||
BESLUTNING_JA: ("soker", "meg"),
|
||||
"soknad_sendt": ("sendt", "dem"),
|
||||
"bekreftelse_mottatt": ("sendt", "dem"),
|
||||
"henvendelse_mottatt": ("dialog", "meg"),
|
||||
"svar_sendt": ("dialog", "dem"),
|
||||
"intervju_avtalt": ("intervju", "ingen"),
|
||||
"intervju_gjennomfort": ("intervju", "dem"),
|
||||
"tilbud_mottatt": ("tilbud", "meg"),
|
||||
AVSLUTTET: ("avsluttet", "ingen"),
|
||||
"avslag": ("avslag", "ingen"),
|
||||
"trukket": ("trukket", "ingen"),
|
||||
"stille": None,
|
||||
}
|
||||
|
||||
#: Which triggers are legal from which state. A trigger whose target is the
|
||||
#: state itself is listed, so a duplicate event is idempotent rather than an
|
||||
#: error -- an operator who logs the same send twice made a bookkeeping slip,
|
||||
#: not a state error. `avslag`, `trukket`, `avsluttet` and `stille` are legal
|
||||
#: from every non-terminal state: a case can be rejected, withdrawn, closed or
|
||||
#: noted as quiet at any point.
|
||||
_ALLTID = ("avslag", "trukket", AVSLUTTET, "stille")
|
||||
LOVLIGE = {
|
||||
"vurderer": ("opprettet", BESLUTNING_JA) + _ALLTID,
|
||||
"soker": (BESLUTNING_JA, "soknad_sendt") + _ALLTID,
|
||||
"sendt": (
|
||||
"soknad_sendt", "bekreftelse_mottatt", "henvendelse_mottatt",
|
||||
"intervju_avtalt", "tilbud_mottatt",
|
||||
) + _ALLTID,
|
||||
"dialog": (
|
||||
"henvendelse_mottatt", "svar_sendt", "intervju_avtalt", "tilbud_mottatt",
|
||||
) + _ALLTID,
|
||||
"intervju": (
|
||||
"intervju_avtalt", "intervju_gjennomfort", "henvendelse_mottatt",
|
||||
"tilbud_mottatt",
|
||||
) + _ALLTID,
|
||||
"tilbud": ("tilbud_mottatt",) + _ALLTID,
|
||||
"avsluttet": (),
|
||||
"avslag": (),
|
||||
"trukket": (),
|
||||
}
|
||||
|
||||
#: Events that mean the other party actually made contact. An automated
|
||||
#: receipt is not contact, which is why `bekreftelse_mottatt` is absent: the
|
||||
#: plan says it must not reset the silence clock, and leaving it out here is
|
||||
#: what makes that true rather than commented.
|
||||
INNKOMMENDE = ("henvendelse_mottatt", "intervju_avtalt", "tilbud_mottatt")
|
||||
|
||||
#: Flag names in the order they are emitted. Fixed, so output ordering never
|
||||
#: depends on which rule happened to fire first.
|
||||
FLAGG = ("sendt_14", "dialog_7", "intervju_10")
|
||||
|
||||
#: build-brief 6's three silence thresholds, in days.
|
||||
TERSKLER = {"sendt_14": 14, "dialog_7": 7, "intervju_10": 10}
|
||||
|
||||
#: The frontmatter keys this module caches, and the only ones it ever writes.
|
||||
CACHE_KEYS = ("status", "ventende_part", "sist_aktivitet", "neste_frist")
|
||||
|
||||
#: The one non-value a frontmatter scalar can carry. `null` reads back as the
|
||||
#: string "null", so the mapping is done here rather than in the parser, which
|
||||
#: must keep returning the operator's bytes untouched.
|
||||
NULL = "null"
|
||||
|
||||
|
||||
class StatusError(Exception):
|
||||
"""A refusal from the status machine, naming what would have been legal."""
|
||||
|
||||
|
||||
def trigger_of(record):
|
||||
"""Map a case-log record to a trigger name, or None when it carries none.
|
||||
|
||||
Raises :class:`StatusError` for a record that names an event outside the
|
||||
closed enum, and for one this module cannot read at all. Returning None is
|
||||
reserved for records that are legitimately not transitions -- a
|
||||
`beslutning: nei`, a `korrigering`, an `utfall` that is not the close.
|
||||
"""
|
||||
if not isinstance(record, dict):
|
||||
raise StatusError("a case-log record must be an object, got %s"
|
||||
% type(record).__name__)
|
||||
|
||||
if "hendelse" in record:
|
||||
hendelse = record["hendelse"]
|
||||
if hendelse not in HENDELSER:
|
||||
raise StatusError(
|
||||
"%r is not a case event. The vocabulary is closed: %s"
|
||||
% (hendelse, ", ".join(HENDELSER))
|
||||
)
|
||||
return hendelse
|
||||
|
||||
kind = record.get("type")
|
||||
if kind == "beslutning":
|
||||
return BESLUTNING_JA if record.get("beslutning") == "ja" else None
|
||||
if kind == "utfall":
|
||||
return AVSLUTTET if record.get("utfall") == AVSLUTTET else None
|
||||
if kind == "korrigering":
|
||||
return None
|
||||
|
||||
raise StatusError(
|
||||
"a case-log record must carry 'hendelse' from the 5.3 enum or 'type' "
|
||||
"from %r; got %r" % (list(jsonl.TYPES), sorted(record))
|
||||
)
|
||||
|
||||
|
||||
def avgjor(records, today):
|
||||
"""Derive status, waiting party, dates and silence flags from a case log.
|
||||
|
||||
``today`` is injected, always: a machine that only behaves deterministically
|
||||
because a test patched its clock is a machine its callers cannot make
|
||||
behave deterministically at all.
|
||||
"""
|
||||
dagen = _dato(today)
|
||||
ordered = jsonl.by_time(records)
|
||||
|
||||
status, ventende = "vurderer", "meg"
|
||||
sist_aktivitet = None
|
||||
sist_innkommende = None
|
||||
sendt_fra = None
|
||||
intervju_holdt = None
|
||||
utfall_etter_intervju = False
|
||||
|
||||
for record in ordered:
|
||||
trigger = trigger_of(record)
|
||||
if trigger is None:
|
||||
continue
|
||||
if status in TERMINALE:
|
||||
raise StatusError(
|
||||
"%r comes after the terminal state %r; a case that starts "
|
||||
"again is a new case with a new sak-id" % (trigger, status)
|
||||
)
|
||||
if trigger not in LOVLIGE[status]:
|
||||
raise StatusError(
|
||||
"%r is not legal from %r. Legal here: %s"
|
||||
% (trigger, status, ", ".join(sorted(LOVLIGE[status])))
|
||||
)
|
||||
|
||||
maal = OVERGANGER[trigger]
|
||||
if maal is not None:
|
||||
status, ventende = maal
|
||||
|
||||
naa = jsonl.instant(record).date()
|
||||
sist_aktivitet = naa if sist_aktivitet is None or naa > sist_aktivitet else sist_aktivitet
|
||||
if trigger in INNKOMMENDE:
|
||||
sist_innkommende = naa
|
||||
if trigger == "soknad_sendt":
|
||||
sendt_fra = naa
|
||||
if trigger == "intervju_gjennomfort":
|
||||
intervju_holdt = naa
|
||||
utfall_etter_intervju = False
|
||||
if intervju_holdt is not None and trigger in ("tilbud_mottatt",) + TERMINALE:
|
||||
utfall_etter_intervju = True
|
||||
|
||||
frister = _frister(status, ventende, sist_innkommende, sendt_fra,
|
||||
intervju_holdt, utfall_etter_intervju)
|
||||
flagg = [navn for navn in FLAGG if navn in frister and frister[navn] <= dagen]
|
||||
neste_frist = min(frister.values()) if frister else None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"ventende_part": ventende,
|
||||
"sist_aktivitet": _iso(sist_aktivitet),
|
||||
"neste_frist": _iso(neste_frist),
|
||||
"flagg": flagg,
|
||||
"innkommende": _innkommende(sist_innkommende),
|
||||
}
|
||||
|
||||
|
||||
def _frister(status, ventende, sist_innkommende, sendt_fra, intervju_holdt,
|
||||
utfall_etter_intervju):
|
||||
"""The date each applicable silence rule trips, keyed by flag name.
|
||||
|
||||
Terminal states are never in here, and `vurderer` and `soker` never are
|
||||
either: nobody is waiting on the other party yet, so there is nothing for
|
||||
silence to mean.
|
||||
"""
|
||||
frister = {}
|
||||
if status in TERMINALE:
|
||||
return frister
|
||||
|
||||
if status == "sendt":
|
||||
basis = sist_innkommende or sendt_fra
|
||||
if basis is not None:
|
||||
frister["sendt_14"] = basis + datetime.timedelta(days=TERSKLER["sendt_14"])
|
||||
if status == "dialog" and ventende == "dem" and sist_innkommende is not None:
|
||||
frister["dialog_7"] = sist_innkommende + datetime.timedelta(days=TERSKLER["dialog_7"])
|
||||
if intervju_holdt is not None and not utfall_etter_intervju:
|
||||
frister["intervju_10"] = intervju_holdt + datetime.timedelta(days=TERSKLER["intervju_10"])
|
||||
return frister
|
||||
|
||||
|
||||
def _innkommende(sist):
|
||||
"""What is known about inbound contact -- and what is only *not recorded*.
|
||||
|
||||
With the mail server deferred, "no inbound" is a fact about this log and
|
||||
not about the world (risk H9). Saying so is the difference between a daily
|
||||
view worth reading and one that flags every case as gone quiet.
|
||||
"""
|
||||
if sist is not None:
|
||||
return {"registrert": True, "sist": _iso(sist), "merknad": None}
|
||||
return {
|
||||
"registrert": False,
|
||||
"sist": None,
|
||||
"merknad": (
|
||||
"ingen innkommende hendelse er registrert. Med e-postserveren "
|
||||
"utsatt betyr det ikke at ingen finnes."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def alle_saker(root):
|
||||
"""Every sak-id under ``root/saker``, sorted. Sorted, not os.listdir order."""
|
||||
saker = paths.safe_join(root, "saker")
|
||||
if not os.path.isdir(saker):
|
||||
return []
|
||||
funnet = []
|
||||
for navn in sorted(os.listdir(saker)):
|
||||
if not os.path.isdir(os.path.join(saker, navn)):
|
||||
continue
|
||||
funnet.append(paths.validate_sak_id(navn))
|
||||
return funnet
|
||||
|
||||
|
||||
def les_sak(root, sak_id):
|
||||
"""Return ``(metadata, body, records)`` for one case. Pure reader."""
|
||||
paths.validate_sak_id(sak_id)
|
||||
katalog = paths.safe_join(root, "saker", sak_id)
|
||||
with open(os.path.join(katalog, "sak.md"), "r", encoding="utf-8") as handle:
|
||||
meta, body = frontmatter_lib.parse(handle.read())
|
||||
return meta, body, jsonl.read_lines(os.path.join(katalog, "logg.jsonl"))
|
||||
|
||||
|
||||
def status_for_sak(root, sak_id, today):
|
||||
"""The derived truth for one case, plus how the cache disagrees with it."""
|
||||
meta, _body, records = les_sak(root, sak_id)
|
||||
resultat = avgjor(records, today)
|
||||
resultat["sak_id"] = sak_id
|
||||
resultat["arbeidsgiver"] = meta.get("arbeidsgiver")
|
||||
resultat["rolle"] = meta.get("rolle")
|
||||
resultat["divergens"] = _divergens(meta, resultat)
|
||||
return resultat
|
||||
|
||||
|
||||
def rapport(root, today, sak=None):
|
||||
"""The whole workspace as one ordered structure. Writes nothing."""
|
||||
saker = [paths.validate_sak_id(sak)] if sak else alle_saker(root)
|
||||
return {
|
||||
"dato": _iso(_dato(today)),
|
||||
"saker": [status_for_sak(root, sak_id, today) for sak_id in saker],
|
||||
}
|
||||
|
||||
|
||||
def divergenser(root, today, sak=None):
|
||||
"""Every case whose cached frontmatter disagrees with its log."""
|
||||
ut = []
|
||||
for resultat in rapport(root, today, sak)["saker"]:
|
||||
if resultat["divergens"]:
|
||||
ut.append({"sak_id": resultat["sak_id"], "divergens": resultat["divergens"]})
|
||||
return ut
|
||||
|
||||
|
||||
def oppdater(root, sak_id, resultat):
|
||||
"""Rewrite the four cached keys in ``sak.md``. The only writer here.
|
||||
|
||||
Returns the keys that actually changed. A key whose derivation is None is
|
||||
left alone -- see the module docstring.
|
||||
"""
|
||||
sti = paths.safe_join(root, "saker", sak_id, "sak.md")
|
||||
with open(sti, "r", encoding="utf-8") as handle:
|
||||
meta, body = frontmatter_lib.parse(handle.read())
|
||||
|
||||
endret = []
|
||||
for nokkel in CACHE_KEYS:
|
||||
utledet = resultat.get(nokkel)
|
||||
if utledet is None:
|
||||
continue
|
||||
if _hurtigbuffer(meta, nokkel) != utledet:
|
||||
meta[nokkel] = utledet
|
||||
endret.append(nokkel)
|
||||
if endret:
|
||||
with open(sti, "w", encoding="utf-8") as handle:
|
||||
handle.write(frontmatter_lib.render(meta, body))
|
||||
return endret
|
||||
|
||||
|
||||
def _divergens(meta, resultat):
|
||||
avvik = []
|
||||
for nokkel in CACHE_KEYS:
|
||||
utledet = resultat.get(nokkel)
|
||||
if utledet is None:
|
||||
continue
|
||||
hurtigbuffer = _hurtigbuffer(meta, nokkel)
|
||||
if hurtigbuffer != utledet:
|
||||
avvik.append({
|
||||
"nokkel": nokkel,
|
||||
"hurtigbuffer": hurtigbuffer,
|
||||
"utledet": utledet,
|
||||
})
|
||||
return avvik
|
||||
|
||||
|
||||
def _hurtigbuffer(meta, nokkel):
|
||||
verdi = meta.get(nokkel)
|
||||
return None if verdi == NULL else verdi
|
||||
|
||||
|
||||
def _dato(value):
|
||||
if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime):
|
||||
return value
|
||||
try:
|
||||
return datetime.date.fromisoformat(str(value))
|
||||
except ValueError:
|
||||
raise StatusError("--today must be YYYY-MM-DD, got %r" % (value,))
|
||||
|
||||
|
||||
def _iso(value):
|
||||
return None if value is None else value.isoformat()
|
||||
229
tests/test_sak_status_transitions.py
Normal file
229
tests/test_sak_status_transitions.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""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) == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue