"""End-to-end HTTP tests for undo and retry state revert. These tests drive real turns through the actual routes and the world-state engine, with only the LLM provider mocked. The model banks 10 gold each turn through its ```state delta block. These tests confirm that the adventure's stored gold total stays correct across play, undo, and retry. It was a JavaScript output hook that added the gold until M2 removed campaign scripting; the state machinery under test is the same either way. 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 tally_reply, GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_replies 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")) scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA) setup.add(scenario) setup.flush() adv = models.Adventure(user_id=user.id, title="Cave", scenario_id=scenario.id, world_state={"player": {"hp": 100, "gold": 0}}) setup.add(adv) setup.flush() setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave.")) setup.commit() adv_id, user_id = adv.id, user.id setup.close() # Force a real, non-demo turn that uses the fake provider. # Each turn states its own running total, absolutely (ADR 010). One # repeated reply would set the same total every turn, which is exactly # what a delta protocol could not distinguish from adding. ScriptedProvider.replies = tally_replies(AI_REPLY, 20) monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) 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 _gold(adv_id) -> int: """The banked total, read out of the authoritative narrative state. M5 moved the instrument from an RPG stat to a typed narrative fact. What these assertions are about is unchanged: rollback, and which position's state is current. """ db = SessionLocal() try: return tally_of(db.get(models.Adventure, adv_id).narrative_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 _gold(client.adv_id) == 0 _play(client) assert _gold(client.adv_id) == 10 r = client.post(f"/api/adventures/{client.adv_id}/undo") assert r.status_code == 200, r.text assert _gold(client.adv_id) == 0 # gold reverted to zero def test_two_turns_then_undo_reverts_only_last(client): _play(client) _play(client) assert _gold(client.adv_id) == 20 client.post(f"/api/adventures/{client.adv_id}/undo") assert _gold(client.adv_id) == 10 # back to after turn 1, not 0 def test_retry_does_not_double_apply_gold(client): """A retry replaces its turn rather than stacking on top of it. The retake states the same total the turn it replaces did, so a state that had stacked would read as a value no position ever established. Before the fix this file was written for, the turn's effects ran twice. """ _play(client) assert _gold(client.adv_id) == 10 ScriptedProvider.replies = [tally_reply("Another telling.", 10)] r = client.post(f"/api/adventures/{client.adv_id}/retry") assert r.status_code == 200, r.text assert _gold(client.adv_id) == 10 def test_retry_then_undo_still_clean(client): _play(client) ScriptedProvider.replies = [tally_reply("Another telling.", 10)] client.post(f"/api/adventures/{client.adv_id}/retry") assert _gold(client.adv_id) == 10 client.post(f"/api/adventures/{client.adv_id}/undo") assert _gold(client.adv_id) == 0