test(m1): add conftest fixtures and golden helpers

This commit is contained in:
Kjell Tore Guttormsen 2026-09-04 19:30:58 +02:00
commit d0be8fba11
5 changed files with 369 additions and 0 deletions

100
tests/conftest.py Normal file
View file

@ -0,0 +1,100 @@
"""Shared fixtures for the whole suite (plan Step 6).
Determinism here is a property of the interface, not of the harness. Tests get
`frozen_today` and pass it to scripts as `--today`; nothing monkeypatches
`datetime`. The difference matters: a script that only behaves deterministically
because a test patched the clock is a script whose callers cannot make it
behave deterministically at all, and `dagens` (build-brief 5.8) has to be
reproducible from the command line.
The autouse `fixtures_are_pristine` guard exists because the failure it catches
does not announce itself. A test that writes into `tests/fixtures/` leaves
every later run reading a corpus that no longer matches what it says it is, and
the eventual failure surfaces in some unrelated test hours later. Catching it
at the end of the offending test names the culprit.
"""
import os
import shutil
import pytest
from helpers import golden as golden_helper
from helpers import workspace as workspace_helper
from jobbsok_lib import paths
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures")
#: Every date in the corpus is relative to this. 2026-09-15 is a Tuesday, so
#: weekday-sensitive output does not accidentally land on a weekend.
FROZEN_TODAY = workspace_helper.FROZEN_TODAY
def pytest_addoption(parser):
parser.addoption(
"--update-golden",
action="store_true",
default=False,
help="rewrite golden files instead of comparing against them",
)
@pytest.fixture
def fixtures_dir():
"""Absolute path to the read-only fixture corpus."""
return FIXTURES_DIR
@pytest.fixture
def frozen_today():
"""The date the suite reasons from, passed to scripts as ``--today``."""
return FROZEN_TODAY
@pytest.fixture
def empty_workspace(tmp_path):
"""A scaffolded but empty build-brief section 5 workspace."""
root = str(tmp_path / "workspace")
paths.scaffold(root)
return root
@pytest.fixture
def workspace(empty_workspace):
"""A workspace with the fixture corpus copied in, when there is one.
The populated corpus arrives in plan Step 19; until then this is the
scaffolded tree, so tests written against it now keep working when the
corpus lands rather than being rewritten then.
"""
source = os.path.join(FIXTURES_DIR, "workspace")
if os.path.isdir(source):
shutil.copytree(source, empty_workspace, dirs_exist_ok=True)
return empty_workspace
@pytest.fixture
def golden(request):
"""`assert_golden` with the ``--update-golden`` flag already bound."""
update = request.config.getoption("--update-golden")
def compare(path, actual):
return golden_helper.assert_golden(path, actual, update=update)
return compare
@pytest.fixture(autouse=True)
def fixtures_are_pristine(request):
"""Fail the test that mutated the read-only corpus, at that test."""
if not os.path.isdir(FIXTURES_DIR):
yield
return
if request.config.getoption("--update-golden"):
# Golden regeneration is the one sanctioned write into the corpus.
yield
return
before = workspace_helper.snapshot_tree(FIXTURES_DIR)
yield
workspace_helper.assert_tree_unchanged(FIXTURES_DIR, before)

View file

@ -0,0 +1,5 @@
"""Shared test helpers (plan Step 6).
Importable as ``helpers.*`` because ``tests/`` carries no ``__init__.py``, so
pytest puts it on ``sys.path`` when it collects a test module from there.
"""

51
tests/helpers/golden.py Normal file
View file

@ -0,0 +1,51 @@
"""Golden-file comparison, with regeneration that has to be asked for.
A golden test is only worth its file if updating that file is a deliberate
act. The failure mode this module exists to prevent is the silent
self-heal: a comparator that rewrites the reference whenever it disagrees
turns every golden into a mirror and every golden test into a no-op. So the
rewrite lives behind the ``--update-golden`` flag, and the flag defaults off.
The update flag is a parameter rather than something this module reads off
the pytest config, so the rewrite path is reachable from an ordinary test
without running pytest inside pytest. `tests/conftest.py` binds the flag to
the `golden` fixture for callers who want the wiring instead.
"""
import difflib
import os
def assert_golden(path, actual, update=False):
"""Compare ``actual`` against the golden file at ``path``.
With ``update`` the file is rewritten and nothing is asserted. Without it,
a missing golden and a differing golden both raise, and the message
carries a unified diff -- "golden mismatch" on its own sends the reader
back to the file to work out what moved.
"""
if update:
with open(path, "w", encoding="utf-8") as handle:
handle.write(actual)
return
if not os.path.exists(path):
raise AssertionError(
"golden %r does not exist yet. Re-run with --update-golden to "
"create it, having first read the output you are blessing." % path
)
with open(path, "r", encoding="utf-8") as handle:
expected = handle.read()
if expected == actual:
return
diff = "".join(
difflib.unified_diff(
expected.splitlines(keepends=True),
actual.splitlines(keepends=True),
fromfile="golden: %s" % path,
tofile="actual",
)
)
raise AssertionError("golden %r does not match actual output:\n%s" % (path, diff))

