296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""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
|