Files
interactive-story/backend/tests/test_bundle_v2.py
T
parththakkar106andClaude Opus 5 4af6e17406 Answer the review, and keep the opening node's bank
Nine findings from a review of the phase-14 stack. The one about a retry
withdrawing a memory is not a bug — a memory anchored to a node describes that
node, and it goes when the node goes. The root is the exception, and it is the
only one: migration 62 parked every memory written before memories had
coordinates on depth 0, so withdrawing the opening node would retire a whole
bank nobody attached there. A memory with no source range covers no story and
now stays; a summary that genuinely ends there is still withdrawn.

The rest are repairs.

* The adventure list quoted whichever attempt was written last rather than the
  one the story tells, so switching back left the index disagreeing with the
  page.
* A v1 import gave a typed memory no depth, rebuilding the NULL the migration
  exists to remove — invisible until the imported adventure forked.
* The action cap counted a v1 file's turns, and a turn expands into a row per
  saved attempt, so a file inside the cap could write a multiple of it.
* Forking a live node on a borrowed ancestor promoted a sibling on a branch the
  caller never named. It is a branch switch, and now says so.
* Switching attempts left the state, status and memory panels reading the
  previous take: the story does not change length, so nothing keyed on its
  length noticed. Same class as the branch-switch bug this phase already fixed.
* A retry after switching back numbered the new attempt into the middle of the
  group instead of the end.
* The cursor backfill numbered every action in the table once per adventure;
  correlated to the adventure being updated, it is an index lookup instead.
* Renaming a branch answered own_actions=0.

And one behaviour change recorded rather than repaired: script-visible history
and actionCount no longer count blank-text rows. That is the right shape and
there is no reading compatible with both, so plan/14 says so.

409 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30

609 lines
23 KiB
Python

