"""What the `sak` skill promises, checked where a document can be checked (plan Step 21). The skill is prose, so the same rule applies here as in `tests/test_skills_contract.py`: the invocation is a model turn and cannot run in pytest. What runs is the sequence the skill *documents* -- create the case from `templates/sak.md`, append the event, refresh the cache -- executed here step by step, plus the assertion that SKILL.md still says to do exactly that. A skill that stopped naming `--oppdater` fails the second half; a sequence that stopped refreshing fails the first. Two properties are worth stating, because both are design decisions this step had to take rather than mechanics it inherited. **The case log has no validated writer, and that is deliberate.** `jobbsok_lib.jsonl.append_line` validates `type` against the decision log's closed set (`beslutning`, `korrigering`, `utfall`), and a build-brief 5.3 case event carries `hendelse` instead. So a case event is written as the single `O_APPEND` line the module's contract describes, and the enum is enforced on the *read* side: `sak_status.trigger_of` refuses an event outside the eleven, by name. The mirrored decision record does go through `append_line`, because that one is a decision-log record living in the case log. **The divergence test is the one that has teeth.** Asserting that the documented sequence leaves `--check` at exit 0 proves nothing on its own: a freshly created case agrees with its own template. So the same sequence is run with the refresh removed, and `--check` must then exit non-zero. Without that pair, a skill that dropped `--oppdater` entirely would pass. Style note: this file follows tests/test_skills_contract.py. """ import json import os import subprocess import sys import pytest import sak_status from jobbsok_lib import frontmatter as frontmatter_lib from jobbsok_lib import jsonl, paths REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SKILL = os.path.join(REPO, "skills", "sak", "SKILL.md") REFERANSE = os.path.join(REPO, "skills", "sak", "references", "status.md") MAL = os.path.join(REPO, "templates", "sak.md") CLI = os.path.join(REPO, "scripts", "sak_status.py") #: build-brief 5.2, in the order the section lists them. FRONTMATTER_NOKLER = ( "sak_id", "arbeidsgiver", "rolle", "kilde", "url", "opprettet", "status", "neste_frist", "ventende_part", "sist_aktivitet", "score", "cv_variant", "soknad_versjon", ) #: build-brief 5.2's body, in the order the section lists it. BODY_SEKSJONER = ( "## Hvorfor denne", "## Status nå", "## Neste handling", "## Åpne spørsmål", "## Tidslinje", ) #: The files a case folder starts life with. `kontakter.md` is empty on #: purpose: build-brief 5 names it, and `datahygiene` deletes third-party #: personal data out of it in M6. SAKSFILER = ("sak.md", "logg.jsonl", "kontakter.md") def mal_verdier(sak_id, arbeidsgiver, rolle, opprettet, **felt): verdier = { "sak_id": sak_id, "arbeidsgiver": arbeidsgiver, "rolle": rolle, "kilde": "manuell", "url": "null", "opprettet": opprettet, "neste_frist": "null", "score": "null", "cv_variant": "null", } verdier.update(felt) return verdier def fyll_mal(verdier): """Render `templates/sak.md` the way the skill documents rendering it.""" with open(MAL, "r", encoding="utf-8") as handle: tekst = handle.read() for nokkel, verdi in verdier.items(): tekst = tekst.replace("{{%s}}" % nokkel, str(verdi)) assert "{{" not in tekst, ( "the template still holds an unsubstituted placeholder: %r" % [bit for bit in tekst.split("{{")[1:]] ) return tekst def legg_til_hendelse(root, sak_id, hendelse, dato, **felt): """Append one build-brief 5.3 event as a single line, as the skill does.""" record = { "ts": dato + "T12:00:00+02:00", "hendelse": hendelse, "kilde": "manuell", "ref": None, "notat": None, } record.update(felt) sti = paths.safe_join(root, "saker", sak_id, "logg.jsonl") payload = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8") handle = os.open(sti, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) try: os.write(handle, payload) finally: os.close(handle) return record def opprett_sak(root, arbeidsgiver, rolle, opprettet, **felt): """The skill's documented creation step, executed.""" sak_id = paths.sak_id(opprettet[:7], arbeidsgiver, rolle) katalog = paths.safe_join(root, "saker", sak_id) os.makedirs(katalog) with open(os.path.join(katalog, "sak.md"), "w", encoding="utf-8") as handle: handle.write(fyll_mal(mal_verdier(sak_id, arbeidsgiver, rolle, opprettet, **felt))) open(os.path.join(katalog, "kontakter.md"), "w", encoding="utf-8").close() open(os.path.join(katalog, "logg.jsonl"), "a", encoding="utf-8").close() legg_til_hendelse(root, sak_id, "opprettet", opprettet) return sak_id def kjor(*argv): proc = subprocess.run( [sys.executable, CLI] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) return proc.returncode, proc.stdout.decode("utf-8"), proc.stderr.decode("utf-8") def oppdater(root, sak_id, today): kode, _ut, feil = kjor( "--workspace", root, "--sak", sak_id, "--today", today, "--oppdater", "--format", "json", ) assert kode == 0, feil return kode def sekvensen(root, today, oppdater_etter_hver_hendelse): """Create a case, take it to `sendt`, refreshing the cache or not. Three appends, because one is not enough to tell the two branches apart: a case that has only just been created agrees with its own template whether or not anything refreshed it. """ sak_id = opprett_sak(root, "Fjordtun Energi AS", "Løsningsarkitekt", "2026-09-01") if oppdater_etter_hver_hendelse: oppdater(root, sak_id, today) # The decision mirrored into the case log -- a decision-log record, so it # goes through the validated writer (module docstring). jsonl.append_line( paths.safe_join(root, "saker", sak_id, "logg.jsonl"), { "type": "beslutning", "id": "b-2026-0901", "ts": "2026-09-02T12:00:00+02:00", "beslutning": "ja", }, ) if oppdater_etter_hver_hendelse: oppdater(root, sak_id, today) legg_til_hendelse(root, sak_id, "soknad_sendt", "2026-09-03", ref="soknad-v1.md") if oppdater_etter_hver_hendelse: oppdater(root, sak_id, today) return sak_id def les_skill(): with open(SKILL, "r", encoding="utf-8") as handle: return handle.read() def test_a_case_from_the_template_carries_5_2_whole_and_one_opprettet_line( empty_workspace, ): sak_id = opprett_sak( empty_workspace, "Fjordtun Energi AS", "Løsningsarkitekt", "2026-09-01" ) katalog = os.path.join(empty_workspace, "saker", sak_id) for navn in SAKSFILER: assert os.path.isfile(os.path.join(katalog, navn)), ( "the case folder has no %r; build-brief 5 names it" % navn ) assert os.path.getsize(os.path.join(katalog, "kontakter.md")) == 0, ( "kontakter.md must start empty -- it is the operator's, not a template's" ) with open(os.path.join(katalog, "sak.md"), "r", encoding="utf-8") as handle: meta, body = frontmatter_lib.parse(handle.read()) assert list(meta) == list(FRONTMATTER_NOKLER), ( "sak.md frontmatter is %r; build-brief 5.2 names %r" % (list(meta), list(FRONTMATTER_NOKLER)) ) assert meta["sak_id"] == sak_id assert meta["status"] == "vurderer" and meta["ventende_part"] == "meg", ( "a case with only an `opprettet` event derives (vurderer, meg); the " "template must not start it anywhere else" ) for overskrift in BODY_SEKSJONER: assert overskrift in body, "the case body has no %r section" % overskrift records = jsonl.read_lines(os.path.join(katalog, "logg.jsonl")) assert len(records) == 1, "expected exactly one log line, got %r" % (records,) assert records[0]["hendelse"] == "opprettet" assert sak_status.trigger_of(records[0]) == "opprettet", ( "the event does not read back as a trigger the status machine knows" ) def test_the_case_identifier_is_pure_ascii_for_a_norwegian_employer(empty_workspace): sak_id = opprett_sak( empty_workspace, "Værøy Sjømat AS", "Dataingeniør", "2026-09-01" ) assert sak_id == "2026-09-vaeroy-sjomat-as-dataingenior" sak_id.encode("ascii") # raises if a macOS NFD byte survived (risk H8) assert paths.SAK_ID_RE.match(sak_id), ( "%r does not match the build-brief 5.2 format" % sak_id ) assert os.path.isdir(os.path.join(empty_workspace, "saker", sak_id)) def test_the_documented_sequence_leaves_the_cache_agreeing_with_the_log( empty_workspace, frozen_today ): sak_id = sekvensen(empty_workspace, frozen_today, oppdater_etter_hver_hendelse=True) kode, ut, feil = kjor( "--workspace", empty_workspace, "--today", frozen_today, "--check" ) assert kode == 0, "--check found divergence after the documented sequence:\n%s%s" % (ut, feil) meta, _body = frontmatter_lib.parse( open(os.path.join(empty_workspace, "saker", sak_id, "sak.md"), encoding="utf-8").read() ) assert meta["status"] == "sendt" and meta["ventende_part"] == "dem" assert meta["sist_aktivitet"] == "2026-09-03" def test_dropping_the_oppdater_call_makes_the_same_sequence_diverge( empty_workspace, frozen_today ): sak_id = sekvensen(empty_workspace, frozen_today, oppdater_etter_hver_hendelse=False) kode, ut, _feil = kjor( "--workspace", empty_workspace, "--today", frozen_today, "--check" ) assert kode == sak_status.EXIT_DIVERGENS, ( "the same sequence without --oppdater checked clean, so the refresh " "step in the skill is unfalsifiable" ) assert sak_id in ut def test_the_skill_delegates_every_status_question_to_the_script(): tekst = les_skill() assert "${CLAUDE_PLUGIN_ROOT}/scripts/sak_status.py" in tekst, ( "the skill never names the status script it is required to delegate to" ) for flagg in ("--oppdater", "--check"): assert flagg in tekst, "the skill never names %s" % flagg assert "${CLAUDE_PLUGIN_ROOT}/templates/sak.md" in tekst, ( "the skill never names the template it creates cases from" ) assert "references/status.md" in tekst def test_the_status_reference_lists_the_closed_vocabulary_whole(): with open(REFERANSE, "r", encoding="utf-8") as handle: tekst = handle.read() mangler = [h for h in sak_status.HENDELSER if h not in tekst] assert mangler == [], ( "references/status.md is missing %r of the eleven 5.3 events" % (mangler,) ) mangler = [t for t in sak_status.TILSTANDER if t not in tekst] assert mangler == [], "references/status.md is missing the states %r" % (mangler,) for terskel in ("14", "7", "10"): assert terskel in tekst, "the silence rule at %s days is not written down" % terskel