A bundle had one list and a forked adventure has two stories, so export was emitting every branch's turns interleaved by index — a mangled story rather than lost data, and unreachable only because forking has no UI yet. `ai-dnd-adventure-v2` carries the branches, the depth each one left its parent at, which attempt at every turn is the story, and what each node left behind. That last one is not decoration: the after-snapshots are what a branch switch puts back, and a bundle without them imports a tree nobody can switch inside. `app/bundle.py` owns both formats and nothing else knows either. The v1 reader stays — those files are already on people's disks — and it is now the only place a `variants` array exists anywhere. The rule the module is built on is that a bundle carries what was chosen and never what is derived. The head branch, the fork points, the live flags and the anchors are decisions somebody made. The lineage, the head depth, the legacy `index` and the variant ordinals are computed from those and are rebuilt on the way in, because a bundle is a text file anybody can edit and a derived field shipped beside its source is a chance for the file to disagree with itself where no read would report it. `index` is the one that stops being academic here. It agreed with `depth` until SP5, and this is the first writer that has to fill it for a forked story, where two branches both hold a node at depth 4. It is allocated one per turn instead: siblings share it, no two coordinates do. Everything a hand-edited file can get wrong about the shape of a tree is a 400 raised before the adventure row exists, because a half-applied import is exactly the failure this phase exists to end — a story that goes quiet. A file wrong about which attempt is live is corrected rather than refused; that is an invariant of the database, not of the format. Measured on the 600-action fixture: 587 kB to 911 kB, and all of the increase is the outcomes at 489 B a node — the coordinates themselves save 57.5 B a node against the old turn-and-variants shape. Twenty forks add 660 B. 4.3% of the import body cap. 381 tests green, 16 of them new in test_bundle_v2.py. No migration, no vacuum owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
540 lines
20 KiB
Python
540 lines
20 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_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"]
|