Files
nisse/nisse-dev/tests/test_oplog.py
T
dongarandClaude Opus 5.5 1ad0b2147d
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Retire the old build spec, and point every reference at the architecture (M0.1)
Development Plan M0.1. Every section reference in code, tests, specs/,
the example config, the tuning guide, and the CLAUDE.md and README files
now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old
spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps
it). The plan's old-to-new table goes with it.

Mostly mechanical, with a per-site choice where one old section split:
A4 became L2 where it meant stop/abandon/the loop and L3 where it meant
approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2
became D4 for outbound Gitea and publishing and D5 for review intake;
'A3 step N' became the matching step of L8's boot sequence. Comments
describing behaviour the architecture drops (planning always escalating,
thresholds tuned at an old build step) now describe the current code and
point at the Development Plan.

docs/Architecture.md gains one line the rewrite missed: intake outages
delay but never fail work (old B2), now in L5, where the poller and
Matrix code cite it.

Comments and docs only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-01 00:17:48 +00:00

159 lines
5.6 KiB
Python

"""State-change log conventions (architecture L12): every durable mutation emits one
JSON-lines event with the fixed envelope, from the store's own write path."""
import io
import json
import sqlite3
from pathlib import Path
import pytest
from nisse_dev.oplog import OpLog
from nisse_dev.reconcile import reconcile_on_boot
from nisse_dev.state import StateStore
ENVELOPE_FIELDS = {"ts", "agent", "event", "task_id", "cause"}
@pytest.fixture
def stream() -> io.StringIO:
return io.StringIO()
@pytest.fixture
def store(tmp_path: Path, stream: io.StringIO) -> StateStore:
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", stream))
def events(stream: io.StringIO) -> list[dict]:
return [json.loads(line) for line in stream.getvalue().splitlines()]
def test_every_event_carries_the_envelope(
store: StateStore, stream: io.StringIO
) -> None:
store.transition("PLANNING_ARCH", cause="operator command: use demo")
intent_id = store.begin_intent("push_branch", {}, cause="milestone execution")
store.commit_intent(intent_id, cause="push verified")
for event in events(stream):
assert ENVELOPE_FIELDS <= event.keys()
assert event["agent"] == "nisse-dev"
def test_transition_logs_from_to_and_cause(
store: StateStore, stream: io.StringIO
) -> None:
store.transition("PLANNING_ARCH", cause="operator command: use demo", project="d")
store.transition("AWAIT_ARCH_SIGNOFF", cause="plan submitted", project="d")
first, second = events(stream)
assert first["event"] == "state.transition"
assert (first["from"], first["to"]) == (None, "PLANNING_ARCH")
assert (second["from"], second["to"]) == ("PLANNING_ARCH", "AWAIT_ARCH_SIGNOFF")
assert second["cause"] == "plan submitted"
def test_intent_lifecycle_events(store: StateStore, stream: io.StringIO) -> None:
intent_id = store.begin_intent("push_branch", {"b": "m2"}, cause="milestone")
store.abort_intent(intent_id, cause="discarded")
begin, abort = events(stream)
assert begin["event"] == "intent.begin"
assert abort["event"] == "intent.abort"
assert begin["intent_id"] == abort["intent_id"] == intent_id
assert begin["kind"] == abort["kind"] == "push_branch"
def test_task_id_threads_through_events(store: StateStore, stream: io.StringIO) -> None:
store.transition("IDLE", cause="boot")
store.oplog.task_id = "task-42"
store.transition("PLANNING_ARCH", cause="operator command")
before, during = events(stream)
assert before["task_id"] is None
assert during["task_id"] == "task-42"
def test_migration_emits_event(tmp_path: Path, stream: io.StringIO) -> None:
db = tmp_path / "state.db"
with StateStore(db, schema_version=1):
pass
def add_notes(conn: sqlite3.Connection) -> None:
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY)")
with StateStore(
db,
schema_version=2,
migrations={1: add_notes},
oplog=OpLog("nisse-dev", stream),
):
pass
[event] = events(stream)
assert event["event"] == "schema.migrate"
assert (event["from_version"], event["to_version"]) == (1, 2)
def test_boot_reconcile_emits_action_and_verdicts(
store: StateStore, stream: io.StringIO
) -> None:
store.begin_intent("push_branch", {}, cause="milestone")
reconcile_on_boot(store, {"push_branch": lambda intent: True})
boot = [e for e in events(stream) if e["event"] == "boot.reconcile"]
assert len(boot) == 1
assert boot[0]["action"] == "resume"
assert "effect verified" in boot[0]["verdicts"][0]
commit = [e for e in events(stream) if e["event"] == "intent.commit"]
assert commit[0]["cause"] == "reconciliation verdict: effect verified"
def test_literal_log_lines_for_human_readability(
store: StateStore, stream: io.StringIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Asserts the exact bytes written per line, so the on-disk format is
visible here rather than only in a running system."""
class _FrozenDatetime:
@staticmethod
def now(tz: object) -> object:
from datetime import UTC, datetime
return datetime(2026, 9, 5, 12, 0, 0, tzinfo=UTC)
monkeypatch.setattr("nisse_dev.oplog.datetime", _FrozenDatetime)
store.transition(
"PLANNING_ARCH", cause="operator command: use demo", project="demo"
)
store.oplog.task_id = "demo-m2"
intent_id = store.begin_intent(
"push_branch", {"branch": "milestone-2"}, cause="milestone 2 execution"
)
store.commit_intent(intent_id, cause="push verified by Gitea query")
assert stream.getvalue().splitlines() == [
(
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
' "event": "state.transition", "task_id": null,'
' "cause": "operator command: use demo",'
' "from": null, "to": "PLANNING_ARCH", "project": "demo"}'
),
(
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
' "event": "intent.begin", "task_id": "demo-m2",'
' "cause": "milestone 2 execution", "intent_id": 1,'
' "kind": "push_branch"}'
),
(
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
' "event": "intent.commit", "task_id": "demo-m2",'
' "cause": "push verified by Gitea query",'
' "intent_id": 1, "kind": "push_branch"}'
),
]
def test_failed_resolution_emits_nothing(
store: StateStore, stream: io.StringIO
) -> None:
with pytest.raises(KeyError):
store.commit_intent(9999, cause="test")
assert events(stream) == []