"""Phase 14 SP6 — the export bundle carries the tree.
A bundle is the only part of this phase a migration can never reach: the file
is already on somebody's disk. So there are two formats, and the two halves of
this file watch different things.
**v2 has to be lossless for a story that forked**, which v1 could not be — it
had one list and there were two stories, so it interleaved them by `index` and
read as a mangled story. Losslessness here means the *tree*: every branch, the
fork point it left its parent at, which attempt at each turn is the story, and
what each node left behind — because that last one is what a branch switch puts
back, and a tree nobody can switch inside is not the tree that was exported.
**v1 has to still import**, because a backup that stops importing is not a
backup.
Underneath both is the rule the module is built on: a bundle carries what was
*chosen* and never what is *derived*. The lineage, the head depth, the legacy
`index` and the variant ordinals are all rebuilt on the way in, so a
hand-edited file cannot disagree with itself — and the tests that matter most
here are the ones that hand it a file which does.
python -m pytest tests/test_bundle_v2.py -v
"""
import os
import tempfile
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, bundle, limits, models
from app.context import lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
from app.routers import adventures
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
# Ten gold a turn, so the script scoreboard is a number that says how many turns
# the story behind it has — which makes an after-snapshot visible from outside.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
OPENING = "You enter a cave."
class ScriptedProvider:
replies: list = []
calls = 0
def __init__(self, *a, **k):
pass
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1)
ScriptedProvider.calls += 1
yield ("text", ScriptedProvider.replies[index])
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="bundle@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text=OPENING))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
adventures._active_turns.clear()
Base.metadata.drop_all(bind=engine)
# ------------------------------------------------------------------ helpers
def _play(client, adv_id, text="look around", type="do"):
r = client.post(f"/api/adventures/{adv_id}/actions", json={"type": type, "text": text})
assert r.status_code == 200, r.text
def _retry(client, adv_id):
assert client.post(f"/api/adventures/{adv_id}/retry").status_code == 200
def _export(client, adv_id) -> dict:
r = client.get(f"/api/adventures/{adv_id}/export")
assert r.status_code == 200, r.text
return r.json()
def _import(client, payload):
return client.post("/api/adventures/import", json=payload)
def _imported(client, payload) -> int:
r = _import(client, payload)
assert r.status_code == 201, r.text
return r.json()["id"]
def _branches(client, adv_id) -> list[dict]:
r = client.get(f"/api/adventures/{adv_id}/branches")
assert r.status_code == 200, r.text
return r.json()
def _switch(client, adv_id, branch_id):
r = client.post(f"/api/adventures/{adv_id}/branches/{branch_id}/switch")
assert r.status_code == 200, r.text
def _texts(client, adv_id) -> list[str]:
return [a["text"] for a in client.get(f"/api/adventures/{adv_id}").json()["actions"]]
def _every_branch_story(client, adv_id) -> list[list[str]]:
"""What each branch tells, in branch order — the whole tree as text."""
stories = []
for branch in _branches(client, adv_id):
_switch(client, adv_id, branch["id"])
stories.append(_texts(client, adv_id))
return stories
def _adventure_count() -> int:
db = SessionLocal()
try:
return db.query(models.Adventure).count()
finally:
db.close()
def _rows(adv_id) -> list[models.Action]:
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.order_by(models.Action.branch_id, models.Action.depth,
models.Action.variant_index)
.all()
)
finally:
db.close()
def _branch_rows(adv_id) -> list[models.Branch]:
db = SessionLocal()
try:
return (
db.query(models.Branch)
.filter(models.Branch.adventure_id == adv_id)
.order_by(models.Branch.id)
.all()
)
finally:
db.close()
def _script_state(adv_id) -> dict:
db = SessionLocal()
try:
return db.get(models.Adventure, adv_id).script_state
finally:
db.close()
def _forked_story(client) -> int:
"""A story that went two ways, and stayed both.
root: start · do · [attempt two] · do · next turn
fork: [ATTEMPT ONE] · do · elsewhere
The *discarded* attempt is the one that gets promoted, because a fork moves
the take you are leaving for and leaves the line you came from untouched.
Returns the adventure id, with the head on the fork.
"""
adv_id = client.adv_id
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn.", "Elsewhere."]
_play(client, adv_id)
_retry(client, adv_id)
_play(client, adv_id, "go deeper")
discarded = [a.id for a in _rows(adv_id) if a.type == "ai" and not a.live][0]
assert client.post(f"/api/adventures/{adv_id}/actions/{discarded}/fork").status_code == 200
_play(client, adv_id, "go sideways")
return adv_id
# ------------------------------------------------------- the round trip (v2)
def test_a_forked_story_survives_the_round_trip(client):
"""The headline: both futures come back, and both are still readable.
This is the thing v1 could not do. The check is not "the same rows" — the
ids are new — but "the same stories", read the way a player reads them: by
switching to a branch and looking at what it says.
"""
original = _forked_story(client)
before = _every_branch_story(client, original)
assert len(before) == 2, "the fixture forked"
assert before[0] != before[1], "and the two branches tell different stories"
copy = _imported(client, _export(client, original))
assert copy != original
assert _every_branch_story(client, copy) == before
def test_the_fork_point_comes_back_where_it_was_put(client):
"""`fork_depth` is stored, never inferred — including through a file.
Inferring it from where two branches' nodes first differ would be a guess
about how the story was played, and a wrong one the moment an attempt
happens to repeat its parent's text.
"""
original = _forked_story(client)
before = [(b["parent_branch_id"] is None, b["fork_depth"]) for b in _branches(client, original)]
copy = _imported(client, _export(client, original))
assert [(b["parent_branch_id"] is None, b["fork_depth"])
for b in _branches(client, copy)] == before
def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
original = _forked_story(client)
head_before = [b["is_head"] for b in _branches(client, original)]
assert head_before == [False, True], "the fixture left the head on the fork"
copy = _imported(client, _export(client, original))
assert [b["is_head"] for b in _branches(client, copy)] == head_before
# And the tip it sits at is derived from the nodes that arrived, not read
# out of the file — the bundle never says how deep a branch goes.
assert _texts(client, copy) == _texts(client, original)
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
"""The after-snapshots are why the bundle carries them.
The gold script adds ten a turn, so the scoreboard is a count of the story
behind it. A bundle that carried the actions but not the outcomes would
import a tree that reads correctly and switches wrong.
"""
original = _forked_story(client)
# One more turn on the fork, so the two tips are genuinely different
# numbers: played turn for turn, the branches earn the same gold and a
# switch that restored nothing at all would still look right.
ScriptedProvider.replies = ["Further still."]
_play(client, original, "press on")
per_branch = []
for branch in _branches(client, original):
_switch(client, original, branch["id"])
per_branch.append(_script_state(original).get("gold"))
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
copy = _imported(client, _export(client, original))
restored = []
for branch in _branches(client, copy):
_switch(client, copy, branch["id"])
restored.append(_script_state(copy).get("gold"))
assert restored == per_branch
def test_a_memory_comes_back_on_the_node_it_hangs_off(client):
"""Derived work is addressed by coordinate, so the coordinate is carried."""
original = _forked_story(client)
tip = [a for a in _rows(original) if a.live][-1]
db = SessionLocal()
try:
db.add(models.Memory(
adventure_id=original, text="They met a goblin.",
source_start=0, source_end=tip.depth,
branch_id=tip.branch_id, depth=tip.depth,
))
db.commit()
finally:
db.close()
exported = _export(client, original)
assert [(m["branch"], m["depth"]) for m in exported["memories"]] == [(1, tip.depth)]
copy = _imported(client, exported)
db = SessionLocal()
try:
memories = db.query(models.Memory).filter(
models.Memory.adventure_id == copy).all()
branches = [b.id for b in _branch_rows(copy)]
assert [(branches.index(m.branch_id), m.depth) for m in memories] == [(1, tip.depth)]
finally:
db.close()
# --------------------------------------------------- what is not in the file
def test_the_lineage_is_rebuilt_rather_than_carried(client):
"""A cache of `parent` + `fork_depth` is not a second thing to ship.
The file says where each branch forked; the ancestry that makes the fork
readable is computed from that on the way in, capped at the fork exactly as
`tree.fork` caps it. Shipping the cache too would put two sources of truth
for one fact in a file anybody can hand-edit.
"""
original = _forked_story(client)
exported = _export(client, original)
assert all("lineage" not in b for b in exported["branches"])
root, forked = _branch_rows(_imported(client, exported))
assert root.lineage == [[root.id, None]]
assert forked.lineage == [[forked.id, None], [root.id, forked.fork_depth]]
# Which is the arithmetic the reader depends on: the parent is capped one
# depth short of the attempt that was promoted, so the fork cannot see it.
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` is a fact about the adventure, and `depth` is one about a path.
Two branches have a node at depth 3, so depth cannot be the number
`max_action_index` hands out next. The import allocates one per turn
instead: siblings share it, the way SP4 leaves them, and no two coordinates
do.
"""
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})
# The case that makes the rule necessary: the fork and the line 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
def test_a_node_naming_a_branch_the_file_does_not_list_is_refused(client):
"""Refused, not half-applied. A tree missing a branch is a story that
silently stops, which is the failure this whole phase exists to end."""
payload = _export(client, _forked_story(client))
payload["branches"] = payload["branches"][:1]
before = _adventure_count()
r = _import(client, payload)
assert r.status_code == 400, r.text
assert "branch" in r.json()["detail"].lower()
assert _adventure_count() == before, "nothing was created"
def test_a_branch_forking_from_one_listed_after_it_is_refused(client):
"""The ordering rule buys acyclicity for the price of a comparison — and a
cycle would be an import that never returns rather than one that fails."""
payload = _export(client, _forked_story(client))
payload["branches"] = [{"parent": 1, "forkDepth": 0}, {"parent": None, "forkDepth": None}]
before = _adventure_count()
r = _import(client, payload)
assert r.status_code == 400, r.text
assert _adventure_count() == before
def test_a_fork_with_no_depth_is_refused(client):
payload = _export(client, _forked_story(client))
payload["branches"][1].pop("forkDepth")
before = _adventure_count()
r = _import(client, payload)
assert r.status_code == 400, r.text
assert "depth" in r.json()["detail"].lower()
assert _adventure_count() == before
def test_more_branches_than_the_cap_is_refused(client, monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
payload = {
"format": bundle.FORMAT, "title": "Too many",
"branches": [{"parent": None, "forkDepth": None}]
* (limits.MAX_BRANCHES_PER_ADVENTURE + 1),
"actions": [],
}
before = _adventure_count()
r = _import(client, payload)
assert r.status_code == 409, r.text
assert _adventure_count() == before
def test_a_turn_the_file_gives_no_live_attempt_still_tells_one(client):
"""A coordinate with nothing live is a turn no read can see.
The file is allowed to be wrong about this — it is a text file — so the
import picks the first attempt rather than importing a story with a hole.
"""
payload = _export(client, _forked_story(client))
for node in payload["actions"]:
node["live"] = False
copy = _imported(client, payload)
assert _texts(client, copy), "the story is readable"
live = [(r.branch_id, r.depth) for r in _rows(copy) if r.live]
assert len(live) == len(set(live)), "exactly one live attempt per coordinate"
assert len(live) == len({(r.branch_id, r.depth) for r in _rows(copy)})
# ------------------------------------------------------------- the v1 reader
def test_a_v1_bundle_still_imports(client):
"""The reader stays after the writer goes: those files are already saved."""
payload = {
"format": bundle.LEGACY_FORMAT,
"title": "Old backup",
"memoryCursor": 0, "summaryCursor": 0,
"actions": [
{"index": 0, "type": "start", "text": OPENING},
{"index": 1, "type": "do", "text": "> You go north."},
{
"index": 2, "type": "ai", "text": "Two.",
"variants": [{"text": "One."}, {"text": "Two."}],
"variantIndex": 1,
},
],
}
copy = _imported(client, payload)
assert _texts(client, copy) == [OPENING, "> You go north.", "Two."]
# One branch, and the `variants` array split back into the sibling group it
# always described.
assert len(_branches(client, copy)) == 1
ai = [r for r in _rows(copy) if r.type == "ai"]
assert [(r.text, r.live) for r in ai] == [("One.", False), ("Two.", True)]
assert len({(r.branch_id, r.depth) for r in ai}) == 1
def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client):
"""v1 counts covered actions; the tree anchors them. The translation needs
the nodes to exist, so it happens after they are written."""
payload = {
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
"memoryCursor": 2, "summaryCursor": 2,
"actions": [
{"index": 0, "type": "start", "text": OPENING},
{"index": 1, "type": "story", "text": "A corridor."},
{"index": 2, "type": "story", "text": "A door."},
],
}
copy = _imported(client, payload)
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:
db.close()
def test_a_v2_bundle_brings_its_anchors_back(client):
"""The other direction: v2 carries the anchor and the count is read off it."""
original = _forked_story(client)
tip = [a for a in _rows(original) if a.live][-1]
db = SessionLocal()
try:
adventure = db.get(models.Adventure, original)
adventure.memory_cursor_branch_id = tip.branch_id
adventure.memory_cursor_depth = tip.depth
db.commit()
finally:
db.close()
exported = _export(client, original)
assert exported["memoryCursor"] == {"branch": 1, "depth": tip.depth}
copy = _imported(client, exported)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, copy)
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()
def test_a_v1_memory_that_summarises_nothing_lands_on_the_root(client):
"""The import has to answer the question migration 62 answered.
A v1 file has no depths, and a memory the player typed has no `sourceEnd`
to derive one from — so it used to come back with a NULL depth, which is the
exact state SP7 removed from the schema. `Path._entry_clause` compares
`depth <= max_depth` and a NULL fails it, so the memory would read fine
until the imported adventure was forked and then vanish from the new branch.
"""
payload = {
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
"memoryCursor": 0, "summaryCursor": 0,
"actions": [
{"index": 0, "type": "start", "text": OPENING},
{"index": 1, "type": "story", "text": "A corridor."},
],
"memories": [
{"text": "Kira is the innkeeper's daughter"}, # typed
{"text": "The corridor, summarised", "sourceStart": 1, "sourceEnd": 1},
],
}
copy = _imported(client, payload)
db = SessionLocal()
try:
rows = {m.text: m for m in db.query(models.Memory)
.filter(models.Memory.adventure_id == copy).all()}
assert rows["Kira is the innkeeper's daughter"].depth == 0, (
"a typed memory anchors at the root, which every branch can see"
)
assert rows["The corridor, summarised"].depth == 1, "derived from its range"
assert all(m.depth is not None for m in rows.values())
assert all(m.branch_id is not None for m in rows.values())
finally:
db.close()
def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypatch):
"""The cap has to count what gets written, not what the file lists.
A v1 turn carries its retries in a `variants` array, and SP4 made every
attempt a row — so one entry can become ten. Counting entries lets a file
inside the cap write a multiple of it, and the body limit is no help: the
text is tiny, it is the row count that is the cost.
"""
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
monkeypatch.setattr(limits, "_BUNDLE_LIST_CAPS",
{**limits._BUNDLE_LIST_CAPS, "actions": 6})
payload = {
"format": bundle.LEGACY_FORMAT, "title": "Small file, many rows",
"memoryCursor": 0, "summaryCursor": 0,
"actions": [{"index": 0, "type": "start", "text": OPENING}] + [
{
"index": i, "type": "ai", "text": "Take four.",
"variants": [{"text": f"Take {n}."} for n in range(4)],
"variantIndex": 3,
}
for i in range(1, 4)
],
}
assert len(payload["actions"]) <= 6, "the file itself is inside the cap"
before = _adventure_count()
r = _import(client, payload)
assert r.status_code == 409, r.text
assert _adventure_count() == before, "and nothing was written"
def test_an_unknown_format_is_refused(client):
r = _import(client, {"format": "ai-dnd-adventure-v3", "title": "From the future"})
assert r.status_code == 400, r.text
assert bundle.FORMAT in r.json()["detail"]