125
tests/helpers/workspace.py Normal file
View file

@ -0,0 +1,125 @@
"""Workspace construction and corpus-integrity helpers (plan Step 6).
`make_case` builds the case shape from build-brief 5.2 and 5.3 so tests can
say what they need in one line instead of laying out directories by hand.
`snapshot_tree` and `assert_tree_unchanged` are the machinery behind
conftest's autouse pristine guard: a test that writes into the fixture corpus
poisons every later run, and the failure would otherwise surface far from its
cause.
Note on the case log: entries here carry `hendelse` from the build-brief 5.3
enum and are written with a single append, mirroring
`jobbsok_lib.jsonl.append_line`'s contract without going through its
`type` validation -- that closed set (`beslutning`, `korrigering`, `utfall`)
is the decision log's discriminator, and the case log has its own. Step 17
defines the event enum properly; until then this helper does not force a
decision-log type onto a case event. Reading goes back through
`jsonl.read_lines`, so the torn-line and corruption rules still apply.
"""
import datetime
import json
import os
from jobbsok_lib import frontmatter as frontmatter_lib
from jobbsok_lib import jsonl, paths
#: The frozen date the whole suite reasons from, as an aware instant. Norway
#: is on CEST (+02:00) on this date, and the offset is written out rather than
#: derived, so the fixtures do not depend on the machine's timezone database.
FROZEN_TODAY = "2026-09-15"
FROZEN_NOON = datetime.datetime.fromisoformat(FROZEN_TODAY + "T12:00:00+02:00")
def event(hendelse, days_ago=0, kilde=None, ref=None, notat=None):
"""A build-brief 5.3 case-log record, dated relative to the frozen today."""
moment = FROZEN_NOON - datetime.timedelta(days=days_ago)
return {
"ts": moment.isoformat(),
"hendelse": hendelse,
"kilde": kilde,
"ref": ref,
"notat": notat,
}
def make_case(root, arbeidsgiver, rolle, year_month, events=(), **fields):
"""Create ``saker/<sak-id>/`` with a sak.md and a logg.jsonl.
Returns the `sak-id`, which is derived through :func:`paths.sak_id` rather
than assembled here -- the slug rules are that module's contract and a
second implementation would drift from it.
"""
sak_id = paths.sak_id(year_month, arbeidsgiver, rolle)
case_dir = paths.safe_join(root, "saker", sak_id)
os.makedirs(case_dir, exist_ok=True)
metadata = {
"sak_id": sak_id,
"arbeidsgiver": arbeidsgiver,
"rolle": rolle,
"opprettet": FROZEN_TODAY,
"status": "vurderes",
"ventende_part": "meg",
}
metadata.update(fields)
with open(os.path.join(case_dir, "sak.md"), "w", encoding="utf-8") as handle:
handle.write(frontmatter_lib.render(metadata, "## Hvorfor denne\n\n"))
log = os.path.join(case_dir, "logg.jsonl")
for record in events:
_append_raw(log, record)
if not events:
open(log, "a", encoding="utf-8").close()
return sak_id
def read_jsonl(path):
"""Read a JSONL file through the real reader, torn-line rules and all."""
return jsonl.read_lines(path)
def frontmatter(path):
"""Parse a file's frontmatter, returning ``(metadata, body)``."""
with open(path, "r", encoding="utf-8") as handle:
return frontmatter_lib.parse(handle.read())
def snapshot_tree(root):
"""Map every file under ``root`` to the pair that betrays a mutation.
Size catches an edit that changes length, modification time catches one
that does not. Neither alone is enough, and a content hash would make the
autouse guard cost real time on every test in the suite.
"""
snapshot = {}
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
full = os.path.join(dirpath, name)
info = os.stat(full)
snapshot[os.path.relpath(full, root)] = (info.st_size, info.st_mtime_ns)
return snapshot
def assert_tree_unchanged(root, before):
"""Fail if anything under ``root`` was added, removed or modified."""
after = snapshot_tree(root)
added = sorted(set(after) - set(before))
removed = sorted(set(before) - set(after))
changed = sorted(name for name in set(before) & set(after) if before[name] != after[name])
if not (added or removed or changed):
return
raise AssertionError(
"the fixture corpus under %r was mutated by a test -- added=%r "
"removed=%r changed=%r. Fixtures are read-only; copy into tmp_path "
"instead." % (root, added, removed, changed)
)
def _append_raw(path, record):
payload = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
handle = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
try:
os.write(handle, payload)
finally:
os.close(handle)

