Files
interactive-story/backend/tests/test_turn_flow_integration.py
T
parththakkar106andClaude Opus 4.8 dab1807118 Roll back script_state on undo/retry; fix retry double-apply
The shared per-adventure script_state ("scoreboard" scripts write to) was
never reverted by undo, and retry re-ran the output hook on top of the already-
mutated state, double-applying its changes (e.g. "+10 gold" became +20).

Each action now snapshots script_state as it was immediately before its own
hooks ran (new Action.state_before column, migration 25):
- undo restores the turn's first-action snapshot, prunes memories that
  summarized the removed actions, and takes the turn lock against races.
- retry restores the AI action's snapshot before regenerating.

Story-card mutations are not reverted (documented limit). Adds the project's
first test suite: unit + full HTTP integration through the real scripting
engine (14 tests). See plan/11-state-revert-and-retry-fix.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 02:58:38 +05:30

137 lines
4.3 KiB
Python

"""End-to-end HTTP tests for undo/retry state revert, driving real turns through
the actual routes + scripting engine with only the LLM provider mocked.
A script's output hook adds 10 gold each turn; we assert the shared scoreboard
behaves correctly across play / undo / retry.
python -m pytest tests/test_turn_flow_integration.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
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
from app.routers import adventures
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
class FakeProvider:
"""Stand-in for OpenAICompatibleProvider: streams one fixed line, no network."""
def __init__(self, *a, **k):
pass
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
yield ("text", "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 our fake provider.
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", FakeProvider)
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)
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) == {} # scoreboard rolled back
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 (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) == {}