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>
137 lines
4.3 KiB
Python
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) == {}
|