A Save Point is a name for a story position, and restoring one is head movement. That is the whole architecture, and it is what ADR 012 and BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored (branch, depth) and made arriving at one a row lookup plus a state restore, so a Save Point needs no restore machinery of its own. What the user gets: - Name the moment they are reading, keep playing, restart the app, and come back to it. Restoring moves the story back and deletes nothing: the later turns stay, Redo still walks forward into them, and writing something different is what starts a new line while the old one is kept. - Rename, delete, and a list, in a Save Points panel beside the branch panel, with a Save Point button next to Undo and Redo. Both confirmations say what is *not* destroyed, because that is the part the screen cannot show. - Save Points survive export and import. What was deliberately not built: - No second restore path. `head.move_to_node` is the only new movement: its depth half is M3's `head.move_to` unchanged, and its branch half is the single assignment `switch_branch` already makes. No head field is written in the checkpoint router, nothing reconstructs state, nothing prunes a memory, nothing copies or deletes a turn, and restore never forks — the first write below the restored head does, through `fork_if_behind_head`. - No automatic cleanup. A Save Point behind the head, or naming a line the story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal is a cascade: deleting a branch takes its Save Points, as it takes its memories, because the story they named went with it. - No new ADR. ADR 012 already decides the architecture, and a table is not a decision. The one call the planning package did not already make: restore moves the branch half of the head only when the coordinate is off the path being read. Doing it unconditionally would quietly hand back an abandoned continuation whenever a Save Point in a shared prefix was restored; never doing it would make a Save Point on a departed line unrestorable, which contradicts §19. TECHNICAL-DESIGN §8.8 records it. Schema: a `checkpoints` table holding a name, an optional note and a (branch, depth) coordinate — no copy of any story. `create_all` builds it as it did `memories` and `branches`; migration 80 adds the index. No backfill, because nobody had named a position before M4. The coordinate is deliberately not an action id: one coordinate holds every attempt at a turn and exactly one is live, so a coordinate follows a retry where a row id would pin a take the story no longer tells. Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering D11-D14, I04, L03, E-series lineage and memory isolation after restore and divergence, the edge cases, and an M3-database migration. One pre-existing fixture in test_tree_migration.py needed `checkpoints` added to its drop list — SQLite refuses to drop a table another table references. Not verified: the browser. No session has had a usable one, so the Save Point panel's DOM behaviour is unobserved — as M3's Redo control still is. The twenty-step sequence was driven over HTTP against a live server with a real process restart instead, and all seventeen checks pass. M4 is implemented, not accepted: no review has been written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
1132 lines
39 KiB
Python
1132 lines
39 KiB
Python
"""M4: Save Points, and the promise that restoring one deletes nothing.
|
|
|
|
This file is the acceptance contract for the milestone that gave a story
|
|
position a durable name. Its subject is one claim:
|
|
|
|
A Save Point is a name for a coordinate, and restoring it is head movement.
|
|
|
|
Everything else follows. Restore keeps later history because head movement
|
|
deletes nothing (M3). Redo still walks forward afterwards because the retained
|
|
lineage is unchanged. Divergence after a restore forks through the same check
|
|
every write goes through, and the Save Point still names the same position when
|
|
it is over. Memory and context narrow and widen with the head, because the head
|
|
caps the one path all four reads share.
|
|
|
|
The tests are named for the acceptance items they discharge — D11-D14, E01-E04,
|
|
I04 and L03 in `planning/V1-ACCEPTANCE-TESTS.md`.
|
|
|
|
The world state is instrumentation here, not the subject, exactly as it is in
|
|
`test_head_cursor.py`: each scripted reply banks ten gold so that "the state at
|
|
this position" is a number a test can assert rather than a paragraph it has to
|
|
interpret. M5 replaces that machinery with genre-neutral narrative state, and
|
|
these tests then need the instrumentation moved, not the assertions removed.
|
|
|
|
python -m pytest tests/test_save_points.py -v
|
|
"""
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import limits, models
|
|
from app.context import lineage
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.main import app
|
|
from app import auth
|
|
from app.routers import adventures
|
|
|
|
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="save@example.com")
|
|
setup.add(user)
|
|
setup.flush()
|
|
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
|
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA)
|
|
setup.add(scenario)
|
|
setup.flush()
|
|
adv = models.Adventure(
|
|
user_id=user.id, title="Abbey", scenario_id=scenario.id,
|
|
world_state={"player": {"hp": 100, "gold": 0}},
|
|
)
|
|
setup.add(adv)
|
|
setup.flush()
|
|
setup.add(models.Action(adventure_id=adv.id, type="start", text="The road forks."))
|
|
setup.commit()
|
|
adv_id, user_id = adv.id, user.id
|
|
setup.close()
|
|
|
|
ScriptedProvider.replies = gold_replies()
|
|
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
|
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
|
|
|
def _current_user(db=Depends(get_db)):
|
|
return db.get(models.User, user_id)
|
|
|
|
app.dependency_overrides[auth.get_current_user] = _current_user
|
|
c = TestClient(app)
|
|
c.adv_id = adv_id
|
|
try:
|
|
yield c
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
adventures.turns._active_turns.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
# ------------------------------------------------------------------ helpers
|
|
|
|
def _play(client, text="look around", type="do", adv_id=None):
|
|
r = client.post(
|
|
f"/api/adventures/{adv_id or client.adv_id}/actions",
|
|
json={"type": type, "text": text},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r
|
|
|
|
|
|
def _turns(client, count):
|
|
for n in range(count):
|
|
_play(client, f"turn {n}")
|
|
|
|
|
|
def _undo(client):
|
|
return client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
|
|
|
|
def _redo(client):
|
|
return client.post(f"/api/adventures/{client.adv_id}/redo")
|
|
|
|
|
|
def _save(client, name="Before entering the abbey", note=None, adv_id=None):
|
|
"""Creates a Save Point at wherever the story is being read."""
|
|
payload = {"name": name}
|
|
if note is not None:
|
|
payload["note"] = note
|
|
r = client.post(
|
|
f"/api/adventures/{adv_id or client.adv_id}/checkpoints", json=payload
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def _list(client, adv_id=None):
|
|
r = client.get(f"/api/adventures/{adv_id or client.adv_id}/checkpoints")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _restore(client, checkpoint_id, adv_id=None):
|
|
return client.post(
|
|
f"/api/adventures/{adv_id or client.adv_id}/checkpoints/{checkpoint_id}/restore"
|
|
)
|
|
|
|
|
|
def _adventure(client, adv_id=None) -> dict:
|
|
r = client.get(f"/api/adventures/{adv_id or client.adv_id}")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _texts(client, adv_id=None) -> list[str]:
|
|
return [a["text"] for a in _adventure(client, adv_id)["actions"]]
|
|
|
|
|
|
def _rows(adv_id) -> list[models.Action]:
|
|
"""Every action row, story or not, live or not, head or no head."""
|
|
db = SessionLocal()
|
|
try:
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == adv_id)
|
|
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _gold(adv_id) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
adv = db.get(models.Adventure, adv_id)
|
|
return (adv.world_state or {}).get("player", {}).get("gold", 0)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _head(adv_id) -> tuple[int, int]:
|
|
db = SessionLocal()
|
|
try:
|
|
adv = db.get(models.Adventure, adv_id)
|
|
return adv.head_branch_id, adv.head_depth
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _restart(client):
|
|
"""Simulates stopping and restarting the application.
|
|
|
|
A Save Point is a row, so what a restart has to prove is that nothing about
|
|
it lived in the process. Every session this file opens is closed again, so
|
|
dropping the client's own session and reading through a new one is what a
|
|
restart changes: no cached adventure, no cached head, no in-memory list.
|
|
"""
|
|
client.close()
|
|
adventures.turns._active_turns.clear()
|
|
fresh = TestClient(app)
|
|
fresh.adv_id = client.adv_id
|
|
return fresh
|
|
|
|
|
|
def _export(client, adv_id=None) -> dict:
|
|
r = client.get(f"/api/adventures/{adv_id or client.adv_id}/export")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _import(client, bundle) -> dict:
|
|
r = client.post("/api/adventures/import", json=bundle)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def _story_of(adv_id) -> list[str]:
|
|
"""The story an adventure tells, read through its own head."""
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adv_id)
|
|
rows = (
|
|
db.query(models.Action)
|
|
.filter(
|
|
models.Action.adventure_id == adv_id,
|
|
lineage.path_of(db, adventure).clause(models.Action),
|
|
)
|
|
.order_by(models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
return [a.text for a in rows]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# --------------------------------------------------------- D11: named, durable
|
|
|
|
def test_d11_a_named_save_point_survives_a_restart(client):
|
|
"""The acceptance test names the Save Point, so this one does too."""
|
|
_turns(client, 3)
|
|
made = _save(client, "Before entering the abbey")
|
|
assert made["name"] == "Before entering the abbey"
|
|
|
|
after = _restart(client)
|
|
try:
|
|
kept = _list(after)
|
|
assert [c["name"] for c in kept] == ["Before entering the abbey"]
|
|
assert kept[0]["id"] == made["id"]
|
|
assert (kept[0]["branch_id"], kept[0]["depth"]) == (
|
|
made["branch_id"], made["depth"]
|
|
)
|
|
finally:
|
|
after.close()
|
|
|
|
|
|
def test_a_save_point_records_the_position_the_story_is_read_at(client):
|
|
"""The active head, not the retained tip. A Save Point made after an Undo
|
|
names the undone position, because that is the turn the reader is looking
|
|
at — a distinction that only exists because M3 stopped Undo deleting."""
|
|
_turns(client, 5)
|
|
_undo(client)
|
|
_undo(client)
|
|
branch, depth = _head(client.adv_id)
|
|
|
|
made = _save(client, "Two turns back")
|
|
|
|
assert (made["branch_id"], made["depth"]) == (branch, depth)
|
|
assert made["turn"] == depth + 1
|
|
|
|
|
|
def test_a_save_point_needs_a_name(client):
|
|
_turns(client, 1)
|
|
for blank in ("", " ", "\n\t "):
|
|
r = client.post(
|
|
f"/api/adventures/{client.adv_id}/checkpoints", json={"name": blank}
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
assert "needs a name" in r.json()["detail"]
|
|
assert _list(client) == []
|
|
|
|
|
|
def test_a_name_is_stored_trimmed(client):
|
|
_turns(client, 1)
|
|
made = _save(client, " Before entering the abbey ")
|
|
assert made["name"] == "Before entering the abbey"
|
|
|
|
|
|
def test_an_empty_story_has_no_position_to_save(client):
|
|
"""The opening node is the campaign's first position, so this refuses only
|
|
the case where the head rests in front of every turn."""
|
|
_undo(client) # nothing to undo; the head is already on the opening
|
|
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
|
|
assert "no turn here to save" in r.json()["detail"]
|
|
|
|
|
|
# ------------------------------------------------------------ D12: restoring
|
|
|
|
def test_d12_restore_returns_the_transcript_and_the_state(client):
|
|
_turns(client, 2)
|
|
told = _texts(client)
|
|
banked = _gold(client.adv_id)
|
|
made = _save(client, "Before entering the abbey")
|
|
|
|
_turns(client, 3)
|
|
assert _gold(client.adv_id) == banked + 3 * GOLD_PER_TURN
|
|
|
|
page = _restore(client, made["id"])
|
|
assert page.status_code == 200, page.text
|
|
|
|
assert [a["text"] for a in page.json()["actions"]] == told
|
|
assert _texts(client) == told
|
|
assert _gold(client.adv_id) == banked
|
|
|
|
|
|
def test_restore_is_the_same_head_movement_undo_makes(client):
|
|
"""The mechanism claim, measured rather than asserted in prose. Restoring to
|
|
a position and undoing to it must leave the campaign in the same state, both
|
|
halves of the head included."""
|
|
_turns(client, 4)
|
|
made = _save(client, "Here")
|
|
_turns(client, 2)
|
|
|
|
_restore(client, made["id"])
|
|
by_restore = (_head(client.adv_id), _gold(client.adv_id), _texts(client))
|
|
|
|
_redo(client)
|
|
_redo(client)
|
|
_undo(client)
|
|
_undo(client)
|
|
by_undo = (_head(client.adv_id), _gold(client.adv_id), _texts(client))
|
|
|
|
assert by_restore == by_undo
|
|
|
|
|
|
def test_restoring_the_same_save_point_repeatedly_is_stable(client):
|
|
_turns(client, 3)
|
|
made = _save(client, "Here")
|
|
_turns(client, 2)
|
|
|
|
seen = []
|
|
for _ in range(3):
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
seen.append((_head(client.adv_id), _gold(client.adv_id), _texts(client)))
|
|
assert seen[0] == seen[1] == seen[2]
|
|
|
|
|
|
def test_a_save_point_at_the_current_tip_restores_to_a_story_that_never_moved(client):
|
|
_turns(client, 3)
|
|
made = _save(client, "Right here")
|
|
before = (_head(client.adv_id), _gold(client.adv_id), _texts(client))
|
|
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
|
|
assert (_head(client.adv_id), _gold(client.adv_id), _texts(client)) == before
|
|
|
|
|
|
def test_a_save_point_at_the_campaign_opening_restores_to_the_opening(client):
|
|
"""The floor Undo stops at is a position like any other, and naming it must
|
|
not need a special case."""
|
|
_turns(client, 3)
|
|
for _ in range(3):
|
|
_undo(client)
|
|
assert _texts(client) == ["The road forks."]
|
|
made = _save(client, "The very beginning")
|
|
|
|
_redo(client)
|
|
_redo(client)
|
|
assert len(_texts(client)) == 5
|
|
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
assert _texts(client) == ["The road forks."]
|
|
assert _gold(client.adv_id) == 0
|
|
|
|
|
|
def test_restore_after_undo_and_redo_activity_lands_where_the_name_says(client):
|
|
_turns(client, 5)
|
|
_undo(client)
|
|
_undo(client)
|
|
made = _save(client, "Amid the undoing")
|
|
at_save = (_head(client.adv_id), _gold(client.adv_id), _texts(client))
|
|
|
|
_redo(client)
|
|
_undo(client)
|
|
_undo(client)
|
|
_redo(client)
|
|
_redo(client)
|
|
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
assert (_head(client.adv_id), _gold(client.adv_id), _texts(client)) == at_save
|
|
|
|
|
|
def test_several_save_points_at_different_positions_each_restore_to_their_own(client):
|
|
marks = []
|
|
for n in range(4):
|
|
_play(client, f"turn {n}")
|
|
marks.append((_save(client, f"After turn {n}"), _texts(client), _gold(client.adv_id)))
|
|
|
|
for mark, told, banked in reversed(marks):
|
|
assert _restore(client, mark["id"]).status_code == 200
|
|
assert _texts(client) == told
|
|
assert _gold(client.adv_id) == banked
|
|
|
|
|
|
def test_two_save_points_may_name_the_same_position(client):
|
|
"""No uniqueness is imposed on names or on positions. Nothing in the product
|
|
requirements asks for it, and two names for one turn is a reasonable thing
|
|
for a player to want."""
|
|
_turns(client, 2)
|
|
first = _save(client, "Before the abbey")
|
|
second = _save(client, "Where I keep dying")
|
|
|
|
assert first["id"] != second["id"]
|
|
assert (first["branch_id"], first["depth"]) == (second["branch_id"], second["depth"])
|
|
|
|
_turns(client, 2)
|
|
for made in (first, second):
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
assert len(_texts(client)) == 5
|
|
|
|
|
|
# ---------------------------------------- D13: restore does not delete history
|
|
|
|
def test_d13_restore_deletes_no_accepted_history(client):
|
|
"""The measurement, on row identity rather than on a count: every row that
|
|
existed before the restore is still there afterwards, and it is the same
|
|
row."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 3)
|
|
before = {a.id for a in _rows(client.adv_id)}
|
|
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
|
|
after = {a.id for a in _rows(client.adv_id)}
|
|
assert after == before
|
|
assert len(after) == 11 # the opening, plus two rows for each of five turns
|
|
|
|
|
|
def test_d13_the_retained_continuation_can_still_be_redone(client):
|
|
"""Restore is not a decision to abandon anything, so the future it steps
|
|
behind is still the continuation this story tells. `STORY-BRANCH-SEMANTICS`
|
|
§20."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 3)
|
|
whole = _texts(client)
|
|
tip_gold = _gold(client.adv_id)
|
|
|
|
_restore(client, made["id"])
|
|
assert _adventure(client)["can_redo"] is True
|
|
|
|
for _ in range(3):
|
|
assert _redo(client).status_code == 200
|
|
assert _texts(client) == whole
|
|
assert _gold(client.adv_id) == tip_gold
|
|
|
|
|
|
def test_d13_a_different_continuation_forks_and_keeps_the_old_future(client):
|
|
"""The other half of D13. The fork happens on the first write below the
|
|
restored head, not because Restore was clicked."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 3)
|
|
old_future = {a.id for a in _rows(client.adv_id)}
|
|
branches_before = _branch_count(client.adv_id)
|
|
|
|
_restore(client, made["id"])
|
|
# Restore itself created nothing.
|
|
assert _branch_count(client.adv_id) == branches_before
|
|
|
|
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "go around the back")
|
|
|
|
# The write forked...
|
|
assert _branch_count(client.adv_id) == branches_before + 1
|
|
# ...ordinary Redo no longer offers the displaced future...
|
|
assert _adventure(client)["can_redo"] is False
|
|
assert _redo(client).status_code == 400
|
|
# ...and not one row of it was deleted to achieve that.
|
|
assert old_future <= {a.id for a in _rows(client.adv_id)}
|
|
assert _texts(client)[-1].startswith("Through the side door.")
|
|
|
|
|
|
def test_d13_the_save_point_still_names_the_same_position_after_divergence(client):
|
|
"""`STORY-BRANCH-SEMANTICS.md` §19: a divergence does not disturb a Save
|
|
Point. It still points at the turn it was made on, and restoring it now
|
|
returns to the *new* line's reading of that position."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
told = _texts(client)
|
|
_turns(client, 3)
|
|
|
|
_restore(client, made["id"])
|
|
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "go around the back")
|
|
|
|
kept = _list(client)
|
|
assert len(kept) == 1
|
|
assert (kept[0]["branch_id"], kept[0]["depth"]) == (made["branch_id"], made["depth"])
|
|
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
assert _texts(client) == told
|
|
# The new continuation is what Redo walks into now, not the displaced one.
|
|
assert _adventure(client)["can_redo"] is True
|
|
_redo(client)
|
|
assert _texts(client)[-1].startswith("Through the side door.")
|
|
|
|
|
|
def test_a_save_point_on_a_line_the_story_left_still_restores(client):
|
|
"""The case that needs the head's other half to move.
|
|
|
|
A Save Point survives divergence, so one can name a position on a future the
|
|
story has since displaced — and no amount of depth movement reaches a branch
|
|
the current path does not contain. Restoring it moves the line as well, the
|
|
same single assignment a branch switch makes, and still forks nothing.
|
|
"""
|
|
_turns(client, 2)
|
|
fork_point = _save(client, "The fork")
|
|
_turns(client, 3)
|
|
deep = _save(client, "Down the old road")
|
|
old_story = _texts(client)
|
|
old_gold = _gold(client.adv_id)
|
|
old_branch, old_depth = _head(client.adv_id)
|
|
|
|
_restore(client, fork_point["id"])
|
|
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "go around the back")
|
|
new_branch, _ = _head(client.adv_id)
|
|
assert new_branch != old_branch
|
|
|
|
# The displaced Save Point is not on the line being read...
|
|
listed = {c["id"]: c for c in _list(client)}
|
|
assert listed[deep["id"]]["on_path"] is False
|
|
assert listed[fork_point["id"]]["on_path"] is True
|
|
assert listed[deep["id"]]["resolved"] is True
|
|
|
|
# ...and restoring it goes back to the line it names, with its own state.
|
|
assert _restore(client, deep["id"]).status_code == 200
|
|
assert _head(client.adv_id) == (old_branch, old_depth)
|
|
assert _texts(client) == old_story
|
|
assert _gold(client.adv_id) == old_gold
|
|
|
|
|
|
def test_restore_onto_an_inherited_position_keeps_the_line_being_read(client):
|
|
"""A Save Point in the shared prefix must not drag the reader back onto the
|
|
ancestor. The turn is the same row either way; which continuation follows it
|
|
is not, and the active line is the one the reader chose."""
|
|
_turns(client, 4)
|
|
_undo(client)
|
|
_undo(client)
|
|
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "the other way")
|
|
new_branch, _ = _head(client.adv_id)
|
|
|
|
# Made now, on the new line, but naming a turn that physically lives on the
|
|
# branch the story left.
|
|
_undo(client)
|
|
_undo(client)
|
|
_undo(client)
|
|
made = _save(client, "In the shared past")
|
|
assert made["branch_id"] != new_branch
|
|
|
|
_redo(client)
|
|
_redo(client)
|
|
assert _restore(client, made["id"]).status_code == 200
|
|
|
|
# Still reading the new line, so Redo walks up the shared past and on into
|
|
# the new continuation — depth 6 holds "A new road." on this line and the
|
|
# displaced "Take 3." on the one the story left.
|
|
assert _head(client.adv_id)[0] == new_branch
|
|
for _ in range(3):
|
|
assert _adventure(client)["can_redo"] is True
|
|
assert _redo(client).status_code == 200
|
|
assert _texts(client)[-1].startswith("A new road.")
|
|
|
|
|
|
def _branch_count(adv_id) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
return db.query(models.Branch).filter_by(adventure_id=adv_id).count()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------------------- D14: deleting
|
|
|
|
def test_d14_delete_removes_the_pointer_and_no_story(client):
|
|
_turns(client, 3)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 2)
|
|
rows = {a.id for a in _rows(client.adv_id)}
|
|
told = _texts(client)
|
|
head_before = _head(client.adv_id)
|
|
|
|
r = client.delete(f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}")
|
|
assert r.status_code == 204, r.text
|
|
|
|
assert _list(client) == []
|
|
assert {a.id for a in _rows(client.adv_id)} == rows
|
|
assert _texts(client) == told
|
|
assert _head(client.adv_id) == head_before
|
|
assert _branch_count(client.adv_id) >= 1
|
|
|
|
|
|
def test_deleting_one_save_point_leaves_the_others(client):
|
|
_turns(client, 1)
|
|
first = _save(client, "One")
|
|
_turns(client, 1)
|
|
second = _save(client, "Two")
|
|
|
|
client.delete(f"/api/adventures/{client.adv_id}/checkpoints/{first['id']}")
|
|
|
|
assert [c["id"] for c in _list(client)] == [second["id"]]
|
|
|
|
|
|
def test_deleting_a_save_point_twice_is_a_404(client):
|
|
_turns(client, 1)
|
|
made = _save(client, "One")
|
|
path = f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}"
|
|
assert client.delete(path).status_code == 204
|
|
assert client.delete(path).status_code == 404
|
|
|
|
|
|
# --------------------------------------------------------------- renaming
|
|
|
|
def test_rename_changes_the_label_and_not_the_coordinate(client):
|
|
"""`STORY-BRANCH-SEMANTICS.md` §23."""
|
|
_turns(client, 3)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 2)
|
|
|
|
r = client.patch(
|
|
f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}",
|
|
json={"name": " Before the abbey, second try "},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
renamed = r.json()
|
|
assert renamed["name"] == "Before the abbey, second try"
|
|
assert (renamed["branch_id"], renamed["depth"]) == (made["branch_id"], made["depth"])
|
|
|
|
# And it still restores to exactly the position it always did.
|
|
_restore(client, made["id"])
|
|
assert len(_texts(client)) == 7
|
|
|
|
|
|
def test_rename_moves_no_story_and_no_head(client):
|
|
_turns(client, 3)
|
|
made = _save(client, "One")
|
|
rows = {a.id for a in _rows(client.adv_id)}
|
|
head_before = _head(client.adv_id)
|
|
|
|
client.patch(
|
|
f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}",
|
|
json={"name": "Another name"},
|
|
)
|
|
|
|
assert {a.id for a in _rows(client.adv_id)} == rows
|
|
assert _head(client.adv_id) == head_before
|
|
|
|
|
|
def test_rename_refuses_a_blank_name(client):
|
|
_turns(client, 1)
|
|
made = _save(client, "One")
|
|
r = client.patch(
|
|
f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}", json={"name": " "}
|
|
)
|
|
assert r.status_code == 400
|
|
assert _list(client)[0]["name"] == "One"
|
|
|
|
|
|
def test_a_note_can_be_kept_and_edited(client):
|
|
"""`DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
|
|
optional second field."""
|
|
_turns(client, 1)
|
|
made = _save(client, "One", note="the door was locked")
|
|
assert made["note"] == "the door was locked"
|
|
|
|
r = client.patch(
|
|
f"/api/adventures/{client.adv_id}/checkpoints/{made['id']}",
|
|
json={"note": "the door was barred"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["note"] == "the door was barred"
|
|
assert r.json()["name"] == "One"
|
|
|
|
|
|
# ------------------------------------------------------ ownership and errors
|
|
|
|
def test_a_save_point_cannot_be_reached_through_another_campaign(client):
|
|
"""A coordinate from another campaign names a different story's turn. The id
|
|
is matched against the adventure in the path, so this is a 404 rather than a
|
|
restore of the wrong story."""
|
|
_turns(client, 3)
|
|
mine = _save(client, "Mine")
|
|
|
|
other = client.post(
|
|
"/api/adventures", json={"title": "Another campaign"}
|
|
)
|
|
assert other.status_code in (200, 201), other.text
|
|
other_id = other.json()["id"]
|
|
|
|
for method, path in (
|
|
("post", f"/api/adventures/{other_id}/checkpoints/{mine['id']}/restore"),
|
|
("patch", f"/api/adventures/{other_id}/checkpoints/{mine['id']}"),
|
|
("delete", f"/api/adventures/{other_id}/checkpoints/{mine['id']}"),
|
|
):
|
|
call = getattr(client, method)
|
|
r = call(path, json={"name": "x"}) if method == "patch" else call(path)
|
|
assert r.status_code == 404, (path, r.text)
|
|
|
|
# It is untouched, and still restores in its own campaign.
|
|
assert [c["id"] for c in _list(client)] == [mine["id"]]
|
|
assert _restore(client, mine["id"]).status_code == 200
|
|
|
|
|
|
def test_an_unknown_save_point_is_a_404(client):
|
|
_turns(client, 1)
|
|
assert _restore(client, 999_999).status_code == 404
|
|
assert client.delete(
|
|
f"/api/adventures/{client.adv_id}/checkpoints/999999"
|
|
).status_code == 404
|
|
|
|
|
|
def test_a_save_point_whose_turn_is_gone_refuses_rather_than_approximating(client):
|
|
"""Moving the head to the nearest surviving turn would be worse than doing
|
|
nothing: a Save Point that silently means somewhere else."""
|
|
_turns(client, 3)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 2)
|
|
head_before = _head(client.adv_id)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
doomed = (
|
|
db.query(models.Action)
|
|
.filter_by(
|
|
adventure_id=client.adv_id,
|
|
branch_id=made["branch_id"],
|
|
depth=made["depth"],
|
|
)
|
|
.all()
|
|
)
|
|
assert doomed
|
|
for row in doomed:
|
|
db.delete(row)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
listed = _list(client)
|
|
assert listed[0]["resolved"] is False
|
|
|
|
r = _restore(client, made["id"])
|
|
assert r.status_code == 409
|
|
assert "no longer part of this story" in r.json()["detail"]
|
|
# And nothing moved.
|
|
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."""
|
|
_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")
|
|
|
|
# Read somewhere the doomed branch is not load-bearing, then delete it.
|
|
db = SessionLocal()
|
|
try:
|
|
adv = db.get(models.Adventure, client.adv_id)
|
|
root = (
|
|
db.query(models.Branch)
|
|
.filter_by(adventure_id=client.adv_id, parent_branch_id=None)
|
|
.one()
|
|
)
|
|
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
|
|
|
|
def test_e01_a_memory_past_a_restored_head_stops_being_retrievable(client):
|
|
"""The M3 chokepoint, exercised through the M4 door. Nothing prunes a memory
|
|
here — it stops matching the capped path, and starts again on Redo, without
|
|
being deleted or re-embedded."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 3)
|
|
|
|
branch, depth = _head(client.adv_id)
|
|
db = SessionLocal()
|
|
try:
|
|
db.add(models.Memory(
|
|
adventure_id=client.adv_id,
|
|
text="Mara learns the location of the key.",
|
|
branch_id=branch, depth=depth,
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
assert _visible_memories(client) == ["Mara learns the location of the key."]
|
|
|
|
_restore(client, made["id"])
|
|
assert _visible_memories(client) == []
|
|
# Not deleted — still a row, still embedded as it was.
|
|
assert _memory_rows(client.adv_id) == 1
|
|
|
|
for _ in range(3):
|
|
_redo(client)
|
|
assert _visible_memories(client) == ["Mara learns the location of the key."]
|
|
assert _memory_rows(client.adv_id) == 1
|
|
|
|
|
|
def test_e01_an_old_futures_memory_stays_out_of_a_new_continuation(client):
|
|
"""After restore plus a divergent write, the displaced line's memory must not
|
|
become eligible on the line now being read."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
_turns(client, 3)
|
|
|
|
branch, depth = _head(client.adv_id)
|
|
db = SessionLocal()
|
|
try:
|
|
db.add(models.Memory(
|
|
adventure_id=client.adv_id,
|
|
text="Mara learns the location of the key.",
|
|
branch_id=branch, depth=depth,
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
_restore(client, made["id"])
|
|
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "go around the back")
|
|
|
|
assert _visible_memories(client) == []
|
|
# Play on: it must not reappear as the new line grows past the old depth.
|
|
ScriptedProvider.replies = gold_replies("New")
|
|
_turns(client, 3)
|
|
assert _visible_memories(client) == []
|
|
assert _memory_rows(client.adv_id) == 1
|
|
|
|
|
|
def test_e04_the_transcript_after_a_restore_holds_only_the_active_lineage(client):
|
|
"""Everything a read can see comes through the one capped path, so the
|
|
displaced continuation is absent from the transcript rather than filtered out
|
|
of it."""
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
ScriptedProvider.replies = gold_replies("Old")
|
|
_turns(client, 3)
|
|
displaced = [t for t in _texts(client) if t.startswith("Old")]
|
|
assert displaced
|
|
|
|
_restore(client, made["id"])
|
|
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "go around the back")
|
|
ScriptedProvider.replies = gold_replies("New")
|
|
_turns(client, 2)
|
|
|
|
told = _texts(client)
|
|
assert not any(t.startswith("Old") for t in told)
|
|
assert any(t.startswith("New") for t in told)
|
|
# The rows are still there; they are simply not on this path.
|
|
assert any(a.text.startswith("Old") for a in _rows(client.adv_id))
|
|
|
|
|
|
def _visible_memories(client) -> list[str]:
|
|
r = client.get(f"/api/adventures/{client.adv_id}/memories")
|
|
assert r.status_code == 200, r.text
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
path = lineage.path_of(db, adventure)
|
|
rows = (
|
|
db.query(models.Memory)
|
|
.filter(
|
|
models.Memory.adventure_id == client.adv_id,
|
|
path.clause(models.Memory),
|
|
)
|
|
.order_by(models.Memory.id)
|
|
.all()
|
|
)
|
|
return [m.text for m in rows]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _memory_rows(adv_id) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
return db.query(models.Memory).filter_by(adventure_id=adv_id).count()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------------ I04: export / import
|
|
|
|
def test_i04_named_save_points_survive_export_and_import(client):
|
|
_turns(client, 4)
|
|
_undo(client)
|
|
_undo(client)
|
|
early = _save(client, "Before entering the abbey", note="the door was locked")
|
|
_redo(client)
|
|
late = _save(client, "In the cloister")
|
|
told = _texts(client)
|
|
|
|
bundle = _export(client)
|
|
assert [c["name"] for c in bundle["checkpoints"]] == [
|
|
"Before entering the abbey", "In the cloister",
|
|
]
|
|
|
|
imported = _import(client, bundle)
|
|
copied = _list(client, adv_id=imported["id"])
|
|
|
|
assert sorted(c["name"] for c in copied) == [
|
|
"Before entering the abbey", "In the cloister",
|
|
]
|
|
by_name = {c["name"]: c for c in copied}
|
|
assert by_name["Before entering the abbey"]["note"] == "the door was locked"
|
|
# The coordinates point into the imported story, not the original's rows.
|
|
assert by_name["Before entering the abbey"]["depth"] == early["depth"]
|
|
assert by_name["In the cloister"]["depth"] == late["depth"]
|
|
assert all(c["resolved"] for c in copied)
|
|
assert {c["branch_id"] for c in copied} & {
|
|
b.id for b in _branch_rows(imported["id"])
|
|
} == {c["branch_id"] for c in copied}
|
|
|
|
|
|
def test_i04_an_import_opens_where_the_bundle_was_read_not_at_a_save_point(client):
|
|
"""A Save Point in the file is a position someone named, not the position the
|
|
campaign is read at. The head comes from `headDepth`, as it did before M4."""
|
|
_turns(client, 5)
|
|
_undo(client)
|
|
_undo(client)
|
|
_save(client, "Way back at the start") # made here, then the head moves on
|
|
_redo(client)
|
|
undone_story = _texts(client)
|
|
|
|
imported = _import(client, _export(client))
|
|
|
|
assert _story_of(imported["id"]) == undone_story
|
|
assert _adventure(client, adv_id=imported["id"])["can_redo"] is True
|
|
# And the Save Point arrived pointing somewhere else entirely.
|
|
saved = _list(client, adv_id=imported["id"])[0]
|
|
assert saved["depth"] < _head(imported["id"])[1]
|
|
|
|
|
|
def test_i04_an_imported_save_point_restores_in_the_new_campaign(client):
|
|
_turns(client, 2)
|
|
made = _save(client, "Before entering the abbey")
|
|
at_save = _texts(client)
|
|
_turns(client, 3)
|
|
|
|
imported = _import(client, _export(client))
|
|
copied = _list(client, adv_id=imported["id"])[0]
|
|
assert copied["id"] != made["id"]
|
|
|
|
r = _restore(client, copied["id"], adv_id=imported["id"])
|
|
assert r.status_code == 200, r.text
|
|
assert _story_of(imported["id"]) == at_save
|
|
# The original campaign did not move.
|
|
assert len(_texts(client)) == 11
|
|
|
|
|
|
def test_a_bundle_written_before_m4_imports_with_no_save_points(client):
|
|
"""Backward compatibility. A file with no `checkpoints` key is one written
|
|
when Save Points did not exist, and a campaign that had none is what it
|
|
records — so it opens, and it opens empty."""
|
|
_turns(client, 3)
|
|
told = _texts(client)
|
|
bundle = _export(client)
|
|
del bundle["checkpoints"]
|
|
|
|
imported = _import(client, bundle)
|
|
|
|
assert _story_of(imported["id"]) == told
|
|
assert _list(client, adv_id=imported["id"]) == []
|
|
|
|
|
|
def test_a_save_point_naming_a_turn_the_file_does_not_carry_is_dropped(client):
|
|
"""A bookmark pointing outside the story is dropped rather than refusing the
|
|
whole import. The head is checked the other way, because misplacing *it*
|
|
affects every read in the file."""
|
|
_turns(client, 2)
|
|
_save(client, "Real")
|
|
bundle = _export(client)
|
|
bundle["checkpoints"].append(
|
|
{"name": "Imaginary", "note": "", "branch": 0, "depth": 999}
|
|
)
|
|
bundle["checkpoints"].append({"name": " ", "branch": 0, "depth": 1})
|
|
bundle["checkpoints"].append({"name": "No branch", "branch": 77, "depth": 1})
|
|
|
|
imported = _import(client, bundle)
|
|
|
|
assert [c["name"] for c in _list(client, adv_id=imported["id"])] == ["Real"]
|
|
|
|
|
|
def test_save_points_on_two_branches_survive_the_round_trip(client):
|
|
_turns(client, 2)
|
|
shared = _save(client, "The fork")
|
|
_turns(client, 2)
|
|
old_line = _save(client, "Down the old road")
|
|
|
|
_restore(client, shared["id"])
|
|
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
|
|
_play(client, "go around the back")
|
|
new_line = _save(client, "Down the new road")
|
|
|
|
imported = _import(client, _export(client))
|
|
copied = _list(client, adv_id=imported["id"])
|
|
|
|
assert sorted(c["name"] for c in copied) == [
|
|
"Down the new road", "Down the old road", "The fork",
|
|
]
|
|
# Three names, and they did not all collapse onto one branch.
|
|
assert len({c["branch_id"] for c in copied}) == 2
|
|
assert all(c["resolved"] for c in copied)
|
|
del old_line, new_line
|
|
|
|
|
|
def _branch_rows(adv_id):
|
|
db = SessionLocal()
|
|
try:
|
|
return db.query(models.Branch).filter_by(adventure_id=adv_id).all()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------- L03: reconstruction after restart
|
|
|
|
def test_l03_a_save_point_restores_the_right_state_after_a_restart(client):
|
|
_turns(client, 3)
|
|
banked = _gold(client.adv_id)
|
|
told = _texts(client)
|
|
made = _save(client, "Before entering the abbey")
|
|
|
|
_turns(client, 4)
|
|
assert _gold(client.adv_id) == banked + 4 * GOLD_PER_TURN
|
|
|
|
after = _restart(client)
|
|
try:
|
|
r = _restore(after, made["id"])
|
|
assert r.status_code == 200, r.text
|
|
assert _texts(after) == told
|
|
assert _gold(after.adv_id) == banked
|
|
# And the later history is still all there.
|
|
assert len(_rows(after.adv_id)) == 1 + 2 * 7
|
|
finally:
|
|
after.close()
|
|
|
|
|
|
def test_l03_the_save_point_list_is_rebuilt_from_rows_alone(client):
|
|
_turns(client, 2)
|
|
first = _save(client, "One", note="a note")
|
|
_turns(client, 2)
|
|
second = _save(client, "Two")
|
|
|
|
after = _restart(client)
|
|
try:
|
|
kept = _list(after)
|
|
assert [c["name"] for c in kept] == ["Two", "One"] # newest first
|
|
assert kept[1]["note"] == "a note"
|
|
assert [c["id"] for c in kept] == [second["id"], first["id"]]
|
|
assert all(c["resolved"] for c in kept)
|
|
finally:
|
|
after.close()
|
|
|
|
|
|
# ---------------------------------------------------- the schema M4 adds
|
|
|
|
def test_an_m3_database_gains_the_save_point_table_and_keeps_its_story():
|
|
"""An M3 campaign database opens under M4 with no Save Points and no loss.
|
|
|
|
The table is created by `create_all`, on existing databases as well as fresh
|
|
ones, exactly as `memories` and `branches` were before it; migration 80 adds
|
|
the index. What this proves is that a database stamped at M3's version — one
|
|
that has never seen a `checkpoints` table — reaches M4's version with the
|
|
table, the index, and every row it already had.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
from sqlalchemy import create_engine, inspect, text
|
|
from sqlalchemy.orm import sessionmaker
|
|
from app import migrations
|
|
|
|
path = os.path.join(tempfile.mkdtemp(), "m3.db")
|
|
m3 = create_engine(f"sqlite:///{path}")
|
|
Base.metadata.create_all(bind=m3)
|
|
# A campaign written by M3. Built through the models so that the columns it
|
|
# carries are whatever the application writes, rather than a list this test
|
|
# would have to keep current.
|
|
session = sessionmaker(bind=m3)()
|
|
try:
|
|
owner = models.User(is_guest=False, email="m3@example.com")
|
|
session.add(owner)
|
|
session.flush()
|
|
session.add(models.Adventure(user_id=owner.id, title="Old", head_depth=2))
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
with m3.begin() as conn:
|
|
# Now make it an M3 *schema*: no Save Points, stamped at M3's version.
|
|
conn.execute(text("DROP TABLE checkpoints"))
|
|
conn.execute(text("PRAGMA user_version = 79"))
|
|
assert "checkpoints" not in inspect(m3).get_table_names()
|
|
|
|
migrations.bootstrap(m3)
|
|
|
|
insp = inspect(m3)
|
|
assert "checkpoints" in insp.get_table_names()
|
|
assert {c["name"] for c in insp.get_columns("checkpoints")} == {
|
|
"id", "adventure_id", "name", "note", "branch_id", "depth",
|
|
"created_at", "updated_at",
|
|
}
|
|
assert "ix_checkpoints_adventure" in {i["name"] for i in insp.get_indexes("checkpoints")}
|
|
with m3.connect() as conn:
|
|
assert conn.execute(text("PRAGMA user_version")).scalar() == 80
|
|
# No Save Points were invented for a campaign whose owner named none...
|
|
assert conn.execute(text("SELECT COUNT(*) FROM checkpoints")).scalar() == 0
|
|
# ...and the campaign it already had is untouched.
|
|
assert conn.execute(text("SELECT title, head_depth FROM adventures")).one() == ("Old", 2)
|
|
|
|
# Running it again changes nothing.
|
|
migrations.bootstrap(m3)
|
|
assert "checkpoints" in inspect(m3).get_table_names()
|
|
m3.dispose()
|