Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`, `variant_count`, `state_before`, and `world_state_before`, plus `adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in SQLite, so migration 71 quotes it. Nothing outside the migrations read these. `models.py`, `schemas.py`, and `ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders by `id`, and `attempts.renumber`, `context.history.max_action_index`, and `nodes.next_index` are deleted. Two changes keep the migration replayable on a `create_all` database: - `_split_variants_into_siblings` wrote through the live ORM table, so it stopped compiling once migration 66 removed five of its columns. It now writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`. - Five data passes read columns these migrations drop. Each now calls `_has_columns` and returns early when the columns are absent. `bootstrap` takes a `through` version so a migration test can stop at the schema it asserts on. 555 tests pass, up from 549. Eight of the new cases assert each column is gone after a real schema-45 database migrates all the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
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, 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) == {}
|