diff --git a/backend/app/bundle.py b/backend/app/bundle.py index 78853e0..c1bf286 100644 --- a/backend/app/bundle.py +++ b/backend/app/bundle.py @@ -54,7 +54,7 @@ from fastapi import HTTPException from sqlalchemy import insert, update from sqlalchemy.orm import Session, undefer -from . import attempts, models +from . import attempts, models, schemas from .context import cursors, lineage 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) if branch.parent_branch_id is not None else None ) - if parent is None: - return dict(_ROOT) - return {"parent": parent, "forkDepth": branch.fork_depth} + out = ( + dict(_ROOT) if parent is None + 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: @@ -258,8 +266,9 @@ def _planned_branches(bundle: dict) -> list[dict]: specs: list[dict] = [] for i, entry in enumerate(entries): parent = entry.get("parent") + name = _planned_branch_name(entry, i) if parent is None: - specs.append(dict(_ROOT)) + specs.append(dict(_ROOT, **({"name": name} if name else {}))) continue # 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 @@ -279,10 +288,35 @@ def _planned_branches(bundle: dict) -> list[dict]: f"Branch {i} forks from branch {parent} but does not say at " f"what depth.", ) - specs.append({"parent": parent, "forkDepth": fork_depth}) + specs.append({ + "parent": parent, "forkDepth": fork_depth, + **({"name": name} if name else {}), + }) 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]: raw = bundle.get("actions") nodes: list[dict] = [] @@ -429,6 +463,7 @@ def _write_branches( parent_branch_id=ids[parent] if parent is not None else None, fork_depth=fork_depth if parent is not None else None, lineage=[], + name=spec.get("name"), created_at=models.utcnow(), ) ).inserted_primary_key[0] diff --git a/backend/app/context/cursors.py b/backend/app/context/cursors.py index 35032f9..eed5489 100644 --- a/backend/app/context/cursors.py +++ b/backend/app/context/cursors.py @@ -88,6 +88,17 @@ class Cursor: self.anchor(adventure, node.branch_id, lineage.NO_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( self, adventure: models.Adventure, branch_id: int | None, depth: int ) -> None: diff --git a/backend/app/migrations.py b/backend/app/migrations.py index c1fb0dd..593e751 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -251,6 +251,12 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [ # 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. (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) diff --git a/backend/app/models.py b/backend/app/models.py index 1c32917..69c7e01 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -211,6 +211,13 @@ class Branch(Base): # beneath it, inclusive. Computed once at fork from the parent's lineage # plus one entry, so no read ever reconstructs it. 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) diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 6086f5b..e6134d1 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -1110,11 +1110,158 @@ def list_branches( ), own_actions=count, is_head=(branch.id == adventure.head_branch_id), + name=branch.name, created_at=branch.created_at, )) 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( "/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b7cf238..447d0dd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -22,6 +22,7 @@ MEMORY_TEXT_MAX = 5_000 # multi-megabyte PNG in a row that gets read on every list request. IMAGE_MAX = 400_000 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)] Tags = Annotated[str, Field(max_length=TAGS_MAX)] @@ -220,9 +221,18 @@ class BranchOut(ORMModel): depth: int own_actions: int = 0 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 +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): text: ActionText diff --git a/backend/tests/test_branch_management.py b/backend/tests/test_branch_management.py new file mode 100644 index 0000000..ed85f69 --- /dev/null +++ b/backend/tests/test_branch_management.py @@ -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"]