Files
interactive-story/backend/tests/test_turn_flow_integration.py
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-05 07:01:50 -04:00

133 lines
4.7 KiB
Python

"""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