test(m1): add conftest fixtures and golden helpers
This commit is contained in:
parent
aa7fe4b553
commit
d0be8fba11
5 changed files with 369 additions and 0 deletions
100
tests/conftest.py
Normal file
100
tests/conftest.py
Normal 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)
|
||||||
5
tests/helpers/__init__.py
Normal file
5
tests/helpers/__init__.py
Normal 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
51
tests/helpers/golden.py
Normal 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
125
tests/helpers/workspace.py
Normal 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)
|
||||||
88
tests/test_helpers_selfcheck.py
Normal file
88
tests/test_helpers_selfcheck.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue