Let a branch be named, and thrown away

SP7 needs three branch operations and SP5 built one. Switching exists;
naming and deleting had no column and no route between them.

A name is stored because a player chose it. An unnamed branch keeps NULL
rather than a generated "branch 4" — a generated label is derived, and it
would go stale the moment a branch before it is deleted and the ordinals
shift underneath. The client draws those from the fork depth, which
nothing can shift. The v2 bundle carries the name for the same reason it
carries the fork points and leaves `lineage` out: it is a decision, not
something computed from one.

Delete is what stands between a tree and unbounded growth, since nothing
prunes one on its own. It refuses two branches: the root, which holds the
turns every other branch borrows, and the one being read — including any
branch the head was forked from, which is the same mistake in disguise and
the one that would cascade the head away and leave head_branch_id pointing
at nothing. The nodes, memories and descendants go through the foreign
keys that already cascade.

A cursor standing on a deleted branch is cleared. On Postgres a stale
branch id would simply never resolve; SQLite hands the freed id to the
next fork, and then the anchor resolves onto a branch it has never seen
and calls a stretch of story already summarized.

396 tests, 15 new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent a7bf47a35e
commit cf3d52171e
7 changed files with 606 additions and 6 deletions
+41 -6
View File
@@ -54,7 +54,7 @@ from fastapi import HTTPException
from sqlalchemy import insert, update from sqlalchemy import insert, update
from sqlalchemy.orm import Session, undefer from sqlalchemy.orm import Session, undefer
from . import attempts, models from . import attempts, models, schemas
from .context import cursors, lineage from .context import cursors, lineage
FORMAT = "ai-dnd-adventure-v2" FORMAT = "ai-dnd-adventure-v2"
@@ -150,9 +150,17 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
local.get(branch.parent_branch_id) local.get(branch.parent_branch_id)
if branch.parent_branch_id is not None else None if branch.parent_branch_id is not None else None
) )
if parent is None: out = (
return dict(_ROOT) dict(_ROOT) if parent is None
return {"parent": parent, "forkDepth": branch.fork_depth} else {"parent": parent, "forkDepth": branch.fork_depth}
)
# A name is something a player chose, so it travels — the same rule that
# puts the fork points in the file and leaves `lineage` out. An unnamed
# branch omits the key rather than carrying a null, which keeps the file
# for a tree nobody has named byte-identical to the one SP6 wrote.
if branch.name:
out["name"] = branch.name
return out
def _exported_node(action: models.Action, local: dict[int, int]) -> dict: def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
@@ -258,8 +266,9 @@ def _planned_branches(bundle: dict) -> list[dict]:
specs: list[dict] = [] specs: list[dict] = []
for i, entry in enumerate(entries): for i, entry in enumerate(entries):
parent = entry.get("parent") parent = entry.get("parent")
name = _planned_branch_name(entry, i)
if parent is None: if parent is None:
specs.append(dict(_ROOT)) specs.append(dict(_ROOT, **({"name": name} if name else {})))
continue continue
# A branch may only fork from one listed before it. That is how the # A branch may only fork from one listed before it. That is how the
# export writes them — branches are numbered in creation order and a # export writes them — branches are numbered in creation order and a
@@ -279,10 +288,35 @@ def _planned_branches(bundle: dict) -> list[dict]:
f"Branch {i} forks from branch {parent} but does not say at " f"Branch {i} forks from branch {parent} but does not say at "
f"what depth.", f"what depth.",
) )
specs.append({"parent": parent, "forkDepth": fork_depth}) specs.append({
"parent": parent, "forkDepth": fork_depth,
**({"name": name} if name else {}),
})
return specs return specs
def _planned_branch_name(entry: dict, i: int) -> str | None:
"""The name a branch entry carries, or None for one nobody named.
Checked before the row is created rather than left to the column, for the
reason the whole planner exists: a 400 from a pure function beats a half
written adventure and a database error from three branches in.
"""
raw = entry.get("name")
if raw is None:
return None
if not isinstance(raw, str):
raise HTTPException(400, f"Branch {i} has a name that is not text.")
name = raw.strip()
if len(name) > schemas.BRANCH_NAME_MAX:
raise HTTPException(
400,
f"Branch {i}'s name is longer than {schemas.BRANCH_NAME_MAX} "
f"characters.",
)
return name or None
def _planned_nodes(bundle: dict, branches: int) -> list[dict]: def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
raw = bundle.get("actions") raw = bundle.get("actions")
nodes: list[dict] = [] nodes: list[dict] = []
@@ -429,6 +463,7 @@ def _write_branches(
parent_branch_id=ids[parent] if parent is not None else None, parent_branch_id=ids[parent] if parent is not None else None,
fork_depth=fork_depth if parent is not None else None, fork_depth=fork_depth if parent is not None else None,
lineage=[], lineage=[],
name=spec.get("name"),
created_at=models.utcnow(), created_at=models.utcnow(),
) )
).inserted_primary_key[0] ).inserted_primary_key[0]
+11
View File
@@ -88,6 +88,17 @@ class Cursor:
self.anchor(adventure, node.branch_id, lineage.NO_DEPTH self.anchor(adventure, node.branch_id, lineage.NO_DEPTH
if node.depth is None else node.depth) if node.depth is None else node.depth)
def clear(self, adventure: models.Adventure) -> None:
"""Forget the anchor entirely: nothing is covered.
For when the ground the anchor stood on is gone — a deleted branch. On
Postgres a stale branch id would simply never resolve, but SQLite hands
a freed id to the next fork, and an anchor that resolves onto a branch
it has never seen would report a stretch of story as already
summarized. Clearing costs a re-summarize, which is the safe direction.
"""
self.anchor(adventure, None, NO_DEPTH)
def rewind_to( def rewind_to(
self, adventure: models.Adventure, branch_id: int | None, depth: int self, adventure: models.Adventure, branch_id: int | None, depth: int
) -> None: ) -> None:
+6
View File
@@ -251,6 +251,12 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# handful of rows behind it, which ix_actions_branch_depth already serves, # handful of rows behind it, which ix_actions_branch_depth already serves,
# so this is a no-op statement that gives the passes a version to hang on. # so this is a no-op statement that gives the passes a version to hang on.
(60, "CREATE INDEX IF NOT EXISTS ix_actions_branch_depth ON actions (branch_id, depth)"), (60, "CREATE INDEX IF NOT EXISTS ix_actions_branch_depth ON actions (branch_id, depth)"),
# Phase 14, SP7 — a branch can be named. NULL is "nobody named this one",
# which is every branch alive when this runs, so there is no backfill and
# nothing to derive. `branches` holds a handful of rows per adventure rather
# than one per turn, so unlike SP1's and SP4's this rewrite is a few hundred
# rows against a few hundred thousand and needs no VACUUM FULL of its own.
(61, "ALTER TABLE branches ADD COLUMN name VARCHAR(80)"),
] ]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+7
View File
@@ -211,6 +211,13 @@ class Branch(Base):
# beneath it, inclusive. Computed once at fork from the parent's lineage # beneath it, inclusive. Computed once at fork from the parent's lineage
# plus one entry, so no read ever reconstructs it. # plus one entry, so no read ever reconstructs it.
lineage: Mapped[list] = mapped_column(JSON, default=list) lineage: Mapped[list] = mapped_column(JSON, default=list)
# What the player called this line of the story, or NULL for one nobody has
# named. NULL rather than a generated "branch 4", because a generated name
# is derived and this column is for what was chosen — the same rule the v2
# bundle is built on. A stored default would also become a lie the moment a
# branch before it is deleted and the ordinals shift under it; an unnamed
# branch is drawn from its fork depth instead, which nothing can shift.
name: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+147
View File
@@ -1110,11 +1110,158 @@ def list_branches(
), ),
own_actions=count, own_actions=count,
is_head=(branch.id == adventure.head_branch_id), is_head=(branch.id == adventure.head_branch_id),
name=branch.name,
created_at=branch.created_at, created_at=branch.created_at,
)) ))
return out return out
def get_branch_or_404(
adventure: models.Adventure, branch_id: int, db: Session
) -> models.Branch:
"""One branch of this adventure, or a 404 that does not confirm it exists."""
branch = db.get(models.Branch, branch_id)
if branch is None or branch.adventure_id != adventure.id:
raise HTTPException(404, "Branch not found")
return branch
@router.patch(
"/{adventure_id}/branches/{branch_id}", response_model=schemas.BranchOut
)
def rename_branch(
adventure_id: int,
branch_id: int,
payload: schemas.BranchRename,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Name a branch, or clear the name to leave it unnamed again.
A blank string means the same thing as `null` — a name of spaces is not a
name anyone chose, and storing one would give the client something to draw
that reads as an empty label rather than as a fork depth.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
branch = get_branch_or_404(adventure, branch_id, db)
name = (payload.name or "").strip()
branch.name = name or None
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(branch)
tip = (
db.query(func.max(models.Action.depth))
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch.id,
models.Action.live.is_(True),
)
.scalar()
)
return schemas.BranchOut(
id=branch.id,
parent_branch_id=branch.parent_branch_id,
fork_depth=branch.fork_depth,
depth=tip if tip is not None else (
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
),
own_actions=0,
is_head=(branch.id == adventure.head_branch_id),
name=branch.name,
created_at=branch.created_at,
)
@router.delete("/{adventure_id}/branches/{branch_id}", status_code=204)
def delete_branch(
adventure_id: int,
branch_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Throw away a branch, and everything forked from it.
Nothing auto-prunes a tree, so this is the only thing standing between a
heavily-retried adventure and unbounded growth — which is why it ships with
the view that first lets anyone make a fork rather than after it.
Two branches cannot go. The root, because it holds the turns every other
branch borrows and deleting it would take the whole story. And the one
being read — or any branch it was forked from, which is the same mistake
wearing a disguise: the cascade would take the head out from under the
player and leave `head_branch_id` pointing at nothing. Switch first.
The nodes and memories go with it through `ON DELETE CASCADE`, and the
descendants through `branches.parent_branch_id`'s, so the delete is one
statement however deep the subtree is.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
branch = get_branch_or_404(adventure, branch_id, db)
if branch.parent_branch_id is None:
raise HTTPException(
400, "This is the story's first branch — deleting it would delete "
"the adventure. Delete the adventure itself instead.",
)
head = db.get(models.Branch, adventure.head_branch_id)
# The head's lineage names itself and every branch it borrows from, so one
# membership test covers both "you are standing on it" and "you are on
# something forked from it".
if head is not None and branch.id in {
entry_id for entry_id, _ in lineage.entries_of(head)
}:
raise HTTPException(
400, "You are reading this branch, or one forked from it. Switch to "
"another branch first.",
)
acquire_turn_lock(adventure_id)
try:
# Collected before the delete, because afterwards there is nothing left
# to ask which branches went. A cursor left pointing at a deleted branch
# would be harmless on Postgres, where ids are never reused, and a real
# bug on SQLite, where the next fork can be handed the id that just went
# free — at which point a stale anchor silently resolves onto a branch
# it has never seen.
doomed = _branch_subtree(db, adventure, branch)
for cursor in cursors.ALL:
stored_branch, _ = cursor.stored(adventure)
if stored_branch in doomed:
cursor.clear(adventure)
db.delete(branch)
adventure.updated_at = models.utcnow()
db.commit()
finally:
_active_turns.discard(adventure_id)
# The deleted branch's memories go with it, and their cached vectors fall
# out of the catalogue on the next read — no invalidation call needed. See
# the note on memorybank's cache.
def _branch_subtree(
db: Session, adventure: models.Adventure, root: models.Branch
) -> set[int]:
"""`root` and every branch descended from it, by parent pointer.
Walked over the adventure's own branch rows rather than queried per level:
an adventure has a handful of branches, and the walk is the same cost as
one round trip while a recursive CTE would have to be written twice for the
two dialects this codebase keeps parity with.
"""
children: dict[int | None, list[int]] = {}
for bid, parent in db.query(models.Branch.id, models.Branch.parent_branch_id).filter(
models.Branch.adventure_id == adventure.id
):
children.setdefault(parent, []).append(bid)
found: set[int] = set()
stack = [root.id]
while stack:
current = stack.pop()
if current in found:
continue
found.add(current)
stack.extend(children.get(current, ()))
return found
@router.post( @router.post(
"/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage "/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage
) )
+10
View File
@@ -22,6 +22,7 @@ MEMORY_TEXT_MAX = 5_000
# multi-megabyte PNG in a row that gets read on every list request. # multi-megabyte PNG in a row that gets read on every list request.
IMAGE_MAX = 400_000 IMAGE_MAX = 400_000
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16) ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
BRANCH_NAME_MAX = 80 # what a player called one line of the story — VARCHAR(80)
Name = Annotated[str, Field(max_length=NAME_MAX)] Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)] Tags = Annotated[str, Field(max_length=TAGS_MAX)]
@@ -220,9 +221,18 @@ class BranchOut(ORMModel):
depth: int depth: int
own_actions: int = 0 own_actions: int = 0
is_head: bool = False is_head: bool = False
# NULL for a branch nobody has named. The client draws those from the fork
# depth rather than the server inventing one — see the column comment.
name: str | None = None
created_at: datetime created_at: datetime
class BranchRename(BaseModel):
"""A name a player chose, or `null` to go back to being unnamed."""
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
class ActionUpdate(BaseModel): class ActionUpdate(BaseModel):
text: ActionText text: ActionText
+384
View File
@@ -0,0 +1,384 @@
"""Phase 14 SP7 — naming a branch, and throwing one away.
SP5 gave the tree a fork and a switch. Neither of them ever removes anything,
and nothing in the design prunes a tree on its own, so an adventure that is
retried and forked enough grows without a ceiling. Delete is what stands
between the tree and that, which is why it ships with the view that first makes
a fork reachable rather than in some later subphase.
Two rules carry most of this file:
* **A name is chosen, so it is stored; a label is derived, so it is not.** An
unnamed branch keeps NULL and the client draws it from its fork depth. A
generated "branch 4" in the column would be a lie the moment branch 3 is
deleted.
* **The delete may never take the ground under the reader.** Refusing the head
is the obvious half; refusing an *ancestor* of the head is the same mistake
wearing a disguise, and it is the one that would leave `head_branch_id`
pointing at a row the cascade removed.
python -m pytest tests/test_branch_management.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, limits, models, schemas
from app.context import cursors
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
from app.routers import adventures
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="branches@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
adv = models.Adventure(
user_id=user.id, title="Cave", script_state={}, world_state={},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(
adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
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, text="look around"):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text
def _retry(client):
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
def _branches(client) -> list[dict]:
r = client.get(f"/api/adventures/{client.adv_id}/branches")
assert r.status_code == 200, r.text
return r.json()
def _rename(client, branch_id, name):
return client.patch(f"/api/adventures/{client.adv_id}/branches/{branch_id}",
json={"name": name})
def _delete(client, branch_id):
return client.delete(f"/api/adventures/{client.adv_id}/branches/{branch_id}")
def _switch(client, branch_id):
return client.post(f"/api/adventures/{client.adv_id}/branches/{branch_id}/switch")
def _texts(client) -> list[str]:
return [
a["text"]
for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
]
def _discarded_on(adv_id, branch_id=None) -> int:
"""An AI attempt nobody built on, optionally restricted to one branch."""
db = SessionLocal()
try:
q = db.query(models.Action).filter(
models.Action.adventure_id == adv_id,
models.Action.type == "ai",
models.Action.live.is_(False),
)
if branch_id is not None:
q = q.filter(models.Action.branch_id == branch_id)
return q.order_by(models.Action.id).first().id
finally:
db.close()
def _forked(client):
"""A story with one fork. Returns (root id, forked id); the fork is head.
start · do · [attempt one | ATTEMPT TWO] · do · next turn
└── forked here
"""
_play(client)
_retry(client)
_play(client, "go deeper")
root = _branches(client)[0]["id"]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{_discarded_on(client.adv_id)}/fork")
assert r.status_code == 200, r.text
forked = [b for b in _branches(client) if b["id"] != root][0]["id"]
return root, forked
def _counts(adv_id, branch_ids):
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(models.Action.branch_id.in_(branch_ids)).count(),
db.query(models.Memory)
.filter(models.Memory.branch_id.in_(branch_ids)).count(),
)
finally:
db.close()
# ------------------------------------------------------------------ naming
def test_a_branch_starts_unnamed(client):
"""NULL, not a generated label — the client draws one from the fork depth.
A name written here would go stale the moment a branch before it is
deleted and the ordinals shift under it.
"""
root, forked = _forked(client)
assert [b["name"] for b in _branches(client)] == [None, None]
def test_a_name_is_stored_and_read_back(client):
root, forked = _forked(client)
r = _rename(client, forked, "the cellar")
assert r.status_code == 200, r.text
assert r.json()["name"] == "the cellar"
assert {b["id"]: b["name"] for b in _branches(client)} == {
root: None, forked: "the cellar",
}
def test_a_blank_name_goes_back_to_unnamed(client):
"""A name of spaces is not a name anyone chose.
Storing one would give the client an empty label to draw where it would
otherwise fall back to the fork depth — a branch that looks nameless and
reads as broken.
"""
root, forked = _forked(client)
_rename(client, forked, "briefly named")
assert _rename(client, forked, " ").json()["name"] is None
assert _rename(client, forked, None).json()["name"] is None
def test_a_name_longer_than_the_column_is_refused(client):
"""422 here rather than a 500 at INSERT: Postgres enforces VARCHAR(80)."""
root, forked = _forked(client)
assert _rename(client, forked, "x" * (schemas.BRANCH_NAME_MAX + 1)).status_code == 422
assert _rename(client, forked, "x" * schemas.BRANCH_NAME_MAX).status_code == 200
def test_naming_a_branch_of_another_adventure_is_a_404(client):
root, forked = _forked(client)
db = SessionLocal()
try:
other = models.Adventure(
user_id=db.get(models.Adventure, client.adv_id).user_id,
title="Elsewhere", script_state={}, world_state={},
)
db.add(other)
db.commit()
other_id = other.id
finally:
db.close()
r = client.patch(f"/api/adventures/{other_id}/branches/{forked}", json={"name": "x"})
assert r.status_code == 404
# ----------------------------------------------------------------- deleting
def test_the_root_branch_cannot_be_deleted(client):
"""It holds the turns every other branch borrows."""
root, forked = _forked(client)
r = _delete(client, root)
assert r.status_code == 400
assert "adventure" in r.json()["detail"].lower()
assert len(_branches(client)) == 2
def test_the_branch_being_read_cannot_be_deleted(client):
root, forked = _forked(client)
assert [b["is_head"] for b in _branches(client) if b["id"] == forked] == [True]
r = _delete(client, forked)
assert r.status_code == 400
assert "switch" in r.json()["detail"].lower()
def test_an_ancestor_of_the_branch_being_read_cannot_be_deleted(client):
"""The same mistake as deleting the head, wearing a disguise.
`parent_branch_id` cascades, so deleting a branch the head was forked from
would take the head with it and leave `head_branch_id` pointing at nothing.
"""
root, forked = _forked(client)
# A fork of the fork, so `forked` is an ancestor of the head rather than
# the head itself.
_retry(client)
_play(client, "press on")
nested = _discarded_on(client.adv_id, branch_id=forked)
r = client.post(f"/api/adventures/{client.adv_id}/actions/{nested}/fork")
assert r.status_code == 200, r.text
assert len(_branches(client)) == 3
r = _delete(client, forked)
assert r.status_code == 400
assert "forked from it" in r.json()["detail"]
assert len(_branches(client)) == 3
def test_deleting_a_branch_leaves_the_line_it_forked_from_untouched(client):
root, forked = _forked(client)
_switch(client, root)
kept = _texts(client)
assert _delete(client, forked).status_code == 204
assert [b["id"] for b in _branches(client)] == [root]
assert _texts(client) == kept, "the parent keeps every turn it had"
def test_deleting_a_branch_takes_its_nodes_and_its_descendants(client):
"""One statement, however deep the subtree — the cascade does the walking."""
root, forked = _forked(client)
_retry(client)
_play(client, "press on")
nested_attempt = _discarded_on(client.adv_id, branch_id=forked)
client.post(f"/api/adventures/{client.adv_id}/actions/{nested_attempt}/fork")
nested = [b["id"] for b in _branches(client) if b["id"] not in (root, forked)][0]
doomed_actions, _ = _counts(client.adv_id, [forked, nested])
assert doomed_actions > 0
root_actions_before, _ = _counts(client.adv_id, [root])
_switch(client, root)
assert _delete(client, forked).status_code == 204
assert [b["id"] for b in _branches(client)] == [root]
assert _counts(client.adv_id, [forked, nested]) == (0, 0)
assert _counts(client.adv_id, [root])[0] == root_actions_before
def test_deleting_a_branch_clears_a_cursor_that_stood_on_it(client):
"""Harmless on Postgres, a real bug on SQLite.
Postgres never reuses a branch id, so a stale anchor simply never resolves.
SQLite hands the freed id to the next fork, at which point the anchor
resolves onto a branch it has never seen and reports a stretch of story as
already summarized — which loses it from the memories for good.
"""
root, forked = _forked(client)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
cursors.MEMORY.anchor(adventure, forked, 3)
cursors.SUMMARY.anchor(adventure, root, 1)
db.commit()
finally:
db.close()
_switch(client, root)
assert _delete(client, forked).status_code == 204
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
assert cursors.MEMORY.stored(adventure) == (None, cursors.NO_DEPTH)
# The one standing on ground that survived is left exactly where it was.
assert cursors.SUMMARY.stored(adventure) == (root, 1)
finally:
db.close()
def test_deleting_an_unknown_branch_is_a_404(client):
root, forked = _forked(client)
assert _delete(client, forked + 9999).status_code == 404
# ------------------------------------------------------------------- backup
def test_a_bundle_carries_the_name_a_player_chose(client):
"""A name is a decision, so it travels — the rule the v2 format is built on.
`lineage` and the head depth stay out because they are computed from what
the file already carries; a name is computed from nothing.
"""
root, forked = _forked(client)
_rename(client, root, "the long way")
_rename(client, forked, "the cellar")
exported = client.get(f"/api/adventures/{client.adv_id}/export").json()
assert [b.get("name") for b in exported["branches"]] == ["the long way", "the cellar"]
r = client.post("/api/adventures/import", json=exported)
assert r.status_code == 201, r.text
restored = client.get(f"/api/adventures/{r.json()['id']}/branches").json()
assert [b["name"] for b in restored] == ["the long way", "the cellar"]
def test_an_unnamed_tree_exports_no_name_key(client):
"""Unchanged from the file SP6 wrote, for a tree nobody has named."""
_forked(client)
exported = client.get(f"/api/adventures/{client.adv_id}/export").json()
assert all("name" not in b for b in exported["branches"])
def test_a_bundle_naming_a_branch_with_a_number_is_refused(client):
"""400 from the planner, not a database error three branches in."""
_forked(client)
exported = client.get(f"/api/adventures/{client.adv_id}/export").json()
exported["branches"][1]["name"] = 7
r = client.post("/api/adventures/import", json=exported)
assert r.status_code == 400
assert "not text" in r.json()["detail"]