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:
committed by
Parth
co-authored by
Claude Opus 5
parent
e0bf2b61d9
commit
f1bebe18d0
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}.",
|
||||
))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user