`backend/app/routers/adventures.py` held 2353 lines and 35 endpoints. It is now a package of 14 modules, the largest 443 lines. The split moves text rather than rewriting it. An AST comparison against the old file confirms all 86 definitions are identical, and the OpenAPI schema still lists the same 35 operations. Names a test replaces now live in `turns.py` only, and other modules reach them as `turns.<name>`. Rebinding a re-exported alias changes the alias and leaves every caller reading the original, so the package root does not re-export them. A patch aimed at the old target raises `AttributeError` instead of passing while doing nothing. Tests and the fixtures in `backend/tools/` say `adventures.turns.<name>`. The same rule keeps the turn lock working. One module owns `_active_turns`, so one lock guards one set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
126 lines
3.9 KiB
Python
126 lines
3.9 KiB
Python
"""End-to-end HTTP tests for undo and retry state revert. These tests drive
|
|
real turns through the actual routes and scripting engine, with only the LLM
|
|
provider mocked.
|
|
|
|
A script's output hook adds 10 gold each turn. These tests confirm that the
|
|
adventure's stored gold total stays correct across play, undo, and retry.
|
|
|
|
python -m pytest tests/test_turn_flow_integration.py -v
|
|
"""
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import auth, limits, models
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.main import app
|
|
from app.routers import adventures
|
|
|
|
from fakes import ScriptedProvider
|
|
|
|
GOLD_SCRIPT = """
|
|
const modifier = (text) => {
|
|
state.gold = (state.gold || 0) + 10;
|
|
return { text };
|
|
};
|
|
modifier(text);
|
|
"""
|
|
|
|
|
|
AI_REPLY = "The torch flickers as you press onward."
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="tester@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={})
|
|
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()
|
|
|
|
# Force a real, non-demo turn that uses the fake provider.
|
|
ScriptedProvider.replies = [AI_REPLY]
|
|
monkeypatch.setattr(adventures.turns, "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.turns._active_turns.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def _state(adv_id):
|
|
db = SessionLocal()
|
|
try:
|
|
return db.get(models.Adventure, adv_id).script_state
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _play(client, type_="do", text="look around"):
|
|
r = client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": type_, "text": text})
|
|
assert r.status_code == 200, r.text
|
|
return r
|
|
|
|
|
|
def test_play_then_undo_reverts_gold(client):
|
|
assert _state(client.adv_id) == {}
|
|
_play(client)
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
|
|
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert r.status_code == 200, r.text
|
|
assert _state(client.adv_id) == {} # gold reverted to zero
|
|
|
|
|
|
def test_two_turns_then_undo_reverts_only_last(client):
|
|
_play(client)
|
|
_play(client)
|
|
assert _state(client.adv_id) == {"gold": 20}
|
|
|
|
client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert _state(client.adv_id) == {"gold": 10} # back to after turn 1, not 0
|
|
|
|
|
|
def test_retry_does_not_double_apply_gold(client):
|
|
_play(client)
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
|
|
# Before the fix, this produced 20 because the output hook ran twice.
|
|
# Now it stays 10.
|
|
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert r.status_code == 200, r.text
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
|
|
|
|
def test_retry_then_undo_still_clean(client):
|
|
_play(client)
|
|
client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert _state(client.adv_id) == {}
|