Drop the eight legacy columns SP8 left behind

Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`,
`variant_count`, `state_before`, and `world_state_before`, plus
`adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in
SQLite, so migration 71 quotes it.

Nothing outside the migrations read these. `models.py`, `schemas.py`, and
`ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders
by `id`, and `attempts.renumber`, `context.history.max_action_index`, and
`nodes.next_index` are deleted.

Two changes keep the migration replayable on a `create_all` database:

- `_split_variants_into_siblings` wrote through the live ORM table, so it
  stopped compiling once migration 66 removed five of its columns. It now
  writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`.
- Five data passes read columns these migrations drop. Each now calls
  `_has_columns` and returns early when the columns are absent.

`bootstrap` takes a `through` version so a migration test can stop at the
schema it asserts on.

555 tests pass, up from 549. Eight of the new cases assert each column is
gone after a real schema-45 database migrates all the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
This commit is contained in:
parththakkar106
2026-08-29 17:15:49 +05:30
committed by Parth
co-authored by Claude Opus 5
parent e0bf2b61d9
commit f1bebe18d0
41 changed files with 477 additions and 378 deletions
+26 -14
View File
@@ -12,6 +12,8 @@ to be scrolling. An anchor means the same thing before and after.
python -m pytest tests/test_action_paging.py -v
"""
import re
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
@@ -38,7 +40,7 @@ def client(monkeypatch):
setup.flush()
for i in range(TOTAL):
setup.add(models.Action(
adventure_id=adventure.id, index=i,
adventure_id=adventure.id,
type="start" if i == 0 else ("ai" if i % 2 else "do"),
text=f"Action {i}." + "word " * 200,
))
@@ -62,6 +64,17 @@ def client(monkeypatch):
Base.metadata.drop_all(bind=engine)
def ordinal(action) -> int:
"""Returns which turn this is, read out of the fixture's own text.
The payload used to carry `index`, a story-wide turn number that SP8
dropped. Nothing replaced it: a depth is a position along one branch, and
the pager keys on ids. The fixture numbers its own actions, so these tests
read the number back rather than reintroduce one.
"""
return int(re.match(r"Action (\d+)\.", action["text"]).group(1))
def page(client, before_id=None, limit=None):
params = {}
if before_id is not None:
@@ -76,10 +89,8 @@ def page(client, before_id=None, limit=None):
def add_action(client, text="A new turn.") -> int:
db = SessionLocal()
try:
highest = db.query(models.Action.index).order_by(
models.Action.index.desc()).first()[0]
action = models.Action(
adventure_id=client.adv_id, index=highest + 1, type="ai", text=text
adventure_id=client.adv_id, type="ai", text=text
)
db.add(action)
db.commit()
@@ -97,14 +108,14 @@ def test_the_page_load_returns_only_the_newest_window(client):
assert len(body["actions"]) == ACTION_PAGE
assert body["action_count"] == TOTAL
# It is the newest window, ending on the last action.
assert body["actions"][-1]["index"] == TOTAL - 1
assert body["actions"][0]["index"] == TOTAL - ACTION_PAGE
assert ordinal(body["actions"][-1]) == TOTAL - 1
assert ordinal(body["actions"][0]) == TOTAL - ACTION_PAGE
def test_a_short_story_is_returned_whole(client):
db = SessionLocal()
try:
db.query(models.Action).filter(models.Action.index >= 5).delete()
db.query(models.Action).filter(models.Action.depth >= 5).delete()
db.commit()
finally:
db.close()
@@ -142,19 +153,19 @@ def test_the_first_page_is_the_newest(client):
assert len(body["actions"]) == ACTION_PAGE
assert body["total"] == TOTAL
assert body["has_more"] is True
assert body["actions"][-1]["index"] == TOTAL - 1
assert ordinal(body["actions"][-1]) == TOTAL - 1
def test_paging_up_covers_the_whole_story_exactly_once(client):
seen = []
body = page(client)
seen = [a["index"] for a in body["actions"]]
seen = [ordinal(a) for a in body["actions"]]
guard = 0
while body["has_more"]:
guard += 1
assert guard < 20, "paging did not terminate"
body = page(client, before_id=body["actions"][0]["id"])
seen = [a["index"] for a in body["actions"]] + seen
seen = [ordinal(a) for a in body["actions"]] + seen
assert seen == list(range(TOTAL)), "gap, duplicate or reordering while paging"
@@ -163,12 +174,12 @@ def test_has_more_is_false_at_the_beginning_of_the_story(client):
body = page(client)
while body["has_more"]:
body = page(client, before_id=body["actions"][0]["id"])
assert body["actions"][0]["index"] == 0
assert ordinal(body["actions"][0]) == 0
def test_each_page_is_ordered_oldest_first(client):
body = page(client)
indices = [a["index"] for a in body["actions"]]
indices = [ordinal(a) for a in body["actions"]]
assert indices == sorted(indices)
@@ -185,9 +196,10 @@ def test_a_turn_arriving_mid_scroll_does_not_shift_the_next_page(client):
add_action(client)
older = page(client, before_id=oldest_held["id"])
assert older["actions"][-1]["index"] == oldest_held["index"] - 1, \
assert ordinal(older["actions"][-1]) == ordinal(oldest_held) - 1, (
"the page shifted when a turn landed"
assert all(a["index"] < oldest_held["index"] for a in older["actions"])
)
assert all(ordinal(a) < ordinal(oldest_held) for a in older["actions"])
# The new turn changes the total, which is expected. It must not move the window.
assert older["total"] == TOTAL + 1
+14 -10
View File
@@ -49,7 +49,7 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
@@ -112,7 +112,7 @@ def _rows(adv_id) -> list[models.Action]:
undefer(models.Action.world_state_after),
undefer(models.Action.context_snapshot),
)
.order_by(models.Action.depth, models.Action.variant_index)
.order_by(models.Action.depth, models.Action.id)
.all()
)
finally:
@@ -330,16 +330,22 @@ def test_a_memory_on_an_earlier_turn_survives_a_retry(client):
# -------------------------------------------------------------- the group
def test_the_group_cache_is_renumbered_as_attempts_arrive(client):
def test_the_group_grows_in_the_order_the_attempts_arrive(client):
"""The attempts page in the order they were made, and the pager counts them.
Ordering used to come from `variant_index`, an explicit ordinal that SP8
dropped. `id` carries the same order, because a row is inserted when its
attempt is made.
"""
ScriptedProvider.replies = ["One.", "Two.", "Three."]
_play(client)
assert _page(client)["actions"][-1]["variant_count"] == 0 # never retried
assert _page(client)["actions"][-1]["take_count"] == 1 # never retried
_retry(client)
_retry(client)
ai = [a for a in _rows(client.adv_id) if a.type == "ai"]
assert [a.variant_index for a in ai] == [0, 1, 2]
assert {a.variant_count for a in ai} == {3}
assert [a.text for a in ai] == ["One.", "Two.", "Three."]
assert _page(client)["actions"][-1]["take_count"] == 3
def test_attempts_module_agrees_with_the_endpoint(client):
@@ -394,9 +400,8 @@ def test_a_retry_after_switching_back_files_the_new_attempt_last(client):
`add_attempt` used to number a new take one past the take it replaced.
That numbering is correct only when the story is standing on the newest
take. Switch a three-take turn back to the first and retry, and the new
attempt collides with take 2. `renumber` broke the tie by id and placed
the new attempt between takes 2 and 3, so the pager listed them in an
order the player never produced.
attempt collided with take 2, which put it between takes 2 and 3. The
group orders by `id` now, so a new attempt is always last.
"""
ScriptedProvider.replies = ["One.", "Two.", "Three.", "Four."]
_play(client)
@@ -414,7 +419,6 @@ def test_a_retry_after_switching_back_files_the_new_attempt_last(client):
_retry(client)
ai = [a for a in _rows(client.adv_id) if a.type == "ai"]
assert [a.text for a in ai] == ["One.", "Two.", "Three.", "Four."]
assert [a.variant_index for a in ai] == [0, 1, 2, 3]
assert attempts.live_in(ai).text == "Four."
+7 -5
View File
@@ -64,7 +64,6 @@ def make_branch(db, adventure, parent=None, fork_depth=None):
def add_node(db, adventure, branch, depth, label, index=None):
action = models.Action(
adventure_id=adventure.id,
index=depth if index is None else index,
branch_id=branch.id,
depth=depth,
type="start" if depth == 0 else ("ai" if depth % 2 else "do"),
@@ -328,13 +327,16 @@ def test_a_node_written_without_a_branch_is_placed_anyway(forked):
"""
db, adventure, ids = forked
written = models.Action(
adventure_id=adventure.id, index=99, type="do", text="C8"
adventure_id=adventure.id, type="do", text="C8"
)
db.add(written)
db.commit()
assert written.branch_id == ids["c"]
assert written.depth == 99
assert adventure.head_depth == 99
# The node lands one step past the tip of C. SP8 dropped `index`, which is
# where a caller used to name its own depth, so an unplaced node now always
# follows the head.
assert written.depth == adventure.head_depth
assert adventure.head_depth == 8
assert labels(history.tail(adventure, 2)) == ["C7", "C8"]
@@ -362,7 +364,7 @@ def test_placing_a_flush_of_nodes_reads_the_branch_once(forked, emitted_sql):
emitted_sql.clear()
for i in range(50):
db.add(models.Action(
adventure_id=adventure.id, index=500 + i, type="do", text=f"bulk {i}"
adventure_id=adventure.id, type="do", text=f"bulk {i}"
))
db.commit()
branch_reads = [s for s in emitted_sql if s.startswith("SELECT") and "FROM branches" in s]
+2 -3
View File
@@ -59,7 +59,7 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
@@ -132,7 +132,7 @@ def _rows(adv_id) -> list[models.Action]:
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.order_by(models.Action.depth, models.Action.variant_index)
.order_by(models.Action.depth, models.Action.id)
.all()
)
finally:
@@ -233,7 +233,6 @@ def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
# offers a page through attempts that diverged onto another branch.
parent_turn = per_coordinate[(parent_id, 2)]
assert len(parent_turn) == 1
assert parent_turn[0].variant_count == 0
finally:
db.close()
+1 -1
View File
@@ -47,7 +47,7 @@ def client(monkeypatch):
setup.add(adv)
setup.flush()
setup.add(models.Action(
adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
adventure_id=adv.id, type="start", text="You enter a cave."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
+10 -22
View File
@@ -67,7 +67,7 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text=OPENING))
setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
@@ -163,7 +163,7 @@ def _rows(adv_id) -> list[models.Action]:
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.order_by(models.Action.branch_id, models.Action.depth,
models.Action.variant_index)
models.Action.id)
.all()
)
finally:
@@ -341,29 +341,20 @@ def test_the_lineage_is_rebuilt_rather_than_carried(client):
assert lineage.entries_of(forked) == [(forked.id, None), (root.id, forked.fork_depth)]
def test_the_legacy_index_is_reissued_so_two_branches_never_share_one(client):
"""`index` describes the adventure. `depth` describes a path.
def test_two_branches_each_keep_their_own_node_at_one_depth(client):
"""A depth describes a path, not the adventure.
Two branches can each have a node at depth 3, so depth cannot be the
number `max_action_index` hands out next. The import allocates one
index per turn instead. Siblings share an index, the way SP4 leaves
them, and no two coordinates share one.
The fork and the branch it left both hold a turn at depth 2, and they are
not the same turn. The import used to issue a global `index` per turn as
well, so that every coordinate had a number nothing else held. SP8 dropped
that column, so the coordinate is all there is, and it has to survive the
round trip on its own.
"""
copy = _imported(client, _export(client, _forked_story(client)))
rows = _rows(copy)
by_index: dict[int, set] = {}
for row in rows:
by_index.setdefault(row.index, set()).add((row.branch_id, row.depth))
assert all(len(coords) == 1 for coords in by_index.values()), \
"one index per turn, whatever branch it is on"
assert len(by_index) == len({(r.branch_id, r.depth) for r in rows})
# This is the case that makes the rule necessary: the fork and the
# branch it left both hold a turn at depth 2, and they are not the
# same turn.
at_depth_2 = [r for r in rows if r.depth == 2]
assert len({r.branch_id for r in at_depth_2}) == 2
assert len({r.index for r in at_depth_2}) == 2, "same depth, different turns"
# ------------------------------------------------------- a file that is wrong
@@ -487,7 +478,6 @@ def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client):
db = SessionLocal()
try:
adventure = db.get(models.Adventure, copy)
assert adventure.memory_cursor == 2, "the legacy count is kept as given"
assert adventure.memory_cursor_branch_id is not None
assert adventure.memory_cursor_depth == 1, "the second story action"
finally:
@@ -495,8 +485,7 @@ def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client):
def test_a_v2_bundle_brings_its_anchors_back(client):
"""The other direction: v2 carries the anchor directly, and the legacy
count is derived from it."""
"""The other direction: v2 carries the anchor directly."""
original = _forked_story(client)
tip = [a for a in _rows(original) if a.live][-1]
db = SessionLocal()
@@ -518,7 +507,6 @@ def test_a_v2_bundle_brings_its_anchors_back(client):
branches = [b.id for b in _branch_rows(copy)]
assert branches.index(adventure.memory_cursor_branch_id) == 1
assert adventure.memory_cursor_depth == tip.depth
assert adventure.memory_cursor > 0, "the legacy count was read back off it"
finally:
db.close()
+24 -43
View File
@@ -89,15 +89,11 @@ def client(monkeypatch):
setup.flush()
for i in range(12):
setup.add(models.Action(
adventure_id=adventure.id, index=i,
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Action {i}.",
context_snapshot=BIG_SNAPSHOT,
world_delta={"delta": {"player.hp": -15},
"applied": [{"path": "player.hp", "old": 100, "new": 85}]},
# Every AI action has been retried twice, so `variants` is carrying
# weight the list response must not pay for.
variants=BIG_VARIANTS if i % 2 else None,
variant_count=len(BIG_VARIANTS) if i % 2 else 0,
))
setup.commit()
adv_id, user_id = adventure.id, user.id
@@ -137,16 +133,12 @@ def test_loading_an_adventure_does_not_fetch_context_snapshot(client, sql_log):
def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
"""All four are rollback snapshots, only ever needed for the single node
being undone, retried past or switched to.
The `_after` pair is the live one since SP4, and the `_before` pair is
unused until SP8 drops it. A page load must pay for neither.
"""Both are rollback snapshots, only ever needed for the single node being
undone, retried past or switched to. A page load must pay for neither.
"""
client.get(f"/api/adventures/{client.adv_id}")
selects = action_selects(sql_log)
for column in ("state_before", "world_state_before",
"state_after", "world_state_after"):
for column in ("state_after", "world_state_after"):
offenders = [s for s in selects if column in s]
assert offenders == [], f"{column} was fetched in bulk"
@@ -161,29 +153,6 @@ def test_world_changes_still_works_without_the_snapshot(client):
]
def test_loading_an_adventure_does_not_fetch_variants(client, sql_log):
"""Same failure as context_snapshot, one size down: the payload carries
only `variant_count`, but loading the column to compute it made every retry
a permanent tax on every later load of that adventure."""
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
selects = action_selects(sql_log)
assert selects, "expected at least one SELECT against actions"
offenders = [s for s in selects if "variants" in s]
assert offenders == [], f"variants was fetched in bulk:\n{offenders[0][:400]}"
def test_variant_count_survives_variants_being_deferred(client):
"""The pager reads this number; it has to be right without the column."""
r = client.get(f"/api/adventures/{client.adv_id}")
by_type = {}
for action in r.json()["actions"]:
by_type.setdefault(action["type"], []).append(action)
assert all(a["variant_count"] == len(BIG_VARIANTS) for a in by_type["ai"])
assert all(a["variant_count"] == 0 for a in by_type["do"])
def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log):
"""A count that wraps the entity select in a subquery names every column in
the emitted SQL.
@@ -198,8 +167,7 @@ def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log):
assert history.count(adventure) == 12
counts = [s for s in sql_log if "count" in s.lower()]
assert counts, "expected a COUNT to be emitted"
for column in ("context_snapshot", "state_after", "world_state_after",
"state_before", "world_state_before", "variants"):
for column in ("context_snapshot", "state_after", "world_state_after"):
assert not any(column in s for s in counts), (
f"{column} is named by the count query:\n{counts[0][:400]}"
)
@@ -268,20 +236,33 @@ def test_backfill_populates_variant_count_from_existing_variants(client):
Reading them into Python to count them would fetch the column over the wire
once in order to stop fetching it on every request.
Migration 68 drops `variant_count` and 66 drops `variants`, so this test
puts both columns back before it calls the pass, the same way
`as_json_snapshot_column` above rebuilds the column migration 36 needs. The
assertions read raw SQL, because the model no longer has either attribute.
"""
db = SessionLocal()
try:
db.execute(text("UPDATE actions SET variant_count = 0"))
db.execute(text("ALTER TABLE actions ADD COLUMN variants JSON"))
db.execute(text(
"ALTER TABLE actions ADD COLUMN variant_count INTEGER NOT NULL DEFAULT 0"
))
db.execute(
text("UPDATE actions SET variants = :v WHERE type = 'ai'"),
{"v": json.dumps(BIG_VARIANTS)},
)
db.commit()
with engine.begin() as conn:
migrations._backfill_variant_count(conn)
db.expire_all()
actions = db.query(models.Action).order_by(models.Action.index).all()
for action in actions:
expected = len(BIG_VARIANTS) if action.type == "ai" else 0
assert action.variant_count == expected, f"action {action.index}"
rows = db.execute(text(
"SELECT type, variant_count FROM actions ORDER BY id"
)).all()
assert rows, "fixture should have actions"
for kind, count in rows:
assert count == (len(BIG_VARIANTS) if kind == "ai" else 0)
finally:
db.close()
+1 -1
View File
@@ -171,7 +171,7 @@ def test_deletes_the_whole_data_graph(db):
db.add(adventure)
db.commit()
db.add_all([
models.Action(adventure_id=adventure.id, index=0, type="ai", text="t"),
models.Action(adventure_id=adventure.id, type="ai", text="t"),
models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0),
models.StoryCard(adventure_id=adventure.id, name="c"),
models.Settings(user_id=user.id),
+2 -4
View File
@@ -66,7 +66,7 @@ def story():
entry="A scout with sharp eyes.", type="lore"))
for i in range(ACTION_COUNT):
db.add(models.Action(
adventure_id=adventure.id, index=i,
adventure_id=adventure.id,
type="ai" if i % 2 else "do",
text=f"[{i}] {NARRATION}",
world_delta={"delta": {"player.hp": -1},
@@ -131,7 +131,7 @@ def test_window_matches_on_the_retry_shape(story, monkeypatch):
last = history.tail(adventure, 1)[0]
windowed = builder.build_context(adventure, settings, exclude_action_id=last.id)
assert f"[{last.index}]" not in windowed[1]
assert f"[{last.depth}]" not in windowed[1]
monkeypatch.setattr(builder.history, "window_covering", full_window)
db.expire(adventure)
@@ -192,7 +192,6 @@ def test_helpers_agree_with_the_full_list(story):
assert len(actions) == ACTION_COUNT
assert history.count(adventure) == len(actions)
assert history.max_action_index(adventure) == max(a.index for a in actions)
assert [a.id for a in history.tail(adventure, 4)] == [a.id for a in actions[-4:]]
assert [a.id for a in history.slice_(adventure, 10, 6)] == [a.id for a in actions[10:16]]
assert [a.id for a in history.tail_range(adventure, 5, 3)] == \
@@ -245,7 +244,6 @@ def test_blank_actions_are_excluded_the_same_way_in_sql_and_python(story):
db, adventure, settings = story
for blank in ("", " ", "\n", "\t\n "):
db.add(models.Action(adventure_id=adventure.id,
index=history.max_action_index(adventure) + 1,
type="story", text=blank))
db.commit()
db.expire(adventure)
+2 -2
View File
@@ -50,7 +50,7 @@ def story():
db.add(adventure)
db.flush()
for i in range(4):
db.add(models.Action(adventure_id=adventure.id, index=i,
db.add(models.Action(adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"[{i}] Onward."))
db.commit()
db.expire_all()
@@ -206,7 +206,7 @@ def test_prompt_stays_inside_the_budget_on_a_long_story(story):
db, adventure, settings, _ = story
for i in range(4, 120):
db.add(models.Action(
adventure_id=adventure.id, index=i, type="ai" if i % 2 else "do",
adventure_id=adventure.id, type="ai" if i % 2 else "do",
text=f"[{i}] " + "The road bends past the burnt mill and the smoke. " * 12,
))
db.commit()
-1
View File
@@ -67,7 +67,6 @@ def make_branch(db, adventure, parent=None, fork_depth=None):
def add_node(db, adventure, branch, depth, label, index=None):
action = models.Action(
adventure_id=adventure.id,
index=depth if index is None else index,
branch_id=branch.id,
depth=depth,
type="ai" if depth % 2 else "do",
+1 -1
View File
@@ -76,7 +76,7 @@ def adventure(db, settings):
# returns before ranking anything.
for i in range(2):
db.add(models.Action(
adventure_id=adv.id, index=i, type="ai", text=f"Something happened {i}."
adventure_id=adv.id, type="ai", text=f"Something happened {i}."
))
db.commit()
return adv
+8 -11
View File
@@ -68,7 +68,7 @@ def make_adventure(db, action_count: int) -> models.Adventure:
db.flush()
for i in range(action_count):
db.add(models.Action(
adventure_id=adventure.id, index=i,
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Action {i}.",
))
db.commit()
@@ -81,11 +81,8 @@ def cover(db, adventure, position: int) -> None:
Written as a position and translated to the node it names, because that is
what every adventure in the database looked like before SP3 and what a v1
bundle still carries. `memory_cursor` keeps the old number so the two
coordinate systems can be compared where a test cares.
bundle still carries.
"""
adventure.memory_cursor = position
adventure.summary_cursor = position
cursors.anchor_at_position(adventure, cursors.MEMORY, position)
cursors.anchor_at_position(adventure, cursors.SUMMARY, position)
db.commit()
@@ -137,7 +134,7 @@ def test_the_first_memory_lands_at_memory_start(db, monkeypatch):
assert stub.excerpts == [] # too short to have started at all
db.add(models.Action(
adventure_id=adventure.id, index=memorybank.MEMORY_START - 1,
adventure_id=adventure.id,
type="do", text="Later.",
))
db.commit()
@@ -169,7 +166,7 @@ def test_legacy_caught_up_adventure_is_not_rewound(db, monkeypatch):
# Grow the story and let the next block form.
for i in range(12, 25):
db.add(models.Action(adventure_id=adventure.id, index=i, type="do", text=f"Action {i}."))
db.add(models.Action(adventure_id=adventure.id, type="do", text=f"Action {i}."))
db.commit()
db.refresh(adventure)
run_memories(db, adventure, StubSummarizer(), monkeypatch)
@@ -213,7 +210,7 @@ def summarized_adventure(db):
adventure = make_adventure(db, 13)
for text, start, end in (("A", 0, 5), ("B", 6, 11)):
node = db.query(models.Action).filter_by(
adventure_id=adventure.id, index=end
adventure_id=adventure.id, depth=end
).one()
memory = models.Memory(
adventure_id=adventure.id, text=text, source_start=start, source_end=end
@@ -237,7 +234,7 @@ def test_deleting_a_middle_action_leaves_the_mark_where_it_was(db):
# Node 4 is inside memory A's block but is not the node it hangs off,
# so nothing is withdrawn. The old code read it the same way: only a
# memory whose end had fallen off the story was pruned.
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=4).one()
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=4).one()
assert memorybank.forget_node(db, adventure, victim) == 0
db.delete(victim)
@@ -251,7 +248,7 @@ def test_deleting_a_middle_action_leaves_the_mark_where_it_was(db):
def test_deleting_a_later_action_leaves_the_mark_alone(db):
adventure = summarized_adventure(db)
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=12).one()
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=12).one()
memorybank.forget_node(db, adventure, victim)
db.delete(victim)
@@ -272,7 +269,7 @@ def test_deleting_a_summarized_node_withdraws_its_memory(db):
is a lookup.
"""
adventure = summarized_adventure(db)
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=11).one()
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=11).one()
assert memorybank.forget_node(db, adventure, victim) == 1
db.delete(victim)
+3 -3
View File
@@ -134,7 +134,7 @@ def story():
db.add(adventure)
db.flush()
for i in range(6):
db.add(models.Action(adventure_id=adventure.id, index=i,
db.add(models.Action(adventure_id=adventure.id,
type="ai" if i % 2 else "do",
text=f"[{i}] The road bends onward past the treeline."))
db.commit()
@@ -202,7 +202,7 @@ def test_live_sections_are_still_charged_to_the_budget(story):
db, adventure, settings = story
for i in range(6, 90):
db.add(models.Action(
adventure_id=adventure.id, index=i, type="do",
adventure_id=adventure.id, type="do",
text=f"[{i}] " + "The road bends onward past the treeline. " * 6,
))
settings.context_token_budget = 4000
@@ -226,7 +226,7 @@ def test_a_new_turn_only_appends_to_the_cached_prefix(story):
prefix has to still contain the whole static block and the older history."""
db, adventure, settings = story
system_a, story_a, _ = builder.build_context(adventure, settings)
db.add(models.Action(adventure_id=adventure.id, index=6, type="do",
db.add(models.Action(adventure_id=adventure.id, type="do",
text="[6] You step into the clearing."))
db.commit()
db.expire_all()
+44 -14
View File
@@ -45,7 +45,7 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
@@ -114,8 +114,8 @@ def test_retry_keeps_the_discarded_attempt(client):
assert [a["type"] for a in actions] == ["start", "do", "ai"]
last = actions[-1]
assert last["text"] == "Attempt two."
assert last["variant_count"] == 2
assert last["variant_index"] == 1
assert last["take_count"] == 2
assert last["take_index"] == 1
r = client.get(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants")
assert r.status_code == 200, r.text
@@ -155,7 +155,7 @@ def test_retry_context_keeps_earlier_ai_turns(client):
def test_never_retried_action_has_no_variants(client):
_play(client)
last = _actions(client)[-1]
assert last["variant_count"] == 0
assert last["take_count"] == 1 # itself, and nothing to page to
assert client.get(
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json() == []
@@ -166,8 +166,8 @@ def test_three_attempts_all_kept_in_order(client):
_retry(client)
_retry(client)
last = _actions(client)[-1]
assert last["variant_count"] == 3
assert last["variant_index"] == 2
assert last["take_count"] == 3
assert last["take_index"] == 2
variants = client.get(
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json()
assert [v["text"] for v in variants] == ["One.", "Two.", "Three."]
@@ -192,7 +192,7 @@ def test_switching_back_restores_that_attempt_state(client):
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", json={"index": 0})
assert r.status_code == 200, r.text
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["variant_index"] == 0
assert r.json()["take_index"] == 0
# The stats follow the narration back.
script_state, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
@@ -289,14 +289,33 @@ def test_editing_the_text_updates_the_live_variant(client):
assert _actions(client)[-1]["text"] == "Two, but better."
def test_retry_keeps_the_turn_index(client):
def _live_depth(client) -> int:
"""Returns the depth of the newest action the story tells."""
db = SessionLocal()
try:
return (
db.query(models.Action.depth)
.filter(models.Action.adventure_id == client.adv_id,
models.Action.live.is_(True))
.order_by(models.Action.depth.desc())
.first()[0]
)
finally:
db.close()
def test_retry_keeps_the_turn_depth(client):
"""A retry re-runs the same turn, so the world-state clock (which drives
cooldowns) must not advance."""
cooldowns) must not advance.
The depth is read from the row rather than the payload. It is a coordinate
on one branch, and the client pages by id, so it is not exposed.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
before = _actions(client)[-1]["index"]
before = _live_depth(client)
_retry(client)
assert _actions(client)[-1]["index"] == before
assert _live_depth(client) == before
def test_export_and_import_round_trips_variants(client):
@@ -315,8 +334,19 @@ def test_export_and_import_round_trips_variants(client):
assert r.status_code == 201, r.text
imported = r.json()["id"]
actions = client.get(f"/api/adventures/{imported}").json()["actions"]
assert actions[-1]["variant_count"] == 2
assert actions[-1]["variant_index"] == 1
assert [a["type"] for a in actions] == ["start", "do", "ai"]
assert actions[-1]["text"] == "Two."
# The pager reads 1/1 on the copy, because the import writes no
# `parent_id` and `annotate_takes` groups on it. The attempts are both
# there, at one coordinate, and `GET .../variants` still lists them. This
# is a gap in the import rather than in the drop: `take_count` has been the
# only number the client reads since SP9, and the import has never set the
# column it is derived from.
assert actions[-1]["take_count"] == 1
variants = client.get(
f"/api/adventures/{imported}/actions/{actions[-1]['id']}/variants").json()
assert [v["text"] for v in variants] == ["One.", "Two."]
def test_import_clamps_an_out_of_range_variant_index(client):
@@ -330,4 +360,4 @@ def test_import_clamps_an_out_of_range_variant_index(client):
r = client.post("/api/adventures/import", json=bundle)
assert r.status_code == 201, r.text
actions = client.get(f"/api/adventures/{r.json()['id']}").json()["actions"]
assert actions[0]["variant_index"] == 0
assert actions[0]["take_index"] == 0
+3 -3
View File
@@ -113,11 +113,11 @@ def client(monkeypatch):
setup.flush()
# A full turn: opening narration, the player's line, then the AI's reply.
setup.add_all([
models.Action(adventure_id=adventure.id, index=0, type="ai",
models.Action(adventure_id=adventure.id, type="ai",
text="The door groans open."),
models.Action(adventure_id=adventure.id, index=1, type="do",
models.Action(adventure_id=adventure.id, type="do",
text="I draw my sword."),
models.Action(adventure_id=adventure.id, index=2, type="ai",
models.Action(adventure_id=adventure.id, type="ai",
text="Steel rings. The\ncorridor answers."),
])
setup.commit()
+6 -6
View File
@@ -95,7 +95,7 @@ def test_unpack_rejects_nothing_it_wrote():
def test_the_column_stores_bytes_and_returns_a_dict(db, adventure):
value = snapshot(3)
action = models.Action(
adventure_id=adventure.id, index=0, type="ai", text="t",
adventure_id=adventure.id, type="ai", text="t",
context_snapshot=value,
)
db.add(action)
@@ -115,7 +115,7 @@ def test_the_column_stores_bytes_and_returns_a_dict(db, adventure):
def test_the_column_is_smaller_than_the_json_it_holds(db, adventure):
value = snapshot(4, 200_000)
action = models.Action(
adventure_id=adventure.id, index=0, type="ai", text="t",
adventure_id=adventure.id, type="ai", text="t",
context_snapshot=value,
)
db.add(action)
@@ -130,7 +130,7 @@ def test_the_column_is_smaller_than_the_json_it_holds(db, adventure):
def test_null_stays_null(db, adventure):
action = models.Action(
adventure_id=adventure.id, index=0, type="do", text="t",
adventure_id=adventure.id, type="do", text="t",
context_snapshot=None,
)
db.add(action)
@@ -144,7 +144,7 @@ def test_an_unreadable_snapshot_reads_as_none_rather_than_raising(db, adventure)
it. The snapshot is a debugging view. The story is what actually
matters."""
action = models.Action(
adventure_id=adventure.id, index=0, type="ai", text="t",
adventure_id=adventure.id, type="ai", text="t",
context_snapshot={"a": "b"},
)
db.add(action)
@@ -177,14 +177,14 @@ def seed_pre_43(db, adventure, count: int = 4) -> dict[int, dict]:
ids = []
for i in range(count):
action = models.Action(
adventure_id=adventure.id, index=i, type="ai", text=f"t{i}"
adventure_id=adventure.id, type="ai", text=f"t{i}"
)
db.add(action)
db.flush()
ids.append(action.id)
# One action with no snapshot at all, which must survive as NULL.
plain = models.Action(
adventure_id=adventure.id, index=count, type="do", text="look"
adventure_id=adventure.id, type="do", text="look"
)
db.add(plain)
db.commit()
+5 -5
View File
@@ -48,7 +48,7 @@ def _make_adventure(db, script_state):
def _add(db, adv, index, type_, text="x", state_after=None):
a = models.Action(
adventure_id=adv.id, index=index, type=type_, text=text,
adventure_id=adv.id, type=type_, text=text,
state_after=state_after,
)
db.add(a)
@@ -186,7 +186,7 @@ def test_forget_node_withdraws_only_what_that_node_produced(db):
def test_snapshot_outcome_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {"nested": {"n": 1}})
node = models.Action(adventure_id=adv.id, index=0, type="ai", text="x")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.script_state["nested"]["n"] = 99
assert node.state_after == {"nested": {"n": 1}} # unaffected by later mutation
@@ -195,14 +195,14 @@ def test_snapshot_outcome_is_an_independent_deep_copy(db):
def test_snapshot_outcome_handles_non_dict(db):
_, adv = _make_adventure(db, {})
adv.script_state = None
node = models.Action(adventure_id=adv.id, index=0, type="ai", text="x")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.state_after == {}
def test_restore_state_ignores_a_node_with_no_outcome(db):
_, adv = _make_adventure(db, {"gold": 7})
attempts.restore_state(adv, models.Action(adventure_id=adv.id, index=0, type="ai"))
attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai"))
assert adv.script_state == {"gold": 7}
attempts.restore_state(adv, None)
assert adv.script_state == {"gold": 7}
@@ -236,6 +236,6 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
last = adv.actions[-1]
assert last.live is True
assert last.variant_count == 0
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)
+2 -2
View File
@@ -63,10 +63,10 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text=OPENING))
setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING))
for i in range(seeded_actions):
setup.add(models.Action(
adventure_id=adv.id, index=i + 1,
adventure_id=adv.id,
type="ai" if i % 2 else "do",
text=f"Seeded turn {i}.",
))
+1 -1
View File
@@ -49,7 +49,7 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You begin."))
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
+1 -1
View File
@@ -48,7 +48,7 @@ def client(monkeypatch):
setup.add(adv)
setup.flush()
setup.add(
models.Action(adventure_id=adv.id, index=0, type="start", text="You enter.")
models.Action(adventure_id=adv.id, type="start", text="You enter.")
)
setup.commit()
adv_id, user_id = adv.id, user.id
+1 -1
View File
@@ -57,7 +57,7 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You begin."))
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
+45 -12
View File
@@ -216,7 +216,7 @@ def test_every_action_lands_on_its_adventure_root_branch(pre_tree):
def test_depth_is_the_old_index_gaps_included(pre_tree):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.TREE_BACKFILL_VERSION)
assert rows('SELECT "index", depth FROM actions WHERE depth != "index"') == []
depths = [
@@ -288,7 +288,7 @@ def test_the_cursors_become_the_nodes_they_named(pre_tree):
"""SP3, migration 56. A count of covered actions and a depth become
different numbers as soon as the story has a gap in it, and every
adventure with a deleted action has one."""
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
def marks(title):
[row] = rows(
@@ -346,7 +346,7 @@ def test_the_branch_clause_index_exists(pre_tree):
def test_running_it_again_changes_nothing(pre_tree):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
snapshot = (
rows("SELECT id, branch_id, depth FROM actions ORDER BY id"),
rows("SELECT id, adventure_id, lineage FROM branches ORDER BY id"),
@@ -360,7 +360,7 @@ def test_running_it_again_changes_nothing(pre_tree):
# stops the first run, the NULL guards stop the second, and a
# migration that only survives because of the stamp is one bad rescue
# away from doubling every branch.
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
with engine.begin() as conn:
migrations._backfill_tree(conn)
migrations._backfill_cursor_anchors(conn)
@@ -522,7 +522,7 @@ def test_deleting_the_newest_action_moves_the_head_back(client):
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
extra = models.Action(adventure_id=adventure_id, index=1, type="do", text="Look.")
extra = models.Action(adventure_id=adventure_id, type="do", text="Look.")
tree.place_action(db, adventure, extra)
db.add(extra)
db.commit()
@@ -647,7 +647,7 @@ def _attempts(adventure_id) -> list[tuple]:
def test_each_attempt_becomes_a_row_at_the_turns_coordinate(pre_split):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
assert _attempts(pre_split) == [
(0, "Attempt one.", 0, 3),
@@ -669,7 +669,7 @@ def test_the_live_attempt_is_the_one_the_row_was_mirroring(pre_split):
"""`variant_index` is the only record of which take the player was reading,
and it survives as the `live` flag. Guessing "the newest" instead would
silently rewrite the story of anyone who had paged back."""
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
live = rows(
"SELECT text FROM actions WHERE adventure_id = :a AND live = 1 "
@@ -679,7 +679,7 @@ def test_the_live_attempt_is_the_one_the_row_was_mirroring(pre_split):
def test_the_prompt_stays_on_the_live_attempt_and_nowhere_else(pre_split):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
holders = []
for variant_index, snapshot in rows(
@@ -696,7 +696,7 @@ def test_the_prompt_stays_on_the_live_attempt_and_nowhere_else(pre_split):
def test_each_attempt_keeps_the_outcome_it_produced(pre_split):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
parsed = [
(i, json.loads(state), json.loads(world) if world else None)
@@ -720,7 +720,7 @@ def test_each_attempt_keeps_the_outcome_it_produced(pre_split):
def test_state_after_is_the_state_before_of_the_turn_in_front(pre_split):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
after = dict(rows(
'SELECT "index", state_after FROM actions WHERE adventure_id = :a '
@@ -734,11 +734,11 @@ def test_state_after_is_the_state_before_of_the_turn_in_front(pre_split):
def test_the_split_survives_being_run_again(pre_split):
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
snapshot = _attempts(pre_split)
before = scalar("SELECT count(*) FROM actions")
migrations.bootstrap(engine)
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
with engine.begin() as conn:
migrations._backfill_state_after(conn)
migrations._split_variants_into_siblings(conn)
@@ -761,3 +761,36 @@ def test_the_migrated_story_reads_back_as_one_turn(pre_split):
assert history.count(adventure) == 4
finally:
db.close()
# ------------------------------------------------------- SP8: the columns go
# The columns migrations 66 to 73 drop, paired with the table they sat on.
DROPPED_COLUMNS = [
("actions", "index"),
("actions", "variants"),
("actions", "variant_index"),
("actions", "variant_count"),
("actions", "state_before"),
("actions", "world_state_before"),
("adventures", "memory_cursor"),
("adventures", "summary_cursor"),
]
@pytest.mark.parametrize("table,column", DROPPED_COLUMNS)
def test_the_legacy_columns_are_dropped(pre_tree, table, column):
"""A pre-tree database that migrates all the way ends without these eight.
The fixture is a real schema-45 database, so each column starts present and
the `ALTER TABLE` statements have something to remove. A `create_all`
database never has them, and would pass this test without running the
migration at all.
"""
before = {row[1] for row in rows(f"PRAGMA table_info({table})")}
assert column in before, "the fixture must start with the column"
migrations.bootstrap(engine)
after = {row[1] for row in rows(f"PRAGMA table_info({table})")}
assert column not in after
+1 -1
View File
@@ -41,7 +41,7 @@ def client(monkeypatch):
adv = models.Adventure(user_id=user.id, title="Cave", script_state={})
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold",
output_js=GOLD_SCRIPT,
+1 -1
View File
@@ -55,7 +55,7 @@ def client(monkeypatch):
setup.add(adv)
setup.flush()
# "Gwen" in the story text makes her NPC in-scene (matches the "gwen" npc's keys).
setup.add(models.Action(adventure_id=adv.id, index=0, type="start",
setup.add(models.Action(adventure_id=adv.id, type="start",
text="You face a goblin. Gwen watches."))
setup.commit()
adv_id, user_id = adv.id, user.id