View file

@ -0,0 +1,88 @@
"""The test harness tests itself (plan Step 6).
Everything downstream of here trusts these helpers, so a golden comparator
that silently passes, or a pristine-fixture guard that never trips, would not
announce itself -- it would just make the rest of the suite quietly weaker.
The four tests below are the ones that would catch that: each asserts the
helper can FAIL, not merely that it can pass.
`assert_golden` takes its update flag as an argument rather than reading the
pytest config itself, so the rewrite path is reachable from a test without
re-running pytest inside pytest. The flag wiring is asserted separately, on
the registered option.
Style note: this file follows tests/test_jsonl.py.
"""
import os
import pytest
from helpers import golden, workspace
def test_assert_golden_passes_unchanged_and_fails_on_a_change(tmp_path):
reference = str(tmp_path / "dagens.txt")
with open(reference, "w", encoding="utf-8") as handle:
handle.write("Tromsø: 2 saker\n")
golden.assert_golden(reference, "Tromsø: 2 saker\n")
with pytest.raises(AssertionError) as caught:
golden.assert_golden(reference, "Tromsø: 3 saker\n")
# The message has to carry the diff; "golden mismatch" alone sends the
# reader back to the file to work out what moved.
assert "3 saker" in str(caught.value)
def test_update_golden_rewrites_the_file_and_the_flag_is_registered(tmp_path, request):
reference = str(tmp_path / "dagens.txt")
with open(reference, "w", encoding="utf-8") as handle:
handle.write("gammelt\n")
golden.assert_golden(reference, "nytt\n", update=True)
assert open(reference, encoding="utf-8").read() == "nytt\n"
# Regeneration is deliberate: off unless the operator asks for it.
assert request.config.getoption("--update-golden") is False
def test_make_case_produces_a_case_that_read_jsonl_can_read(empty_workspace, frozen_today):
sak_id = workspace.make_case(
empty_workspace,
arbeidsgiver="Fjordtek AS",
rolle="Rådgiver",
year_month="2026-09",
events=[workspace.event("opprettet", days_ago=3), workspace.event("soknad_sendt", days_ago=1)],
)
assert sak_id == "2026-09-fjordtek-as-radgiver"
log = os.path.join(empty_workspace, "saker", sak_id, "logg.jsonl")
records = workspace.read_jsonl(log)
assert [rec["hendelse"] for rec in records] == ["opprettet", "soknad_sendt"]
meta, _ = workspace.frontmatter(os.path.join(empty_workspace, "saker", sak_id, "sak.md"))
assert meta["sak_id"] == sak_id
assert meta["arbeidsgiver"] == "Fjordtek AS"
assert frozen_today == "2026-09-15"
def test_the_pristine_guard_trips_when_the_corpus_is_written_to(tmp_path):
corpus = tmp_path / "fixtures"
corpus.mkdir()
(corpus / "kandidat.md").write_text("opprinnelig\n", encoding="utf-8")
before = workspace.snapshot_tree(str(corpus))
workspace.assert_tree_unchanged(str(corpus), before) # nothing moved yet
(corpus / "kandidat.md").write_text("endret av en test\n", encoding="utf-8")
with pytest.raises(AssertionError) as caught:
workspace.assert_tree_unchanged(str(corpus), before)
assert "kandidat.md" in str(caught.value)
# A new file is a mutation too -- a test that drops output into the corpus
# poisons every later run just as surely as one that edits a fixture.
restored = workspace.snapshot_tree(str(corpus))
(corpus / "uventet.txt").write_text("x\n", encoding="utf-8")
with pytest.raises(AssertionError):
workspace.assert_tree_unchanged(str(corpus), restored)