M4: close out Save Points, with browser verification

Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
JesseMarkowitz
2026-09-04 06:34:56 -04:00
co-authored by Claude Opus 5
parent 279a871a77
commit 62a997f364
23 changed files with 1818 additions and 131 deletions
+63
View File
@@ -0,0 +1,63 @@
"""The storyteller, run as a real OS process for `test_process_restart.py`.
Not a test module, and named so pytest does not collect it: it is the program
the test starts, twice, against one database file.
The model is replaced with a deterministic fake before the app is imported, so
the process needs no Ollama, no network and no configuration. Everything else —
the engine, the migrations, the routers, the session lifecycle — is the real
application, which is the whole point of spawning a process at all.
python _restart_server.py <db_path> <port>
"""
import itertools
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # backend/, so `app` imports
sys.path.insert(0, str(HERE)) # tests/, so `fakes` imports
db_path, port = sys.argv[1], int(sys.argv[2])
os.environ["AIDND_DB_PATH"] = db_path
# A developer's shell may point these at Postgres, and `app.database` prefers
# either over the SQLite path. The suite's conftest clears them for the same
# reason; a spawned process does not inherit that, so clear them here too.
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fakes import GOLD_PER_TURN, gold_reply # noqa: E402
_turn = itertools.count(1)
class DeterministicProvider:
"""Banks one turn's worth of gold per reply, numbered so text is checkable.
The same instrumentation `test_head_cursor.py` and `test_save_points.py`
use, for the same reason: it makes "the state at this position" a number the
test can assert rather than a paragraph it has to interpret. M5 replaces the
machinery underneath; what this measures is where the story is being read.
"""
last_usage = None
def __init__(self, *a, **k):
pass
async def generate(self, parts, *, temperature, max_tokens):
yield ("text", gold_reply(f"Beat {next(_turn)}."))
from app.routers.adventures import turns # noqa: E402
turns.OpenAICompatibleProvider = DeterministicProvider
from app.main import app # noqa: E402
if __name__ == "__main__":
import uvicorn
# Loopback only, as every supported start path does.
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
+325
View File
@@ -0,0 +1,325 @@
"""D11 and L03 across a genuine OS process boundary.
The rest of the suite runs the app in-process through `TestClient`, which is the
right tool for almost everything and the wrong one for exactly one claim:
*durability*. A Save Point that survived only because a Python object was still
alive would pass a same-process test and fail a user's restart. `TestClient`
cannot tell those apart, so the M4 review recorded the shipped D11/L03 tests as
weaker than the acceptance items they were named for (§R B-3).
This module closes that. It starts the real application as a **subprocess**,
plays a story over HTTP, kills the process, starts a **second** process against
the same database file, and only then asks whether the Save Point is still
there. Everything crossing the boundary crosses it as bytes on disk.
Deterministic and local: the spawned server replaces the model with a scripted
provider (`_restart_server.py`), so there is no Ollama, no network and no
sleep-and-hope — readiness is probed, not waited for.
python -m pytest tests/test_process_restart.py -v
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from fakes import GOLD_PER_TURN
HERE = Path(__file__).resolve().parent
SERVER = HERE / "_restart_server.py"
# How long a spawned server may take to answer before the test gives up. The
# process imports the app and runs migrations on a fresh file, which is well
# under a second on this project; the ceiling is for a loaded machine.
STARTUP_TIMEOUT = 60.0
def _free_port() -> int:
"""Returns a port nothing is listening on.
Bind, read, release. There is a race between releasing and the server
claiming it, which is why the caller probes for readiness rather than
assuming success — a lost race shows up as a startup timeout, not as a
silent pass.
"""
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Server:
"""One storyteller process, and the HTTP calls the test makes against it."""
def __init__(self, db_path: str, port: int):
self.port = port
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), db_path, str(port)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
# Never inherit the parent's database redirection; the child is told
# which file to open on its command line.
env={**os.environ, "AIDND_DB_PATH": db_path},
)
# ------------------------------------------------------------ lifecycle
def wait_until_ready(self) -> None:
deadline = time.monotonic() + STARTUP_TIMEOUT
while time.monotonic() < deadline:
if self.proc.poll() is not None:
raise AssertionError(
f"server exited early ({self.proc.returncode}):\n{self._output()}"
)
try:
self.call("GET", "/settings")
return
except (urllib.error.URLError, ConnectionError, OSError):
time.sleep(0.05)
raise AssertionError(f"server never became ready:\n{self._output()}")
def stop(self) -> None:
"""Ends the process, and does not return until it is actually gone."""
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=15)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=15)
if self.proc.stdout is not None:
self.proc.stdout.close()
def _output(self) -> str:
if self.proc.stdout is None:
return "(no output captured)"
try:
return self.proc.stdout.read().decode(errors="replace")[-2000:]
except Exception:
return "(output unreadable)"
def is_listening(self) -> bool:
try:
self.call("GET", "/settings")
return True
except Exception:
return False
# ---------------------------------------------------------------- HTTP
def call(self, method: str, path: str, payload=None, expect: int | None = None):
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api{path}",
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
body, status = response.read(), response.status
except urllib.error.HTTPError as exc: # a real answer, not a failure
body, status = exc.read(), exc.code
if expect is not None and status != expect:
raise AssertionError(f"{method} {path} -> {status}: {body[:400]!r}")
return json.loads(body) if body and status != 204 else None
def play(self, adventure_id: int, text: str) -> None:
"""Plays one turn through the streaming endpoint, to completion."""
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api/adventures/{adventure_id}/actions",
data=json.dumps({"type": "do", "text": text}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=120) as response:
response.read()
# ------------------------------------------------------------- reading
def transcript(self, adventure_id: int) -> list[str]:
page = self.call("GET", f"/adventures/{adventure_id}", expect=200)
return [a["text"] for a in page["actions"]]
def gold(self, adventure_id: int) -> int:
state = self.call("GET", f"/adventures/{adventure_id}/world-state", expect=200)
return state["state"]["player"]["gold"]
def total_rows(self, adventure_id: int) -> int:
"""Every row of the whole tree, head or no head.
`action_count` is scoped to the path being read, so it falls when the
head moves back — which is the feature, not a deletion. The export
carries the entire tree whatever the head is doing, so it is what
"nothing was deleted" has to be measured against.
"""
bundle = self.call("GET", f"/adventures/{adventure_id}/export", expect=200)
return len(bundle["actions"])
@pytest.fixture()
def workspace():
"""A database file, and whichever servers a test starts against it."""
directory = tempfile.mkdtemp(prefix="m4-restart-")
db_path = os.path.join(directory, "campaign.db")
started: list[Server] = []
def start() -> Server:
server = Server(db_path, _free_port())
started.append(server)
server.wait_until_ready()
return server
try:
yield start
finally:
# Every child dies even if the test failed part way through, and each
# stop() waits, so a later test cannot inherit a live listener.
for server in started:
server.stop()
def _campaign_with_a_save_point(server: Server):
"""Three turns, a Save Point on the third, then four more turns.
Returns everything the second process has to be able to reproduce.
"""
scenario = server.call("POST", "/scenarios", {
"title": "Abbey",
"stat_schema": {"player": {"gold": {"initial": 0, "min": 0, "max": 9999}}},
}, expect=201)
adventure = server.call("POST", "/adventures", {
"title": "The abbey", "scenario_id": scenario["id"],
}, expect=201)
adventure_id = adventure["id"]
for n in range(3):
server.play(adventure_id, f"turn {n}")
at_save = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
}
save_point = server.call("POST", f"/adventures/{adventure_id}/checkpoints",
{"name": "Before entering the abbey"}, expect=201)
for n in range(4):
server.play(adventure_id, f"later {n}")
at_tip = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
"rows": server.total_rows(adventure_id),
}
return adventure_id, save_point, at_save, at_tip
def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
"""D11 and L03 together, across a boundary a same-process test cannot cross.
The first process writes the campaign and exits. The second process is a
different interpreter with an empty session, an empty identity map and no
memory of anything — everything it knows, it reads off the disk.
"""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
assert at_tip["gold"] == at_save["gold"] + 4 * GOLD_PER_TURN
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
# --- the boundary -----------------------------------------------------
first.stop()
assert first.proc.poll() is not None, "the first server did not actually exit"
assert not first.is_listening(), "the first server is still answering"
second = workspace()
assert second.proc.pid != first.proc.pid
# --- D11: the Save Point is still there -------------------------------
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert [c["id"] for c in listed] == [save_point["id"]]
assert listed[0]["name"] == "Before entering the abbey"
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
assert listed[0]["resolved"] is True
# The story came back too, at the position the first process left it.
assert second.transcript(adventure_id) == at_tip["transcript"]
rows_before_restore = second.total_rows(adventure_id)
assert rows_before_restore == at_tip["rows"]
# --- L03: restoring reconstructs the historical position --------------
page = second.call(
"POST", f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200,
)
assert [a["text"] for a in page["actions"]] == at_save["transcript"]
assert second.transcript(adventure_id) == at_save["transcript"]
assert second.gold(adventure_id) == at_save["gold"]
# --- restore deleted nothing, and Redo is still available -------------
assert second.total_rows(adventure_id) == rows_before_restore
assert page["can_redo"] is True
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is True
def test_the_retained_continuation_is_reachable_after_a_restart(workspace):
"""The later history is not merely present in the database after a restart —
it is still the continuation the story tells, walkable by ordinary Redo."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
assert second.transcript(adventure_id) == at_save["transcript"]
steps = 0
while second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"]:
second.call("POST", f"/adventures/{adventure_id}/redo", expect=200)
steps += 1
assert steps <= 10, "Redo never stopped"
assert steps == 4
assert second.transcript(adventure_id) == at_tip["transcript"]
assert second.gold(adventure_id) == at_tip["gold"]
assert second.total_rows(adventure_id) == at_tip["rows"]
def test_a_divergent_write_after_a_restart_keeps_the_displaced_future(workspace):
"""D13's second half, across the boundary: the fork still happens on the
write rather than on the restore, and the displaced turns keep their rows."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
branches_before = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_before) == 1, "restore must not fork"
second.play(adventure_id, "go around the back")
branches_after = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_after) == 2, "the write should have forked"
# Nothing was deleted to achieve it: the tree grew by the new turn only.
assert second.total_rows(adventure_id) == at_tip["rows"] + 2
# Ordinary Redo no longer offers the displaced future.
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is False
# And the Save Point still names the position it always did.
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
+473 -15
View File
@@ -748,36 +748,169 @@ def test_a_save_point_whose_turn_is_gone_refuses_rather_than_approximating(clien
assert _head(client.adv_id) == head_before
def test_deleting_a_branch_takes_its_save_points_with_it(client):
"""Referential integrity, not cleanup. Nothing removes a Save Point for
going stale; this one goes because the story it named went."""
def test_a_branch_a_save_point_names_cannot_be_deleted(client):
"""`STORY-BRANCH-SEMANTICS.md` §19: a named Save Point remains until it is
explicitly deleted — and §28 says even a future cleanup feature must retain
paths a checkpoint references.
So the branch delete is refused rather than taking the Save Point with it.
The alternative, a silent cascade, breaks §19 in the way that matters least
visibly: the story is the thing the user asked to delete, and the named
moments would go without ever being mentioned.
"""
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
# Read somewhere the doomed branch is not load-bearing, then delete it.
root_id = _root_branch(client.adv_id)
assert doomed_branch != root_id
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
r = client.delete(f"/api/adventures/{client.adv_id}/branches/{doomed_branch}")
assert r.status_code == 409, r.text
detail = r.json()["detail"]
# The message names the Save Point, so the user does not have to hunt.
assert "On the new line" in detail
assert "does not delete any story" in detail
# Nothing happened: the Save Point, the branch and the story all remain.
assert [c["id"] for c in _list(client)] == [forked["id"]]
assert _branch_count(client.adv_id) == 2
assert any(a.branch_id == doomed_branch for a in _rows(client.adv_id))
def test_deleting_the_save_point_then_lets_the_branch_go(client):
"""The refusal has to be recoverable, or it is just a wall. Deleting the
Save Point deletes no story (§25), so the cost of the recovery is a click."""
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
root_id = _root_branch(client.adv_id)
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
assert client.delete(
f"/api/adventures/{client.adv_id}/branches/{doomed_branch}"
).status_code == 409
rows_with_story = len(_rows(client.adv_id))
# Delete the Save Point explicitly...
assert client.delete(
f"/api/adventures/{client.adv_id}/checkpoints/{forked['id']}"
).status_code == 204
# ...which took no story with it...
assert len(_rows(client.adv_id)) == rows_with_story
# ...and now the branch can go.
r = client.delete(f"/api/adventures/{client.adv_id}/branches/{doomed_branch}")
assert r.status_code in (200, 204), r.text
assert _branch_count(client.adv_id) == 1
def test_a_branch_no_save_point_names_still_deletes(client):
"""The guard must not turn into a general refusal to delete branches."""
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
assert _list(client) == []
r = client.delete(f"/api/adventures/{client.adv_id}/branches/{forked_branch}")
assert r.status_code in (200, 204), r.text
assert _branch_count(client.adv_id) == 1
def test_a_save_point_on_a_descendant_also_protects_the_branch(client):
"""Deleting a branch takes everything forked from it, so the check has to
cover the subtree. A guard that looked only at the named branch would let a
Save Point on a child be deleted without a word — the exact failure the
guard exists to prevent, one level down."""
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "third line")
deepest = _save(client, "Down on the deepest line")
assert deepest["branch_id"] != middle_branch
root_id = _root_branch(client.adv_id)
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
# Deleting the *middle* branch would take the deepest one with it.
r = client.delete(f"/api/adventures/{client.adv_id}/branches/{middle_branch}")
assert r.status_code == 409, r.text
assert "Down on the deepest line" in r.json()["detail"]
assert [c["id"] for c in _list(client)] == [deepest["id"]]
def test_a_save_point_elsewhere_does_not_block_an_unrelated_branch(client):
"""The guard is scoped to the subtree being deleted, not to the campaign."""
_turns(client, 3)
elsewhere = _save(client, "Safe on the root")
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
assert elsewhere["branch_id"] == root_id
r = client.delete(f"/api/adventures/{client.adv_id}/branches/{forked_branch}")
assert r.status_code in (200, 204), r.text
# The unrelated Save Point is untouched and still restores.
assert [c["id"] for c in _list(client)] == [elsewhere["id"]]
assert _restore(client, elsewhere["id"]).status_code == 200
def test_the_refusal_names_several_save_points_without_running_on(client):
"""A long list is truncated so the message stays a sentence someone reads."""
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way")
for n in range(5):
_save(client, f"Point {n}")
doomed_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
detail = client.delete(
f"/api/adventures/{client.adv_id}/branches/{doomed_branch}"
).json()["detail"]
assert "Point 0" in detail and "2 more" in detail
assert "Point 4" not in detail
assert len(_list(client)) == 5
def _root_branch(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, client.adv_id)
root = (
return (
db.query(models.Branch)
.filter_by(adventure_id=client.adv_id, parent_branch_id=None)
.filter_by(adventure_id=adv_id, parent_branch_id=None)
.one()
.id
)
doomed_id = forked["branch_id"]
assert doomed_id != root.id
finally:
db.close()
client.post(f"/api/adventures/{client.adv_id}/branches/{root.id}/switch")
r = client.delete(f"/api/adventures/{client.adv_id}/branches/{doomed_id}")
assert r.status_code in (200, 204), r.text
assert [c["id"] for c in _list(client)] == []
# --------------------------------------------- E-series: lineage and memory
@@ -1129,3 +1262,328 @@ def test_an_m3_database_gains_the_save_point_table_and_keeps_its_story():
migrations.bootstrap(m3)
assert "checkpoints" in inspect(m3).get_table_names()
m3.dispose()
# ------------------------------------------- the cost of listing Save Points
def _sql_during(work):
"""Returns every SQL statement a block of work executed."""
from sqlalchemy import event
seen: list[str] = []
def record(conn, cursor, statement, params, context, executemany):
seen.append(statement)
event.listen(engine, "before_cursor_execute", record)
try:
work()
finally:
event.remove(engine, "before_cursor_execute", record)
return seen
def test_listing_save_points_costs_a_bounded_number_of_queries(client):
"""M4 review §R B-1. The list resolved each Save Point on its own, so the
query count grew with the list: 53 SELECTs for 25 Save Points, against 4 for
the comparable branch panel.
The assertion is on *growth*, not on an exact number, because a fixed budget
would be a number to edit rather than a rule to keep. Five times the Save
Points must not mean five times the queries.
"""
_turns(client, 5)
for n in range(5):
_save(client, f"Save Point {n}")
few = _sql_during(lambda: _list(client))
_turns(client, 20)
for n in range(20):
_save(client, f"Later Save Point {n}")
assert len(_list(client)) == 25
many = _sql_during(lambda: _list(client))
# Five times the rows, and the query count does not move at all.
assert len(many) == len(few), (
f"listing 25 Save Points cost {len(many)} queries where 5 cost {len(few)}"
)
# And the whole thing is a handful, not a per-row walk.
assert len(many) <= 6, f"{len(many)} queries to list 25 Save Points"
def test_listing_save_points_does_not_read_story_prose(client):
"""The other half of B-1. Resolving a coordinate asks whether a row exists;
it never needs the narration in it, and `paging.py` states the rule this
follows — a bulk read names the columns it needs.
Enforced on the emitted SQL rather than on a byte count, because the failure
this guards against is a `SELECT` widening back to the whole entity, which a
small fixture would not make visible in bytes.
"""
_turns(client, 3)
for n in range(3):
_save(client, f"Save Point {n}")
statements = _sql_during(lambda: _list(client))
action_reads = [q for q in statements if "FROM actions" in q]
assert action_reads, "the list must still check that coordinates resolve"
for query in action_reads:
selected = query.split("FROM actions")[0]
for column in ("actions.text", "actions.reasoning", "actions.world_delta"):
assert column not in selected, f"{column} fetched to render the list:\n{query}"
def test_a_save_point_on_a_deleted_turn_is_still_reported_unresolved(client):
"""The bulk resolution must not have quietly changed what `resolved` means.
This is the negative control for the B-1 rewrite: one query for many
coordinates is only correct if a coordinate with no live row still comes
back false.
"""
_turns(client, 3)
alive = _save(client, "Still here")
_turns(client, 1)
doomed = _save(client, "About to vanish")
db = SessionLocal()
try:
for row in db.query(models.Action).filter_by(
adventure_id=client.adv_id,
branch_id=doomed["branch_id"],
depth=doomed["depth"],
):
db.delete(row)
db.commit()
finally:
db.close()
by_id = {c["id"]: c for c in _list(client)}
assert by_id[doomed["id"]]["resolved"] is False
assert by_id[alive["id"]]["resolved"] is True
# One resolving and one not, in the same single query.
def test_the_bulk_resolution_does_not_confuse_coordinates_across_branches(client):
"""A coordinate is a pair, and the bulk query must match it as a pair.
Matching `branch IN (...) AND depth IN (...)` would take the cross product,
and a Save Point at a depth that exists on *another* branch would be
reported as resolved. This builds exactly that trap: two branches, and a
Save Point whose own coordinate is dead while the other branch has a live
row at the same depth.
"""
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way") # forks; new branch has rows at 5,6
on_new_line = _save(client, "On the new line")
# A Save Point on the old line at the same depth, whose row we then remove.
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
old_branch = (
db.query(models.Branch)
.filter_by(adventure_id=client.adv_id, parent_branch_id=None)
.one()
)
stranded = models.Checkpoint(
adventure_id=client.adv_id,
name="Stranded on the old line",
branch_id=old_branch.id,
depth=on_new_line["depth"],
)
db.add(stranded)
db.flush()
stranded_id = stranded.id
# Remove the old line's row at that depth, so this coordinate is dead
# while the *other* branch still has a live row at the same depth.
for row in db.query(models.Action).filter_by(
adventure_id=client.adv_id,
branch_id=old_branch.id,
depth=on_new_line["depth"],
):
db.delete(row)
db.commit()
finally:
db.close()
by_id = {c["id"]: c for c in _list(client)}
assert by_id[on_new_line["id"]]["resolved"] is True
assert by_id[stranded_id]["resolved"] is False, (
"a dead coordinate was reported resolved because another branch has a "
"live row at the same depth"
)
# --------------------------------- deleting a branch, and saying so first
def test_the_branch_list_reports_how_many_save_points_each_line_carries(client):
"""The number the delete warning is built from (M4 review §R B-2).
Served as part of the branch list rather than from an endpoint of its own,
and as one grouped query rather than one per branch — the panel already
reads this list to draw itself.
"""
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "the other way")
on_new = _save(client, "On the new line")
branches = client.get(f"/api/adventures/{client.adv_id}/branches").json()
by_id = {b["id"]: b for b in branches}
assert len(branches) == 2
assert by_id[on_new["branch_id"]]["save_points"] == 1
other = next(b for b in branches if b["id"] != on_new["branch_id"])
assert other["save_points"] == 0
_save(client, "A second one here")
branches = client.get(f"/api/adventures/{client.adv_id}/branches").json()
assert {b["id"]: b["save_points"] for b in branches}[on_new["branch_id"]] == 2
def test_the_save_point_count_matches_what_blocks_the_deletion(client):
"""The number the panel disables its Delete button on has to be the same
number the server refuses on, or the UI and the rule disagree."""
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
_play(client, "third line")
_save(client, "Deep one")
branches = client.get(f"/api/adventures/{client.adv_id}/branches").json()
counts = {b["id"]: b["save_points"] for b in branches}
# The count is per branch; the client sums it over the subtree, and the
# server refuses on the same subtree.
assert sum(counts.values()) == 1
assert counts[middle_branch] == 0
root_id = _root_branch(client.adv_id)
client.post(f"/api/adventures/{client.adv_id}/branches/{root_id}/switch")
assert client.delete(
f"/api/adventures/{client.adv_id}/branches/{middle_branch}"
).status_code == 409
def test_both_branch_delete_surfaces_explain_the_save_point_rule():
"""The rule must be visible in every view the deletion is reachable from.
A source-level assertion, because the project has no frontend test runner
(M8). `test_offline_assets.py` reads the frontend the same way, for the same
reason: the check is worth having now, and it is honest about what it is —
it proves the wiring is in the build, not that a user saw it. The browser
smoke test performed at closeout is what proves the rendering.
Two files, because the branch list and the tree overlay each render their
own delete control, and a rule that held in one of them would not be a rule.
"""
from pathlib import Path
repo = Path(__file__).resolve().parents[2]
views = {
"the branch panel":
repo / "frontend/src/pages/Play/panels/BranchPanel.jsx",
"the tree overlay":
repo / "frontend/src/BranchMap.jsx",
}
for where, path in views.items():
source = path.read_text(encoding="utf-8")
assert "savePointsUnder" in source, (
f"{where} does not count the Save Points that protect a branch"
)
# The Delete control is disabled while Save Points protect the subtree,
# and says why rather than failing silently on the server.
assert "protecting > 0" in source, (
f"{where} does not disable Delete while Save Points protect the branch"
)
assert "deleting a Save Point deletes no story" in source, (
f"{where} does not tell the user how to proceed"
)
# The user-facing copy must not explain itself in schema terms.
for jargon in ("cascade", "foreign key", "foreign-key", "ON DELETE"):
assert jargon.lower() not in source.lower(), (
f"{where} uses implementation jargon in user-facing copy: {jargon}"
)
def test_creating_a_save_point_takes_the_campaigns_turn_lock(client):
"""M4 review §S C-5. "Save where I am" has to name one committed position,
and the head is exactly what a turn in flight is about to move.
Asserted by holding the lock and watching Create refuse, which is the same
contract Undo, Redo and Restore already answer with a 409. That proves it
participates in the serialization rather than merely being fast.
"""
_turns(client, 2)
adventures.turns.acquire_turn_lock(client.adv_id)
try:
r = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
json={"name": "During a turn"})
assert r.status_code == 409, r.text
# The same refusal the other position-moving operations give.
assert _undo(client).status_code == 409
assert _restore(client, 1).status_code in (404, 409)
finally:
adventures.turns._active_turns.discard(client.adv_id)
# Nothing was written while the lock was held...
assert _list(client) == []
# ...and the endpoint works again once the turn is done.
assert _save(client, "After the turn")["name"] == "After the turn"
def test_creating_a_save_point_releases_the_lock_even_when_it_refuses(client):
"""A refused create must not leave the campaign wedged.
The empty-story refusal is raised from inside the locked section, so this is
the case that would strand the lock if the release were not in a `finally`.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, client.adv_id)
adv.head_depth = lineage.NO_DEPTH
db.commit()
finally:
db.close()
r = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
json={"name": "Nowhere"})
assert r.status_code == 400
# The lock is free: an ordinary turn still works.
assert client.adv_id not in adventures.turns._active_turns
_play(client, "carry on")
def test_rename_and_delete_do_not_need_the_turn_lock(client):
"""Deliberate, and worth pinning down: neither reads nor moves a story
position, so neither can race a turn in flight. Renaming a Save Point while
a turn generates is a label edit, and refusing it would be a worse product
for no safety gained."""
_turns(client, 2)
made = _save(client, "One")
adventures.turns.acquire_turn_lock(client.adv_id)
try:
renamed = client.patch(
f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}",
json={"name": "Renamed mid-turn"},
)
assert renamed.status_code == 200, renamed.text
assert renamed.json()["name"] == "Renamed mid-turn"
# And the coordinate did not move while a turn was in flight.
assert (renamed.json()["branch_id"], renamed.json()["depth"]) == (
made["branch_id"], made["depth"]
)
assert client.delete(
f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}"
).status_code == 204
finally:
adventures.turns._active_turns.discard(client.adv_id)