Every test module carried the same eight-line prologue redirecting the database to a temp file. Only the first one to be imported ever took effect: `app.database` reads `AIDND_DB_PATH` at import and builds `engine` from it once, so by the time the second module ran the engine already existed. The other 34 copies created a temp file that nothing opened and nothing deleted, and leaked one per module per run. `conftest.py` now does it once, which is early enough because pytest imports conftest before any test module. It also deletes the file when the run ends. The tests still share one database, exactly as they already did: each `client` fixture calls `create_all` on setup and `drop_all` on teardown, so no test sees another test's rows. `tests/fakes.py` holds the one `ScriptedProvider`. Nine modules each had a copy, and the copies had drifted into four feature sets, so a test that needed to raise a provider error had to be written in one of the files whose copy supported that. The shared one is the superset. The two `FakeProvider` copies were the same class with a fixed reply, so they use it too. `test_chat.py` keeps its own, which implements `chat` rather than `generate` and records what it was constructed with. An autouse fixture resets the fake's class state between tests, so a stale reply list can no longer reach the next test. 435 lines out of the suite. 549 tests pass. Verified live by sabotage: breaking the shared fake fails 13 tests across four modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
564 lines
21 KiB
Python
564 lines
21 KiB
Python
"""Phase 14 SP5: continuing from a discarded attempt forks a branch.
|
|
|
|
SP4 made every attempt at a turn a node. While the attempts sit at the
|
|
tip, they are leaves and cost nothing: switching between them just moves
|
|
the `live` flag. The moment the player continues from an attempt the line
|
|
has already moved past, the two futures must coexist. That is a branch.
|
|
|
|
This file tests the claim the whole design rests on: a fork inserts one
|
|
row and moves one row, no matter how large the story behind it is.
|
|
Everything before the fork is borrowed, not copied, and the arithmetic
|
|
that makes borrowing possible lives in `lineage`.
|
|
|
|
python -m pytest tests/test_branch_forking.py -v
|
|
"""
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import auth, limits, models
|
|
from app.context import cursors, lineage
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.main import app
|
|
from app.routers import adventures
|
|
|
|
from fakes import ScriptedProvider
|
|
|
|
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
|
|
# advance shows up as a change the referee should have rejected.
|
|
SCHEMA = {
|
|
"player": {
|
|
"hp": {"min": 0, "max": 100, "initial": 100},
|
|
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
|
|
}
|
|
}
|
|
|
|
GOLD_SCRIPT = """
|
|
const modifier = (text) => {
|
|
state.gold = (state.gold || 0) + 10;
|
|
return { text };
|
|
};
|
|
modifier(text);
|
|
"""
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="fork@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, "mana": 50}},
|
|
)
|
|
setup.add(adv)
|
|
setup.flush()
|
|
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
|
|
setup.add(models.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
|
|
ScriptedProvider.prompts = []
|
|
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", type="do"):
|
|
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
|
json={"type": type, "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 _texts(client) -> list[str]:
|
|
return [a["text"] for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"]]
|
|
|
|
|
|
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 _fork(client, action_id):
|
|
return client.post(f"/api/adventures/{client.adv_id}/actions/{action_id}/fork")
|
|
|
|
|
|
def _state(adv_id):
|
|
db = SessionLocal()
|
|
try:
|
|
adv = db.get(models.Adventure, adv_id)
|
|
return adv.script_state, adv.world_state
|
|
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.depth, models.Action.variant_index)
|
|
.all()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _divergent_story(client):
|
|
"""A story that retried turn 2, continued from the newer take, and left
|
|
the older one behind as a leaf.
|
|
|
|
start > do > [attempt one | ATTEMPT TWO] > do > next turn
|
|
|
|
Returns the id of the discarded attempt.
|
|
"""
|
|
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
|
_play(client)
|
|
_retry(client)
|
|
_play(client, "go deeper")
|
|
return [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
|
|
|
|
|
# ---------------------------------------------------------------- the fork
|
|
|
|
def test_a_fork_inserts_one_branch_row_and_copies_no_actions(client):
|
|
discarded = _divergent_story(client)
|
|
rows_before = [a.id for a in _rows(client.adv_id)]
|
|
assert len(_branches(client)) == 1
|
|
|
|
r = _fork(client, discarded)
|
|
assert r.status_code == 200, r.text
|
|
|
|
branches = _branches(client)
|
|
assert len(branches) == 2, "exactly one branch row per divergence built on"
|
|
assert [a.id for a in _rows(client.adv_id)] == rows_before, "a fork copies nothing"
|
|
forked = [b for b in branches if b["parent_branch_id"] is not None][0]
|
|
assert forked["is_head"] is True
|
|
assert forked["own_actions"] == 1, "the promoted attempt, and nothing else"
|
|
# The fork point is the depth just before the attempt. The code stores
|
|
# this value instead of inferring it from where two branches first
|
|
# differ, because that inference would guess wrong as soon as an
|
|
# attempt repeats its parent's text.
|
|
assert forked["fork_depth"] == forked["depth"] - 1
|
|
|
|
|
|
def test_the_lineage_is_capped_at_the_fork_depth(client):
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
branch = lineage.branch_of(db, adventure)
|
|
entries = lineage.entries_of(branch)
|
|
assert entries[0] == (branch.id, None), "its own nodes, to the tip"
|
|
assert entries[1] == (branch.parent_branch_id, branch.fork_depth)
|
|
assert len(entries) == 2
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_both_branches_read_independently(client):
|
|
discarded = _divergent_story(client)
|
|
parent = _branches(client)[0]["id"]
|
|
_fork(client, discarded)
|
|
|
|
# The fork's story: everything up to the divergence, then the other take.
|
|
assert _texts(client) == [
|
|
"You enter a cave.", "> You look around.", "Attempt one.",
|
|
]
|
|
# The branch it left behind is unchanged, including turns after the fork.
|
|
r = client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
|
assert r.status_code == 200, r.text
|
|
assert _texts(client) == [
|
|
"You enter a cave.", "> You look around.", "Attempt two.",
|
|
"> You go deeper.", "Next turn.",
|
|
]
|
|
|
|
|
|
def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
|
|
"""Promoting the other attempt must not leave the parent with a gap in
|
|
its story. A coordinate with no live node is a turn that disappears
|
|
from the read."""
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
parent_id = db.query(models.Branch).filter_by(
|
|
adventure_id=adventure.id, parent_branch_id=None).one().id
|
|
per_coordinate = {}
|
|
for row in _rows(client.adv_id):
|
|
per_coordinate.setdefault((row.branch_id, row.depth), []).append(row)
|
|
for (branch_id, depth), group in per_coordinate.items():
|
|
live = [a for a in group if a.live]
|
|
assert len(live) == 1, f"branch {branch_id} depth {depth}"
|
|
# The parent's turn 2 is now a single take, so the pager no longer
|
|
# offers a page through attempts that diverged onto another branch.
|
|
parent_turn = per_coordinate[(parent_id, 2)]
|
|
assert len(parent_turn) == 1
|
|
assert parent_turn[0].variant_count == 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_playing_on_a_fork_continues_that_branchs_depths(client):
|
|
"""A depth is a position along this story. Numbering the next node from
|
|
the adventure-wide index would leave a gap where the other branch's
|
|
turns are, and every windowing estimate would then have to work around
|
|
that gap."""
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
ScriptedProvider.replies = ["Onward."]
|
|
_play(client, "turn back")
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
path = lineage.path_of(db, adventure)
|
|
rows = [a for a in _rows(client.adv_id) if path.contains(a)]
|
|
assert [a.depth for a in sorted(rows, key=lambda a: a.depth)] == [0, 1, 2, 3, 4]
|
|
finally:
|
|
db.close()
|
|
assert _texts(client)[-1] == "Onward."
|
|
|
|
|
|
# ------------------------------------------------------------- not a fork
|
|
|
|
def test_forking_at_the_tip_switches_without_making_a_branch(client):
|
|
"""Attempts nobody has built on stay leaves. This is what keeps the
|
|
lineage a list of divergences instead of a list of every retry."""
|
|
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
|
_play(client)
|
|
_retry(client)
|
|
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
|
|
|
r = _fork(client, discarded)
|
|
assert r.status_code == 200, r.text
|
|
assert len(_branches(client)) == 1, "no branch for an attempt at the tip"
|
|
assert _texts(client)[-1] == "Attempt one."
|
|
|
|
|
|
def test_forking_the_attempt_already_in_the_story_does_nothing(client):
|
|
"""This call is idempotent. A client that has lost track of which take
|
|
is live must not create a new branch on every click."""
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
promoted = [a.id for a in _rows(client.adv_id) if a.type == "ai" and a.live
|
|
and a.branch_id != _branches(client)[0]["id"]][0]
|
|
before = _texts(client)
|
|
|
|
for _ in range(3):
|
|
r = _fork(client, promoted)
|
|
assert r.status_code == 200, r.text
|
|
assert len(_branches(client)) == 2
|
|
assert _texts(client) == before
|
|
|
|
|
|
def test_forking_a_turn_that_is_already_the_story_is_a_no_op(client):
|
|
ScriptedProvider.replies = ["Only take."]
|
|
_play(client)
|
|
only = [a.id for a in _rows(client.adv_id) if a.type == "ai"][0]
|
|
|
|
r = _fork(client, only)
|
|
assert r.status_code == 200, r.text
|
|
assert len(_branches(client)) == 1
|
|
|
|
|
|
def test_forking_a_live_node_on_another_branch_is_refused(client):
|
|
"""A live node off the path belongs to another branch's story, not to a
|
|
spare attempt on this one. The refusal names the tool that actually
|
|
switches branches. It used to answer "only one take", which was true
|
|
of the attempt group but useless here: the caller does not want
|
|
another take, it wants the branch this node is on."""
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
parent_id = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
|
|
stranded = [a.id for a in _rows(client.adv_id)
|
|
if a.branch_id == parent_id and a.depth == 2][0]
|
|
|
|
r = _fork(client, stranded)
|
|
assert r.status_code == 400
|
|
assert "another branch" in r.json()["detail"]
|
|
# Refusing must leave the tree alone. The bug this guards against is a
|
|
# fork that promotes a sibling on the branch it was called against.
|
|
assert len(_branches(client)) == 2
|
|
|
|
|
|
# -------------------------------------------------------------- the state
|
|
|
|
def test_switching_restores_the_script_and_world_state(client):
|
|
ScriptedProvider.replies = [
|
|
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
|
|
"A beating.\n```state\n{\"player.hp\": -40}\n```",
|
|
"Onward.",
|
|
]
|
|
_play(client)
|
|
_retry(client)
|
|
_play(client, "go deeper")
|
|
parent = _branches(client)[0]["id"]
|
|
on_parent = _state(client.adv_id)
|
|
|
|
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
|
_fork(client, discarded)
|
|
script_state, world_state = _state(client.adv_id)
|
|
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
|
|
assert script_state == {"gold": 10}, "one turn of gold, not three"
|
|
|
|
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
|
assert _state(client.adv_id) == on_parent
|
|
|
|
|
|
def test_the_cooldown_clock_travels_with_the_branch(client):
|
|
"""The world-state clock is a depth, and depths repeat across branches,
|
|
so it can only be correct if each branch carries its own. It does,
|
|
without extra work: the clock lives inside `_meta.last_changed`, which
|
|
is part of the world state a switch restores."""
|
|
ScriptedProvider.replies = [
|
|
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
|
"Untouched.",
|
|
"Onward.",
|
|
]
|
|
_play(client)
|
|
_retry(client)
|
|
_play(client, "go deeper")
|
|
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
|
on_parent = _state(client.adv_id)[1]
|
|
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
|
|
|
|
_fork(client, discarded)
|
|
forked = _state(client.adv_id)[1]
|
|
assert forked["player"]["mana"] == 40
|
|
assert forked["_meta"]["last_changed"]["player.mana"] == 2
|
|
|
|
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
|
|
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
|
assert _state(client.adv_id)[1] == on_parent
|
|
|
|
|
|
def test_a_retry_does_not_advance_the_cooldown_clock(client):
|
|
"""SP5's one carried-over open item. A retry re-runs the same turn, so
|
|
the clock the cooldown rules read must not move. The reused `index`
|
|
used to guarantee this; the reused depth guarantees it now."""
|
|
ScriptedProvider.replies = [
|
|
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
|
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
|
|
]
|
|
_play(client)
|
|
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
|
|
_retry(client)
|
|
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
|
|
# The second attempt's drain must land, instead of being rejected for a
|
|
# cooldown it was never actually subject to.
|
|
assert _state(client.adv_id)[1]["player"]["mana"] == 40
|
|
|
|
|
|
# --------------------------------------------------------- derived work
|
|
|
|
def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
|
"""Nothing is moved or removed when a branch forks. The memory attaches
|
|
to the coordinate the parent's attempt still occupies, and the lineage
|
|
caps the parent one depth short of it. The fork therefore cannot see
|
|
this memory, and resummarizes that span from the text it actually
|
|
contains."""
|
|
from app import tree
|
|
|
|
discarded = _divergent_story(client)
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
winner = db.query(models.Action).filter_by(
|
|
adventure_id=adventure.id, type="ai", live=True).order_by(
|
|
models.Action.depth).first()
|
|
memory = models.Memory(
|
|
adventure_id=adventure.id, text="Attempt two happened.",
|
|
source_start=0, source_end=winner.depth,
|
|
)
|
|
tree.attach_memory(memory, winner)
|
|
db.add(memory)
|
|
cursors.MEMORY.anchor_at(adventure, winner)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
_fork(client, discarded)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
# The memory is still there, untouched, because it describes the
|
|
# parent's story, which is unchanged.
|
|
assert [m.text for m in db.query(models.Memory).all()] == ["Attempt two happened."]
|
|
path = lineage.path_of(db, adventure)
|
|
visible = db.query(models.Memory).filter(
|
|
models.Memory.adventure_id == adventure.id,
|
|
path.clause(models.Memory),
|
|
).all()
|
|
assert visible == [], "a sibling's memory reached this branch"
|
|
# The cursor reads one depth short of the memory, so this branch
|
|
# treats the block as due again instead of silently marking it read.
|
|
assert cursors.MEMORY.depth(db, adventure) == 1
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------------------------- undo
|
|
|
|
def test_undo_stops_at_the_fork(client):
|
|
"""Undoing a turn on a fork must never reach into the branch it forked
|
|
from. Those turns belong to that branch's story too."""
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
rows_before = len(_rows(client.adv_id))
|
|
|
|
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert r.status_code == 200, r.text
|
|
# The promoted attempt is removed, and the player action before it
|
|
# stays, because that action belongs to the parent and the parent
|
|
# still has it.
|
|
assert len(_rows(client.adv_id)) == rows_before - 1
|
|
assert _texts(client) == ["You enter a cave.", "> You look around."]
|
|
|
|
# Nothing is left of this branch's own turns, so undo must refuse
|
|
# instead of removing the parent's turns.
|
|
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert r.status_code == 400
|
|
assert "forked from" in r.json()["detail"]
|
|
|
|
|
|
# ----------------------------------------------------------- the tree view
|
|
|
|
def test_the_branch_list_is_the_tree(client):
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
ScriptedProvider.replies = ["Onward."]
|
|
_play(client, "turn back")
|
|
|
|
branches = _branches(client)
|
|
root = [b for b in branches if b["parent_branch_id"] is None][0]
|
|
forked = [b for b in branches if b["parent_branch_id"] == root["id"]][0]
|
|
assert root["fork_depth"] is None and root["depth"] == 4
|
|
assert forked["fork_depth"] == 1 and forked["depth"] == 4
|
|
assert root["own_actions"] == 5 and forked["own_actions"] == 3
|
|
assert forked["is_head"] is True and root["is_head"] is False
|
|
|
|
|
|
def test_fork_agrees_with_the_lineage_computed_by_hand(client):
|
|
"""`test_branch_clause.make_branch` has computed a fork's lineage by
|
|
hand since SP2, precisely so the fixture could not pass by repeating a
|
|
bug in the code under test. SP5 introduces that code, so this test
|
|
checks the two against each other instead of letting them drift
|
|
apart."""
|
|
from tests.test_branch_clause import make_branch
|
|
|
|
discarded = _divergent_story(client)
|
|
_fork(client, discarded)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
real = lineage.branch_of(db, adventure)
|
|
parent = db.get(models.Branch, real.parent_branch_id)
|
|
by_hand = make_branch(db, adventure, parent=parent, fork_depth=real.fork_depth)
|
|
# The two branches share a shape but not an id, so compare only the
|
|
# ancestry, which is the arithmetic part rather than the allocated id.
|
|
assert lineage.entries_of(real)[1:] == lineage.entries_of(by_hand)[1:]
|
|
assert lineage.entries_of(real)[0] == (real.id, None)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_a_deep_fork_chain_reads_for_what_one_branch_costs(client):
|
|
"""Clause count is bounded by the window, not by fork count. This is
|
|
the property the whole lineage cache exists for, now measured through
|
|
real forks instead of hand-built rows."""
|
|
from tools import dbmeter
|
|
|
|
ScriptedProvider.replies = ["First take.", "Second take.", "Onward."]
|
|
_play(client)
|
|
for _ in range(8):
|
|
_retry(client)
|
|
_play(client, "onward")
|
|
discarded = [
|
|
a.id for a in _rows(client.adv_id)
|
|
if a.type == "ai" and not a.live
|
|
]
|
|
if discarded:
|
|
assert _fork(client, discarded[-1]).status_code == 200
|
|
_play(client, "onward")
|
|
branches = _branches(client)
|
|
assert len(branches) > 4, "the fixture did not actually fork"
|
|
|
|
meter = dbmeter.Meter()
|
|
meter.attach(engine)
|
|
try:
|
|
with meter.scope("page"):
|
|
client.get(f"/api/adventures/{client.adv_id}")
|
|
page_bytes = meter.scopes[-1].total.fetched
|
|
finally:
|
|
meter.detach()
|
|
assert page_bytes > 0, "the meter saw nothing"
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
entries = lineage.entries_of(lineage.branch_of(db, adventure))
|
|
# The whole ancestry is available to be named.
|
|
assert len(entries) == len(branches)
|
|
# The windowed read names as few of them as the window needs.
|
|
path = lineage.path_of(db, adventure)
|
|
assert path.prefix_covering(60) <= len(entries)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_switching_to_a_branch_of_another_adventure_is_a_404(client):
|
|
"""A branch id names one adventure, so the two ids in the URL must
|
|
agree. Otherwise a guessed number reads somebody else's story."""
|
|
other = client.post("/api/adventures", json={"title": "Elsewhere"}).json()["id"]
|
|
ScriptedProvider.replies = ["Elsewhere."]
|
|
r = client.post(f"/api/adventures/{other}/actions", json={"type": "do", "text": "wait"})
|
|
assert r.status_code == 200, r.text
|
|
stranger = client.get(f"/api/adventures/{other}/branches").json()
|
|
assert len(stranger) == 1
|
|
|
|
assert client.post(
|
|
f"/api/adventures/{client.adv_id}/branches/{stranger[0]['id']}/switch"
|
|
).status_code == 404
|