94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The milestone is subtraction, and what is left is the single-user local storyteller the specification describes. Removed in full: campaign scripting and its QuickJS sandbox; multi-user accounts, guest sessions, login, registration and the shared demo key; the visitor-analytics tables, dashboard and page beacon; the access log of sign-ins, addresses and devices; per-IP and per-user rate limiting and quotas; Render deployment config; Postgres and psycopg; cloud inference providers, the API-key field and the key encryption that existed to store it; session-cookie signing. None of it was hidden behind a flag — the routes are gone and answer 404. Two things were kept that the brief allowed keeping. The `users` table and its foreign keys stay as an internal ownership detail, because rewriting them out means a migration across most of the schema to delete a column that costs nothing; nothing creates a second user and no request carries an identity. Five inert tables and four inert columns stay for the same reason, so an M1 campaign database opens unchanged. The one addition is app/endpoints.py, which decides where a story may be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an explicit allowlist of networks, not a guess at what `ipaddress` means by "private", which calls the documentation ranges private and IPv6 loopback reserved. Every address a hostname resolves to must be in it, so a split answer does not squeak through, and the rule runs both when the endpoint is saved and before every outbound request, because a name that resolved to the LAN this morning can resolve elsewhere this afternoon. Known cloud hosts are named in the refusal so the error says why rather than looking like broken DNS. TLS is never traded against it: M1's shared trust context is intact on all four clients and there is no way to skip verification. The hardcoded 120-second model timeout is now a setting. That was not theoretical — on this GPU-less four-core host a cold load of qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short at 10s so a wrong address still fails fast; the read timeout defaults to 300s and is bounded at 3600, because "wait longer" must stay a number. Two defects found while testing and fixed here. An unknown /api path fell through the SPA catch-all and came back as HTML with status 200, so a client asking for JSON parsed a web page instead of learning the route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an unauthenticated loopback API would hand every page on the Internet a write handle on the campaign database; it now refuses to start. Verified rather than assumed. Offline, on a network with no route out and no DNS: five turns, retry with both takes retained, restart with an identical transcript digest, a failed model call leaving the accepted AI-turn count untouched, and a capture with zero non-loopback unicast packets. Against a real second machine on the LAN over HTTPS with a private CA: four turns, restart, and a capture showing 289 packets to the approved host, 344 loopback, zero anywhere else, zero DNS queries. Cloud and public endpoints refused with their reasons; no API key settable; every removed route 404. 604 backend tests pass, down from 648 by the fifteen retired with the subsystems they tested and up by the twenty-nine added for the endpoint policy and the removed surface. The scripting tests were not deleted: eight files used a JavaScript counter as instrumentation for the state snapshot and rollback machinery, which M2 does not touch, so the counter moved to the world-state engine and those tests still assert what they always did. Frontend lint and build are clean; the image builds, and its wheel-building stage is gone with quickjs. No M3 work. Undo is still destructive and there is still no Redo.
796 lines
32 KiB
Python
796 lines
32 KiB
Python
"""Phase 14 SP1: every existing adventure becomes a tree with one branch.
|
|
|
|
The migration this file tests cannot be re-run. It reads `index` and
|
|
writes `depth`, and from SP2 on, the reads follow `depth`. If it mis-maps
|
|
a row, that row does not raise an error. It disappears from the story,
|
|
which is why the assertions here check every row instead of a sample.
|
|
|
|
The fixture is a genuine schema-45 database, not a current one with an
|
|
old stamp. `create_all` always builds the current schema, so the three
|
|
tables the tree touches are dropped and rebuilt from the frozen pre-tree
|
|
DDL below. The migration then runs its real ALTER statements against
|
|
them, including the one that adds a foreign key. A pre-migration database
|
|
built any other way, such as a rewound stamp with the columns left in
|
|
place, would silently skip the DDL and test only half the change.
|
|
|
|
python -m pytest tests/test_tree_migration.py -v
|
|
"""
|
|
import json
|
|
|
|
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import text
|
|
|
|
from app import auth, compression, limits, migrations, models, tree
|
|
from app.context import history
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.main import app
|
|
|
|
# The three tables as they stood at schema 45, frozen. This is a snapshot
|
|
# of a past schema. It must not be updated to track `models.py`, because
|
|
# the whole point is that it lacks what SP1 adds. This DDL uses SQLite
|
|
# syntax only. The migration's Postgres half is exercised against a real
|
|
# server at deploy time (see plan/14).
|
|
PRE_TREE_DDL = (
|
|
"""
|
|
CREATE TABLE adventures (
|
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
scenario_id INTEGER,
|
|
title VARCHAR(200) NOT NULL DEFAULT 'Untitled Adventure',
|
|
memory TEXT NOT NULL DEFAULT '',
|
|
authors_note TEXT NOT NULL DEFAULT '',
|
|
ai_instructions TEXT NOT NULL DEFAULT '',
|
|
story_summary TEXT NOT NULL DEFAULT '',
|
|
script_state JSON NOT NULL DEFAULT '{}',
|
|
world_state JSON NOT NULL DEFAULT '{}',
|
|
placeholders JSON,
|
|
auto_summarize BOOLEAN NOT NULL DEFAULT 0,
|
|
memory_bank_enabled BOOLEAN NOT NULL DEFAULT 0,
|
|
memory_cursor INTEGER NOT NULL DEFAULT 0,
|
|
summary_cursor INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME,
|
|
updated_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE actions (
|
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
adventure_id INTEGER NOT NULL REFERENCES adventures(id) ON DELETE CASCADE,
|
|
"index" INTEGER NOT NULL,
|
|
type VARCHAR(20) NOT NULL,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
reasoning TEXT,
|
|
context_snapshot BLOB,
|
|
world_delta JSON,
|
|
state_before JSON,
|
|
world_state_before JSON,
|
|
variants JSON,
|
|
variant_count INTEGER NOT NULL DEFAULT 0,
|
|
variant_index INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE memories (
|
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
adventure_id INTEGER NOT NULL REFERENCES adventures(id) ON DELETE CASCADE,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
embedding_blob BLOB,
|
|
source_start INTEGER,
|
|
source_end INTEGER,
|
|
embedded BOOLEAN NOT NULL DEFAULT 0,
|
|
pinned BOOLEAN NOT NULL DEFAULT 0,
|
|
forgotten BOOLEAN NOT NULL DEFAULT 0,
|
|
use_count INTEGER NOT NULL DEFAULT 0,
|
|
last_used_at DATETIME,
|
|
created_at DATETIME
|
|
)
|
|
""",
|
|
)
|
|
|
|
# The story of adventure "Gapped": index 3 is missing, because deleting a
|
|
# middle action never renumbered the ones after it. The migration must
|
|
# preserve the gap.
|
|
GAPPED_INDEXES = (0, 1, 2, 4)
|
|
STRAIGHT_INDEXES = (0, 1)
|
|
# "Blank" holds an action whose text is nothing but whitespace. It is a
|
|
# row of the adventure but not of the story, so a cursor counting covered
|
|
# actions never counted it. Migration 56 must skip it the same way, using
|
|
# a frozen copy of the story-text predicate. This predicate is the one
|
|
# duplicated definition in the change, so this is the one test case that
|
|
# can catch it drifting.
|
|
BLANK_INDEXES = (0, 1, 2, 3)
|
|
BLANK_AT = 2
|
|
|
|
|
|
@pytest.fixture()
|
|
def pre_tree():
|
|
"""A schema-45 database with three adventures in it, returned as the ids
|
|
(gapped, straight, empty) their stories were written under."""
|
|
# Every test in this module shares one temp file, and a setup that
|
|
# fails before its yield never reaches a teardown. Start from empty
|
|
# instead of from whatever the previous test left.
|
|
Base.metadata.drop_all(bind=engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
with engine.begin() as conn:
|
|
# `branches` and the six new columns never existed at schema 45.
|
|
# Dropping the tables is the only way to remove the columns.
|
|
# SQLite refuses to drop a column that a foreign key references,
|
|
# and that is exactly the case for `branch_id`.
|
|
for table in ("actions", "memories", "branches", "adventures"):
|
|
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
|
for ddl in PRE_TREE_DDL:
|
|
conn.execute(text(ddl))
|
|
conn.execute(text(
|
|
"INSERT INTO users (id, email, is_guest, created_at, demo_turns_used, "
|
|
"demo_turns_date) VALUES (1, 'v45@example.com', 0, CURRENT_TIMESTAMP, 0, '')"
|
|
))
|
|
|
|
# The cursors as schema 45 held them: counts of covered story actions.
|
|
# Gapped's story is 0, 1, 2, 4, so "3 covered" is the node at depth
|
|
# 2 and "4 covered" is the node at depth 4. This is the whole
|
|
# reason a count and a depth are not the same number. Straight is
|
|
# caught up past its own end (5 covered, 2 actions), a state the
|
|
# older rule left behind that a clamp used to mask on every
|
|
# post-turn pass.
|
|
cursors_at = {"Gapped": (3, 4), "Straight": (5, 0), "Empty": (0, 0),
|
|
"Blank": (3, 0)}
|
|
ids = {}
|
|
for name in ("Gapped", "Straight", "Empty", "Blank"):
|
|
conn.execute(text(
|
|
"INSERT INTO adventures (user_id, title, memory_cursor, summary_cursor) "
|
|
"VALUES (1, :title, :mc, :sc)"
|
|
), {"title": name, "mc": cursors_at[name][0], "sc": cursors_at[name][1]})
|
|
ids[name] = conn.execute(text(
|
|
"SELECT id FROM adventures WHERE title = :title"
|
|
), {"title": name}).scalar()
|
|
|
|
for adventure_id, indexes in (
|
|
(ids["Gapped"], GAPPED_INDEXES),
|
|
(ids["Straight"], STRAIGHT_INDEXES),
|
|
(ids["Blank"], BLANK_INDEXES),
|
|
):
|
|
for index in indexes:
|
|
blank = adventure_id == ids["Blank"] and index == BLANK_AT
|
|
conn.execute(text(
|
|
'INSERT INTO actions (adventure_id, "index", type, text) '
|
|
"VALUES (:a, :i, :t, :x)"
|
|
), {"a": adventure_id, "i": index,
|
|
"t": "start" if index == 0 else "do",
|
|
"x": " \n\t " if blank else f"Turn {index}."})
|
|
|
|
# One memory that summarised a block of story, and one written by hand,
|
|
# which summarised nothing and so belongs to no node.
|
|
conn.execute(text(
|
|
"INSERT INTO memories (adventure_id, text, source_start, source_end) "
|
|
"VALUES (:a, 'The gate opened.', 0, 1)"
|
|
), {"a": ids["Gapped"]})
|
|
conn.execute(text(
|
|
"INSERT INTO memories (adventure_id, text) VALUES (:a, 'Hand-written.')"
|
|
), {"a": ids["Gapped"]})
|
|
|
|
conn.execute(text("PRAGMA user_version = 45"))
|
|
|
|
try:
|
|
yield ids
|
|
finally:
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def rows(sql: str, **params) -> list[tuple]:
|
|
with engine.begin() as conn:
|
|
return conn.execute(text(sql), params).all()
|
|
|
|
|
|
def scalar(sql: str, **params):
|
|
with engine.begin() as conn:
|
|
return conn.execute(text(sql), params).scalar()
|
|
|
|
|
|
# ------------------------------------------------------- the migration itself
|
|
|
|
def test_the_stamp_reaches_the_current_version(pre_tree):
|
|
migrations.bootstrap(engine)
|
|
assert scalar("PRAGMA user_version") == migrations.LATEST_VERSION
|
|
|
|
|
|
def test_every_action_lands_on_its_adventure_root_branch(pre_tree):
|
|
before = scalar("SELECT count(*) FROM actions")
|
|
|
|
migrations.bootstrap(engine)
|
|
|
|
assert scalar("SELECT count(*) FROM actions") == before, "the migration lost a row"
|
|
assert scalar("SELECT count(*) FROM actions WHERE branch_id IS NULL") == 0
|
|
assert scalar("SELECT count(*) FROM actions WHERE depth IS NULL") == 0
|
|
# Each action's branch belongs to that action's own adventure. A branch
|
|
# clause that forgot its adventure would still look right on a database
|
|
# holding one, which is why the fixture holds three.
|
|
mismatched = scalar("""
|
|
SELECT count(*) FROM actions a JOIN branches b ON b.id = a.branch_id
|
|
WHERE b.adventure_id != a.adventure_id
|
|
""")
|
|
assert mismatched == 0
|
|
|
|
|
|
def test_depth_is_the_old_index_gaps_included(pre_tree):
|
|
migrations.bootstrap(engine, through=migrations.TREE_BACKFILL_VERSION)
|
|
|
|
assert rows('SELECT "index", depth FROM actions WHERE depth != "index"') == []
|
|
depths = [
|
|
row[0] for row in rows(
|
|
"SELECT depth FROM actions WHERE adventure_id = :a ORDER BY depth",
|
|
a=pre_tree["Gapped"],
|
|
)
|
|
]
|
|
# 3 is still missing. Renumbering here would silently move every cursor
|
|
# pointing past the gap, and the reads only need the order, not density.
|
|
assert depths == list(GAPPED_INDEXES)
|
|
|
|
|
|
def test_one_root_branch_per_adventure_with_its_own_lineage(pre_tree):
|
|
migrations.bootstrap(engine)
|
|
|
|
branches = rows(
|
|
"SELECT id, adventure_id, parent_branch_id, fork_depth, lineage FROM branches"
|
|
)
|
|
assert len(branches) == 4, "one branch per adventure, including the empty one"
|
|
for branch_id, _adventure_id, parent, fork_depth, lineage in branches:
|
|
assert parent is None, "a migrated branch is a root; nothing forked yet"
|
|
assert fork_depth is None
|
|
# The whole story, uncapped: one entry, itself, no ceiling.
|
|
assert json.loads(lineage) == [[branch_id, None]]
|
|
|
|
|
|
def test_the_head_points_at_the_tip_of_the_root_branch(pre_tree):
|
|
migrations.bootstrap(engine)
|
|
|
|
heads = dict(rows("SELECT title, head_depth FROM adventures"))
|
|
assert heads["Gapped"] == max(GAPPED_INDEXES)
|
|
assert heads["Straight"] == max(STRAIGHT_INDEXES)
|
|
# No actions, no tip. -1 keeps "the next node goes at head_depth + 1" true
|
|
# without a special case anywhere else.
|
|
assert heads["Empty"] == tree.NO_DEPTH
|
|
assert scalar("SELECT count(*) FROM adventures WHERE head_branch_id IS NULL") == 0
|
|
dangling = scalar("""
|
|
SELECT count(*) FROM adventures a
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM branches b
|
|
WHERE b.id = a.head_branch_id AND b.adventure_id = a.id
|
|
)
|
|
""")
|
|
assert dangling == 0, "a head pointing outside its own adventure"
|
|
|
|
|
|
def test_memories_attach_to_the_node_they_summarised(pre_tree):
|
|
migrations.bootstrap(engine)
|
|
|
|
summarised = rows(
|
|
"SELECT source_end, depth, branch_id FROM memories WHERE source_end IS NOT NULL"
|
|
)
|
|
assert summarised, "the fixture is supposed to have one"
|
|
for source_end, depth, branch_id in summarised:
|
|
assert depth == source_end, "the memory hangs off the last action it covered"
|
|
assert branch_id is not None
|
|
|
|
# A hand-written memory summarised no node, so SP7's migration 62
|
|
# lands it at depth 0 of its branch. Depth 0 is at or before every
|
|
# fork point, so the memory stays visible from exactly the paths it
|
|
# was visible from before. Anchoring it this way removes nothing from
|
|
# anybody's existing memory bank.
|
|
manual = rows("SELECT depth, branch_id FROM memories WHERE source_end IS NULL")
|
|
assert manual and all(depth == 0 and branch is not None for depth, branch in manual)
|
|
|
|
|
|
def test_the_cursors_become_the_nodes_they_named(pre_tree):
|
|
"""SP3, migration 56. A count of covered actions and a depth become
|
|
different numbers as soon as the story has a gap in it, and every
|
|
adventure with a deleted action has one."""
|
|
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
|
|
|
|
def marks(title):
|
|
[row] = rows(
|
|
"SELECT memory_cursor_depth, summary_cursor_depth, "
|
|
"memory_cursor_branch_id, summary_cursor_branch_id "
|
|
"FROM adventures WHERE title = :t", t=title
|
|
)
|
|
return row
|
|
|
|
# Gapped's story is 0, 1, 2, 4. "3 covered" is the third action, at
|
|
# depth 2. Reading the count as a depth would hand the summarizer node
|
|
# 3, which does not exist, and silently skip node 4 forever.
|
|
memory_depth, summary_depth, memory_branch, summary_branch = marks("Gapped")
|
|
assert (memory_depth, summary_depth) == (2, 4)
|
|
root = scalar(
|
|
"SELECT id FROM branches WHERE adventure_id = "
|
|
"(SELECT id FROM adventures WHERE title = 'Gapped')"
|
|
)
|
|
assert memory_branch == summary_branch == root
|
|
|
|
# Straight was caught up under the older rule: 5 covered, 2 actions. There
|
|
# is no fifth node to name, and the number meant "caught up", so it lands
|
|
# on the tip rather than on nothing.
|
|
memory_depth, summary_depth, _, summary_branch = marks("Straight")
|
|
assert memory_depth == 1
|
|
assert (summary_depth, summary_branch) == (migrations.NO_DEPTH, None)
|
|
|
|
# Nothing covered stays nothing covered, and names no branch.
|
|
assert marks("Empty") == (migrations.NO_DEPTH, migrations.NO_DEPTH, None, None)
|
|
|
|
# A whitespace-only action is a row but not a story action, so it was
|
|
# never counted. "3 covered" of 0, 1, [blank], 3 is the node at depth
|
|
# 3, not 2. The migration's copy of the story-text predicate is the
|
|
# only place that rule is written twice, so this is the test case
|
|
# that catches it drifting.
|
|
assert marks("Blank")[0] == 3
|
|
|
|
# The legacy columns are left exactly as they were: a rolled-back build
|
|
# reads them, and this migration is not the one that drops them.
|
|
assert rows(
|
|
"SELECT memory_cursor, summary_cursor FROM adventures ORDER BY title"
|
|
) == [(3, 0), (0, 0), (3, 4), (5, 0)] # Blank, Empty, Gapped, Straight
|
|
|
|
|
|
def test_the_branch_clause_index_exists(pre_tree):
|
|
"""SP2's reads are only cheap if this index exists. `create_all` does
|
|
not add an index to a table it did not create, which is what
|
|
migration 52 handles."""
|
|
migrations.bootstrap(engine)
|
|
|
|
assert scalar(
|
|
"SELECT count(*) FROM sqlite_master "
|
|
"WHERE type = 'index' AND name = 'ix_actions_branch_depth'"
|
|
) == 1
|
|
|
|
|
|
def test_running_it_again_changes_nothing(pre_tree):
|
|
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
|
|
snapshot = (
|
|
rows("SELECT id, branch_id, depth FROM actions ORDER BY id"),
|
|
rows("SELECT id, adventure_id, lineage FROM branches ORDER BY id"),
|
|
rows("SELECT id, head_branch_id, head_depth, memory_cursor_branch_id, "
|
|
"memory_cursor_depth, summary_cursor_branch_id, summary_cursor_depth "
|
|
"FROM adventures ORDER BY id"),
|
|
rows("SELECT id, branch_id, depth FROM memories ORDER BY id"),
|
|
)
|
|
|
|
# Run the deploy path twice, then the data pass on its own. The stamp
|
|
# stops the first run, the NULL guards stop the second, and a
|
|
# migration that only survives because of the stamp is one bad rescue
|
|
# away from doubling every branch.
|
|
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
|
|
with engine.begin() as conn:
|
|
migrations._backfill_tree(conn)
|
|
migrations._backfill_cursor_anchors(conn)
|
|
|
|
assert (
|
|
rows("SELECT id, branch_id, depth FROM actions ORDER BY id"),
|
|
rows("SELECT id, adventure_id, lineage FROM branches ORDER BY id"),
|
|
rows("SELECT id, head_branch_id, head_depth, memory_cursor_branch_id, "
|
|
"memory_cursor_depth, summary_cursor_branch_id, summary_cursor_depth "
|
|
"FROM adventures ORDER BY id"),
|
|
rows("SELECT id, branch_id, depth FROM memories ORDER BY id"),
|
|
) == snapshot
|
|
|
|
|
|
# -------------------------------------------------- rows written *after* it
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
"""The app on a migrated database, so new rows go through the real writers.
|
|
|
|
Fixing existing rows is only half the job. No migration ever revisits
|
|
a row written after it ran, and a row without a branch is a row no
|
|
read can see.
|
|
"""
|
|
Base.metadata.drop_all(bind=engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="writer@example.com")
|
|
setup.add(user)
|
|
setup.flush()
|
|
scenario = models.Scenario(user_id=user.id, title="S", prompt="You enter a cave.")
|
|
setup.add(scenario)
|
|
setup.commit()
|
|
user_id, scenario_id = user.id, scenario.id
|
|
setup.close()
|
|
|
|
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.scenario_id = scenario_id
|
|
try:
|
|
yield c
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def test_a_new_adventure_gets_a_branch_and_its_opening_sits_on_it(client):
|
|
response = client.post("/api/adventures", json={"scenario_id": client.scenario_id})
|
|
assert response.status_code == 201
|
|
adventure_id = response.json()["id"]
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adventure_id)
|
|
branch = db.query(models.Branch).filter_by(adventure_id=adventure_id).one()
|
|
assert adventure.head_branch_id == branch.id
|
|
assert branch.lineage == [[branch.id, None]]
|
|
opening = db.query(models.Action).filter_by(adventure_id=adventure_id).one()
|
|
assert (opening.branch_id, opening.depth) == (branch.id, 0)
|
|
assert adventure.head_depth == 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_a_blank_adventure_has_a_branch_before_anything_is_played(client):
|
|
adventure_id = client.post("/api/adventures", json={}).json()["id"]
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adventure_id)
|
|
assert adventure.head_branch_id is not None
|
|
assert adventure.head_depth == tree.NO_DEPTH
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_a_hand_written_memory_is_anchored_at_the_head(client):
|
|
"""SP7: nothing carries a NULL depth anymore.
|
|
|
|
On an adventure with no story yet, the head is `NO_DEPTH` (-1), which
|
|
reads as "before the first node" and so falls in range of every
|
|
branch. This is correct for a note written before anything has
|
|
happened.
|
|
"""
|
|
adventure_id = client.post("/api/adventures", json={}).json()["id"]
|
|
|
|
created = client.post(
|
|
f"/api/adventures/{adventure_id}/memories", json={"text": "Remember the gate."}
|
|
)
|
|
assert created.status_code == 201
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
memory = db.query(models.Memory).filter_by(adventure_id=adventure_id).one()
|
|
adventure = db.get(models.Adventure, adventure_id)
|
|
assert memory.branch_id is not None
|
|
assert memory.depth == adventure.head_depth == -1
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_deleting_a_branch_takes_its_nodes_with_it(client):
|
|
"""`ON DELETE CASCADE` on both `branch_id` columns means the database
|
|
removes a branch's nodes instead of relying on application code to
|
|
remember to. SP7 builds delete-a-branch directly on top of this, and
|
|
no other code needs to load a branch to do it."""
|
|
adventure_id = client.post(
|
|
"/api/adventures", json={"scenario_id": client.scenario_id}
|
|
).json()["id"]
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adventure_id)
|
|
memory = models.Memory(
|
|
adventure_id=adventure_id, text="m", source_start=0, source_end=0
|
|
)
|
|
tree.place_memory(db, adventure, memory)
|
|
db.add(memory)
|
|
db.commit()
|
|
branch_id = adventure.head_branch_id
|
|
|
|
db.execute(
|
|
models.Branch.__table__.delete().where(models.Branch.id == branch_id)
|
|
)
|
|
db.commit()
|
|
assert db.query(models.Action).filter_by(adventure_id=adventure_id).count() == 0
|
|
assert db.query(models.Memory).filter_by(adventure_id=adventure_id).count() == 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_deleting_an_adventure_takes_its_branch_with_it(client):
|
|
adventure_id = client.post(
|
|
"/api/adventures", json={"scenario_id": client.scenario_id}
|
|
).json()["id"]
|
|
|
|
assert client.delete(f"/api/adventures/{adventure_id}").status_code == 204
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
assert db.query(models.Branch).filter_by(adventure_id=adventure_id).count() == 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_deleting_the_newest_action_moves_the_head_back(client):
|
|
"""The head is a cache. A cache that only ever moves forward becomes
|
|
wrong the first time someone undoes a turn."""
|
|
adventure_id = client.post(
|
|
"/api/adventures", json={"scenario_id": client.scenario_id}
|
|
).json()["id"]
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adventure_id)
|
|
extra = models.Action(adventure_id=adventure_id, type="do", text="Look.")
|
|
tree.place_action(db, adventure, extra)
|
|
db.add(extra)
|
|
db.commit()
|
|
assert adventure.head_depth == 1
|
|
action_id = extra.id
|
|
finally:
|
|
db.close()
|
|
|
|
assert client.delete(
|
|
f"/api/adventures/{adventure_id}/actions/{action_id}"
|
|
).status_code == 204
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
assert db.get(models.Adventure, adventure_id).head_depth == 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ---------------------------------------- SP4: variants become sibling rows
|
|
|
|
# One turn's retry history as schema 45 stored it: a JSON array on the
|
|
# AI row, with `variant_index` naming the entry `text` mirrors. The live
|
|
# entry is deliberately not the last one written. A migration that
|
|
# assumed it was would still pass on every fixture where the player
|
|
# never paged back.
|
|
RETRY_VARIANTS = [
|
|
{"text": "Attempt one.", "reasoning": None,
|
|
"script_state": {"gold": 10}, "created_at": "2026-01-01T00:00:00",
|
|
"raw_output": "Attempt one.",
|
|
"world_state": {"delta": {"player.hp": -5},
|
|
"report": {"applied": [{"path": "player.hp", "old": 100, "new": 95}]},
|
|
"state": {"player": {"hp": 95}}}},
|
|
{"text": "Attempt two.", "reasoning": "thinking",
|
|
"script_state": {"gold": 20}, "created_at": "2026-01-01T00:01:00",
|
|
"raw_output": "Attempt two.",
|
|
"world_state": {"delta": {"player.hp": -40},
|
|
"report": {"applied": [{"path": "player.hp", "old": 100, "new": 60}]},
|
|
"state": {"player": {"hp": 60}}}},
|
|
{"text": "Attempt three.", "reasoning": None,
|
|
"script_state": {"gold": 30}, "created_at": "2026-01-01T00:02:00",
|
|
"raw_output": "Attempt three."},
|
|
]
|
|
LIVE_VARIANT = 1
|
|
|
|
# The whole turn's assembled prompt, stored once. The attempts differ
|
|
# only in the three slices above, and SP4 must preserve that arrangement.
|
|
# Giving each sibling a copy of this prompt would multiply the largest
|
|
# column in the database by the retry count.
|
|
RETRY_SNAPSHOT = {
|
|
"sections": [{"label": "history", "text": "A long prompt.", "tokens": 4}],
|
|
"prompt": {"system": "S", "story": "A long prompt."},
|
|
"raw_output": "Attempt two.",
|
|
"script": {"logs": []},
|
|
"world_state": RETRY_VARIANTS[LIVE_VARIANT]["world_state"],
|
|
}
|
|
|
|
|
|
@pytest.fixture()
|
|
def pre_split():
|
|
"""A schema-45 adventure with one retried turn, plus a plain turn on
|
|
each side.
|
|
|
|
This fixture is separate from `pre_tree` so SP1's assertions keep
|
|
counting what they were written to count. The story is: 0 start, 1
|
|
do, 2 ai (three attempts), 3 do. The adventure's live state is the
|
|
one attempt 1 produced.
|
|
"""
|
|
Base.metadata.drop_all(bind=engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
with engine.begin() as conn:
|
|
for table in ("actions", "memories", "branches", "adventures"):
|
|
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
|
for ddl in PRE_TREE_DDL:
|
|
conn.execute(text(ddl))
|
|
conn.execute(text(
|
|
"INSERT INTO users (id, email, is_guest, created_at, demo_turns_used, "
|
|
"demo_turns_date) VALUES (1, 'v45@example.com', 0, CURRENT_TIMESTAMP, 0, '')"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO adventures (user_id, title, script_state, world_state) "
|
|
"VALUES (1, 'Retried', :script, :world)"
|
|
), {"script": json.dumps({"gold": 20}),
|
|
"world": json.dumps({"player": {"hp": 60}})})
|
|
adventure_id = conn.execute(
|
|
text("SELECT id FROM adventures WHERE title = 'Retried'")
|
|
).scalar()
|
|
# `state_before` on each row records the script state as that
|
|
# action found it. SP4 reads each row's `state_before` from the
|
|
# next row to build the `state_after` pair.
|
|
for index, kind, before in (
|
|
(0, "start", None), (1, "do", {"gold": 0}),
|
|
(2, "ai", {"gold": 0}), (3, "do", {"gold": 20}),
|
|
):
|
|
conn.execute(text(
|
|
'INSERT INTO actions (adventure_id, "index", type, text, reasoning, '
|
|
"state_before, context_snapshot, variants, variant_count, variant_index) "
|
|
"VALUES (:a, :i, :t, :x, :r, :sb, :cs, :v, :vc, :vi)"
|
|
), {
|
|
"a": adventure_id, "i": index, "t": kind,
|
|
"x": RETRY_VARIANTS[LIVE_VARIANT]["text"] if kind == "ai" else f"Turn {index}.",
|
|
"r": RETRY_VARIANTS[LIVE_VARIANT]["reasoning"] if kind == "ai" else None,
|
|
"sb": None if before is None else json.dumps(before),
|
|
"cs": compression.pack(RETRY_SNAPSHOT) if kind == "ai" else None,
|
|
"v": json.dumps(RETRY_VARIANTS) if kind == "ai" else None,
|
|
"vc": len(RETRY_VARIANTS) if kind == "ai" else 0,
|
|
"vi": LIVE_VARIANT if kind == "ai" else 0,
|
|
})
|
|
conn.execute(text("PRAGMA user_version = 45"))
|
|
try:
|
|
yield adventure_id
|
|
finally:
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def _attempts(adventure_id) -> list[tuple]:
|
|
return rows(
|
|
"SELECT variant_index, text, live, variant_count FROM actions "
|
|
'WHERE adventure_id = :a AND "index" = 2 ORDER BY variant_index',
|
|
a=adventure_id,
|
|
)
|
|
|
|
|
|
def test_each_attempt_becomes_a_row_at_the_turns_coordinate(pre_split):
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
|
|
assert _attempts(pre_split) == [
|
|
(0, "Attempt one.", 0, 3),
|
|
(1, "Attempt two.", 1, 3),
|
|
(2, "Attempt three.", 0, 3),
|
|
]
|
|
# One turn, one coordinate: the siblings share a branch and a depth, and
|
|
# keep the legacy index that says which turn they are all takes on.
|
|
coordinates = rows(
|
|
'SELECT DISTINCT branch_id, depth FROM actions WHERE adventure_id = :a '
|
|
'AND "index" = 2', a=pre_split,
|
|
)
|
|
assert len(coordinates) == 1
|
|
# The rest of the story is untouched, still one row per turn.
|
|
assert scalar("SELECT count(*) FROM actions WHERE adventure_id = :a", a=pre_split) == 6
|
|
|
|
|
|
def test_the_live_attempt_is_the_one_the_row_was_mirroring(pre_split):
|
|
"""`variant_index` is the only record of which take the player was reading,
|
|
and it survives as the `live` flag. Guessing "the newest" instead would
|
|
silently rewrite the story of anyone who had paged back."""
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
|
|
live = rows(
|
|
"SELECT text FROM actions WHERE adventure_id = :a AND live = 1 "
|
|
'AND "index" = 2', a=pre_split,
|
|
)
|
|
assert live == [("Attempt two.",)]
|
|
|
|
|
|
def test_the_prompt_stays_on_the_live_attempt_and_nowhere_else(pre_split):
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
|
|
holders = []
|
|
for variant_index, snapshot in rows(
|
|
'SELECT variant_index, context_snapshot FROM actions WHERE adventure_id = :a '
|
|
'AND "index" = 2 ORDER BY variant_index', a=pre_split,
|
|
):
|
|
stored = compression.unpack(snapshot) if snapshot else {}
|
|
if "sections" in stored:
|
|
holders.append(variant_index)
|
|
else:
|
|
# A superseded attempt keeps only what was its own.
|
|
assert set(stored) <= set(migrations._ATTEMPT_KEYS)
|
|
assert holders == [LIVE_VARIANT]
|
|
|
|
|
|
def test_each_attempt_keeps_the_outcome_it_produced(pre_split):
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
|
|
parsed = [
|
|
(i, json.loads(state), json.loads(world) if world else None)
|
|
for i, state, world in rows(
|
|
"SELECT variant_index, state_after, world_state_after FROM actions "
|
|
'WHERE adventure_id = :a AND "index" = 2 ORDER BY variant_index',
|
|
a=pre_split,
|
|
)
|
|
]
|
|
assert [(i, s) for i, s, _ in parsed] == [
|
|
(0, {"gold": 10}), (1, {"gold": 20}), (2, {"gold": 30})
|
|
]
|
|
assert parsed[0][2] == {"player": {"hp": 95}}
|
|
assert parsed[1][2] == {"player": {"hp": 60}}
|
|
# Attempt three recorded no world state: either an adventure with no
|
|
# RPG layer, or a take made before the column existed. It stays NULL
|
|
# instead of borrowing a neighbor's, so switching to it leaves the RPG
|
|
# layer alone. This matches what `apply_variant` did with an entry
|
|
# that had no world state.
|
|
assert parsed[2][2] is None
|
|
|
|
|
|
def test_state_after_is_the_state_before_of_the_turn_in_front(pre_split):
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
|
|
after = dict(rows(
|
|
'SELECT "index", state_after FROM actions WHERE adventure_id = :a '
|
|
"AND live = 1 ORDER BY depth", a=pre_split,
|
|
))
|
|
# Action 1's outcome is action 2's starting position, exactly.
|
|
assert json.loads(after[1]) == {"gold": 0}
|
|
# The tip has nothing in front of it, so what it left behind is what the
|
|
# adventure is carrying now.
|
|
assert json.loads(after[3]) == {"gold": 20}
|
|
|
|
|
|
def test_the_split_survives_being_run_again(pre_split):
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
snapshot = _attempts(pre_split)
|
|
before = scalar("SELECT count(*) FROM actions")
|
|
|
|
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
|
|
with engine.begin() as conn:
|
|
migrations._backfill_state_after(conn)
|
|
migrations._split_variants_into_siblings(conn)
|
|
|
|
assert scalar("SELECT count(*) FROM actions") == before, "attempts were duplicated"
|
|
assert _attempts(pre_split) == snapshot
|
|
|
|
|
|
def test_the_migrated_story_reads_back_as_one_turn(pre_split):
|
|
"""This is the point of the whole migration: reads see a four-action
|
|
story, not six."""
|
|
migrations.bootstrap(engine)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, pre_split)
|
|
assert [a.text for a in history.story_actions(adventure)] == [
|
|
"Turn 0.", "Turn 1.", "Attempt two.", "Turn 3.",
|
|
]
|
|
assert history.count(adventure) == 4
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------------- SP8: the columns go
|
|
|
|
# The columns migrations 66 to 73 drop, paired with the table they sat on.
|
|
DROPPED_COLUMNS = [
|
|
("actions", "index"),
|
|
("actions", "variants"),
|
|
("actions", "variant_index"),
|
|
("actions", "variant_count"),
|
|
("actions", "state_before"),
|
|
("actions", "world_state_before"),
|
|
("adventures", "memory_cursor"),
|
|
("adventures", "summary_cursor"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("table,column", DROPPED_COLUMNS)
|
|
def test_the_legacy_columns_are_dropped(pre_tree, table, column):
|
|
"""A pre-tree database that migrates all the way ends without these eight.
|
|
|
|
The fixture is a real schema-45 database, so each column starts present and
|
|
the `ALTER TABLE` statements have something to remove. A `create_all`
|
|
database never has them, and would pass this test without running the
|
|
migration at all.
|
|
"""
|
|
before = {row[1] for row in rows(f"PRAGMA table_info({table})")}
|
|
assert column in before, "the fixture must start with the column"
|
|
|
|
migrations.bootstrap(engine)
|
|
|
|
after = {row[1] for row in rows(f"PRAGMA table_info({table})")}
|
|
assert column not in after
|