A campaign could already be exported and imported. What could not survive the trip was everything that explains it: the state events behind the authoritative document, the prompt each turn was actually given, the passages it was shown, the summaries that carry long-story continuity, and which take belonged to which turn. An imported campaign could be read and could no longer say why it was what it was — and a manual correction, the one state change no narration explains, was indistinguishable from something the story had established. The bundle is now `ai-dnd-adventure-v3`, and the version is the design rather than a side effect. Everything added here could have been another optional key, the way persona, Save Points, narrative state and imported knowledge each were. That mechanism stops working at exactly this addition: a v2 file with no prompt provenance is ambiguous between "written before M9" and "written by M9 from a campaign that has none", and those are different facts about a campaign. A version number is how a recovery file states what it was capable of recording. v1 and v2 still import, and every seam from pre-active-head onward is tested for the rule that an older file is never reinterpreted under a newer assumption. Two categories became three. "Chosen travels, derived is recomputed" was enough until stored prompts had to be decided: they are derived, and they must travel anyway. The test that separates evidence from cache is not "could this be recomputed" but "would a recomputation answer the same question" — a rebuilt search index answers the same question, a rebuilt prompt says what the turn would be told *now*, which is the opposite of what the inspector is for. Also here: a real SQLite backup, through the online backup API rather than a file copy, taken while the application is running and verified before it is kept; story cards settled as compatibility-only legacy data and taken out of the narrator's prompt, because they were the untracked path around knowledge authority that IMPORTED-KNOWLEDGE-DESIGN §73 already forbade; and no schema change at all, proved against a database M8's own code wrote. Three defects, found by running the milestone's own tests rather than by reading them. Deleting a campaign leaked its FTS index rows, and SQLite then handed the freed ids to the next source imported into any campaign, which failed with an integrity error that Reindex could not repair — both ends are closed, and a database already carrying the damage now repairs itself. An imported node with no state snapshot was being stamped with the campaign's head state, so an Undo to turn 2 showed what the story knew at turn 20. And the snapshot relink did not persist at all, because it mutated a dict in place on a column SQLAlchemy tracks by assignment: it looked correct in memory and wrote the wrong ids to disk. Carrying per-turn prompts looked like it would halve the length of campaign that can be restored. Measured — and after compressing them inside the file — everything M9 added costs 12% of it: the import ceiling moves from about 318 turns to about 279, against a 100-turn certification target. The dominant cost is not M9's at all. The per-position narrative state document is 74% of a bundle, and v2 already carried it. Backend 1,102 passed / 14 skipped / 0 failed. Frontend 145 passed. Lint, production build and Docker build clean. Verified across two server processes with two data directories, and in a real browser against a real narrator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
1187 lines
50 KiB
Python
1187 lines
50 KiB
Python
"""M9: a campaign survives being moved, and can say why it is what it is.
|
|
|
|
The Definition of Done is one sentence — *a campaign can be safely exported,
|
|
imported into a clean data directory, and reopened at the exact intended active
|
|
position with authoritative history/state intact* — and this file is the part of
|
|
its evidence that runs in the suite. Two things it deliberately does not claim:
|
|
|
|
* **"A clean data directory" is not proved here.** Every test in this file runs
|
|
against one database, and importing beside the original is a weaker check than
|
|
importing where the original has never existed — a shared row, a shared id
|
|
space or a shared cache would go unnoticed. `test_m9_clean_import.py` does that
|
|
across a genuine second process and a genuinely empty database, and this file
|
|
says so rather than implying otherwise.
|
|
* **A green run here is not the milestone.** The browser workflow, the process
|
|
restart and the real-narrator run are in their own places, for the reason M8
|
|
wrote down: a suite that stubs a boundary is not evidence about that boundary.
|
|
|
|
What this file does prove is the shape of the contract. `m9_fixture` builds one
|
|
campaign that holds every portable data family at once — an undone head, a
|
|
retained future, an abandoned line with its own state and derived data, two
|
|
takes at one coordinate, Save Points on two branches, a manual state correction,
|
|
five imported sources across three classes and both lifecycle states, and stored
|
|
prompts for turns that used them. Every assertion below is a comparison between
|
|
that campaign and its copy, read through the API a reader reads.
|
|
|
|
python -m pytest tests/test_m9_portability.py -v
|
|
"""
|
|
|
|
import asyncio
|
|
import copy
|
|
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import auth, bundle, limits, memorybank, models, summaries
|
|
from app.context import lineage
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.knowledge import embeddings
|
|
from app.main import app
|
|
from app.routers import adventures
|
|
|
|
import m9_fixture
|
|
from fakes import ScriptedProvider
|
|
|
|
|
|
class StubDerived:
|
|
"""A deterministic embedder and summariser in one object.
|
|
|
|
Both factories are stubbed from it, which is M6's finding M6-F3: replacing
|
|
only one leaves the other building a real provider against the default
|
|
endpoint, and every turn in the file opens a socket.
|
|
"""
|
|
|
|
written = 0
|
|
|
|
async def complete(self, system, prompt, **kwargs):
|
|
StubDerived.written += 1
|
|
return f"Memory {StubDerived.written}: what the story had established."
|
|
|
|
async def embed(self, texts):
|
|
out = []
|
|
for text in texts:
|
|
lowered = text.lower()
|
|
out.append([
|
|
1.0,
|
|
1.0 if "abbey" in lowered or "crypt" in lowered else 0.0,
|
|
1.0 if "tavern" in lowered or "lantern" in lowered else 0.0,
|
|
1.0 if "rain" in lowered else 0.0,
|
|
1.0 if "seal" in lowered else 0.0,
|
|
])
|
|
return out
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
memorybank._vector_cache.clear()
|
|
embeddings._cache.clear()
|
|
StubDerived.written = 0
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="m9@example.com")
|
|
setup.add(user)
|
|
setup.flush()
|
|
setup.add(models.Settings(
|
|
user_id=user.id, model="test-model", embedding_model="stub-embed",
|
|
context_token_budget=4000, max_output_tokens=400, memory_top_k=3,
|
|
))
|
|
adventure = models.Adventure(
|
|
user_id=user.id, title="M9 Portability Fixture",
|
|
campaign_canon=m9_fixture.CAMPAIGN_CANON,
|
|
)
|
|
setup.add(adventure)
|
|
setup.flush()
|
|
setup.add(models.Action(
|
|
adventure_id=adventure.id, type="start", text=m9_fixture.OPENING,
|
|
))
|
|
# A neighbour campaign. A bundle that reached past its own rows would bring
|
|
# this one's story back with it, and every count below would still add up.
|
|
neighbour = models.Adventure(user_id=user.id, title="Neighbour")
|
|
setup.add(neighbour)
|
|
setup.flush()
|
|
setup.add(models.Action(
|
|
adventure_id=neighbour.id, type="start",
|
|
text="A different story entirely, which must not travel.",
|
|
))
|
|
setup.commit()
|
|
adv_id, other_id, user_id = adventure.id, neighbour.id, user.id
|
|
setup.close()
|
|
|
|
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
|
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
|
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
|
|
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
|
|
app.dependency_overrides[auth.get_current_user] = (
|
|
lambda db=Depends(get_db): db.get(models.User, user_id)
|
|
)
|
|
test_client = TestClient(app)
|
|
test_client.adv_id = adv_id
|
|
test_client.other_id = other_id
|
|
try:
|
|
yield test_client
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
adventures.turns._active_turns.clear()
|
|
memorybank._vector_cache.clear()
|
|
embeddings._cache.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
# ----------------------------------------------------------------- helpers
|
|
|
|
def export(client, adv_id=None) -> dict:
|
|
response = client.get(f"/api/adventures/{adv_id or client.adv_id}/export")
|
|
assert response.status_code == 200, response.text[:400]
|
|
return response.json()
|
|
|
|
|
|
def bring_back(client, payload) -> int:
|
|
"""Imports a bundle and returns the new campaign's id."""
|
|
response = client.post("/api/adventures/import", json=payload)
|
|
assert response.status_code == 201, response.text[:600]
|
|
return response.json()["id"]
|
|
|
|
|
|
def refuses(client, payload) -> str:
|
|
"""Imports a bundle expecting a refusal, and returns the reason given."""
|
|
before = _campaign_count()
|
|
response = client.post("/api/adventures/import", json=payload)
|
|
assert response.status_code in (400, 409), (
|
|
f"expected a refusal, got {response.status_code}: {response.text[:300]}"
|
|
)
|
|
assert _campaign_count() == before, "and nothing was written"
|
|
return response.json()["detail"]
|
|
|
|
|
|
def _campaign_count() -> int:
|
|
with SessionLocal() as db:
|
|
return db.query(models.Adventure).count()
|
|
|
|
|
|
def rows(adv_id, model, **filters):
|
|
with SessionLocal() as db:
|
|
query = db.query(model).filter(model.adventure_id == adv_id)
|
|
for field, value in filters.items():
|
|
query = query.filter(getattr(model, field) == value)
|
|
return query.order_by(model.id).all()
|
|
|
|
|
|
@pytest.fixture()
|
|
def moved(client):
|
|
"""The fixture campaign, its bundle, and the copy the bundle produced.
|
|
|
|
Built once per test that needs it, because building it is a second of
|
|
scripted turns and most of the assertions below are about the same round
|
|
trip seen from different angles.
|
|
"""
|
|
source = m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
copy_id = bring_back(client, payload)
|
|
return {
|
|
"source": source,
|
|
"bundle": payload,
|
|
"copy_id": copy_id,
|
|
"copy": m9_fixture.snapshot_of(client, copy_id),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------- the format and its versions
|
|
|
|
def test_the_export_declares_version_three(moved):
|
|
assert moved["bundle"]["format"] == "ai-dnd-adventure-v3"
|
|
|
|
|
|
def test_every_earlier_version_still_imports(client):
|
|
"""A backup that stops importing is not a backup.
|
|
|
|
Both older versions are exercised, and each is checked for the thing its own
|
|
format could carry: a v1 file has a story, and a v2 file has a story, a tree
|
|
and a chosen head.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
v3 = export(client)
|
|
|
|
v2 = _as_version_two(v3)
|
|
copy_id = bring_back(client, v2)
|
|
assert _texts(client, copy_id) == _texts(client, client.adv_id)
|
|
|
|
v1 = {
|
|
"format": bundle.LEGACY_FORMAT,
|
|
"title": "An old file",
|
|
"actions": [
|
|
{"index": 0, "type": "start", "text": "It begins."},
|
|
{"index": 1, "type": "do", "text": "look around"},
|
|
{"index": 2, "type": "ai", "text": "Nothing moves."},
|
|
],
|
|
}
|
|
v1_id = bring_back(client, v1)
|
|
assert _texts(client, v1_id) == ["It begins.", "look around", "Nothing moves."]
|
|
|
|
|
|
def test_a_version_two_file_gets_no_invented_evidence(client):
|
|
"""Absent is read as "the format could not say", never as "there was none".
|
|
|
|
The distinction is the whole reason the version was bumped. A v2 file
|
|
carries no state events, and the import must leave the campaign with none
|
|
rather than reconstructing an audit trail from the snapshots it does carry —
|
|
a reconstructed trail would be this build's reading of a history it never
|
|
saw, presented to a reader as the record of what happened.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
copy_id = bring_back(client, _as_version_two(export(client)))
|
|
|
|
assert rows(copy_id, models.StateEvent) == []
|
|
assert rows(copy_id, models.StateProposal) == []
|
|
assert rows(copy_id, models.Summary) == []
|
|
assert all(
|
|
row.context_snapshot is None
|
|
for row in _all_actions(copy_id)
|
|
), "a v2 file carries no prompts, and none may be invented"
|
|
# What v2 *could* say still arrives in full.
|
|
assert _texts(client, copy_id) == _texts(client, client.adv_id)
|
|
assert len(rows(copy_id, models.Checkpoint)) == 2
|
|
|
|
|
|
def test_a_format_from_a_later_build_is_refused_rather_than_guessed_at(client):
|
|
detail = refuses(client, dict(export(client), format="ai-dnd-adventure-v9"))
|
|
assert "ai-dnd-adventure-v9" in detail
|
|
assert bundle.FORMAT in detail
|
|
|
|
|
|
def _as_version_two(payload: dict) -> dict:
|
|
"""The same campaign as a version 2 file: no v3 sections, no v3 node keys.
|
|
|
|
This is what an export taken before M9 looks like, built by removing exactly
|
|
what M9 added rather than by keeping a fixture file that would drift.
|
|
"""
|
|
older = copy.deepcopy(payload)
|
|
older["format"] = bundle.TREE_FORMAT
|
|
for key in ("stateEvents", "stateProposals", "summaries"):
|
|
older.pop(key, None)
|
|
for action in older["actions"]:
|
|
for key in ("contextSnapshot", "contextSnapshotZ", "id", "parentId"):
|
|
action.pop(key, None)
|
|
for memory in older.get("memories") or []:
|
|
memory.pop("authority", None)
|
|
for source in older.get("knowledge") or []:
|
|
for key in ("sourceId", "parserVersion", "chunkingVersion"):
|
|
source.pop(key, None)
|
|
return older
|
|
|
|
|
|
# ------------------------------------------------------------------ I01, I02
|
|
|
|
def test_i01_the_export_holds_enough_to_restore_the_story(moved):
|
|
"""I01. Not "the file is non-empty": the file names every family."""
|
|
payload = moved["bundle"]
|
|
for section in ("actions", "branches", "checkpoints", "knowledge",
|
|
"memories", "summaries", "stateEvents", "stateProposals"):
|
|
assert payload[section], f"the bundle carries no {section}"
|
|
assert payload["headDepth"] is not None
|
|
assert payload["narrativeState"]
|
|
assert any(m9_fixture.snapshot_in(a) for a in payload["actions"])
|
|
|
|
|
|
def test_i02_the_copy_reads_the_same_story_and_holds_the_same_state(moved):
|
|
"""I02. The active transcript and the authoritative state, both restored."""
|
|
assert moved["copy"]["transcript"] == moved["source"]["transcript"]
|
|
assert moved["copy"]["state"] == moved["source"]["state"]
|
|
assert moved["copy"]["state"], "and the state is not empty in both"
|
|
assert moved["copy"]["canon_rules"] == moved["source"]["canon_rules"]
|
|
|
|
|
|
def test_the_neighbouring_campaign_does_not_travel(moved, client):
|
|
"""A bundle carries one campaign. Nothing from the one beside it."""
|
|
everything = _texts(client, moved["copy_id"], every_branch=True)
|
|
assert not any("A different story entirely" in text for text in everything)
|
|
|
|
|
|
# ------------------------------------------------------- I07 and the head rules
|
|
|
|
def test_i07_the_copy_opens_at_the_exported_head_not_at_the_tip(moved, client):
|
|
"""I07. The head is where the reader left it, and the future is still there.
|
|
|
|
The fixture's head is behind the retained tip of its own branch *and* behind
|
|
the abandoned line's, and the fixture ends with an Undo precisely so that
|
|
the newest row written is not the position being read. An importer that
|
|
took the tip, the newest row, or the deepest row would each land somewhere
|
|
else.
|
|
"""
|
|
source_head = _head(client.adv_id)
|
|
copy_head = _head(moved["copy_id"])
|
|
assert copy_head["depth"] == source_head["depth"]
|
|
assert moved["copy"]["transcript"] == moved["source"]["transcript"]
|
|
|
|
# The future past the head is still in the database, unread.
|
|
ahead = [
|
|
row for row in _all_actions(moved["copy_id"])
|
|
if row.branch_id == copy_head["branch_id"] and row.depth > copy_head["depth"]
|
|
]
|
|
assert ahead, "the retained future did not survive the round trip"
|
|
|
|
# And Redo is offered, rather than the story having silently been redone.
|
|
assert moved["copy"]["can_redo"] is True
|
|
assert moved["source"]["can_redo"] is True
|
|
|
|
|
|
def test_redo_after_import_walks_the_future_that_was_retained(moved, client):
|
|
"""Redo is coherent in the copy: it reaches the same next turn."""
|
|
before = client.post(f"/api/adventures/{moved['copy_id']}/redo")
|
|
assert before.status_code == 200, before.text[:300]
|
|
original = client.post(f"/api/adventures/{client.adv_id}/redo")
|
|
assert original.status_code == 200, original.text[:300]
|
|
assert _texts(client, moved["copy_id"]) == _texts(client, client.adv_id)
|
|
|
|
|
|
def test_a_file_written_before_the_head_was_carried_opens_at_its_tip(client):
|
|
"""I07's compatibility clause, and why it is not a degraded path.
|
|
|
|
Such a file was written when the head could not be anywhere but the tip, so
|
|
opening it there reproduces the position it actually recorded. Guessing some
|
|
other position for it would be the failure.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
stated = payload.pop("headDepth")
|
|
copy_id = bring_back(client, payload)
|
|
landed = _head(copy_id)
|
|
tip = max(
|
|
row.depth for row in _all_actions(copy_id)
|
|
if row.branch_id == landed["branch_id"]
|
|
)
|
|
assert landed["depth"] == tip
|
|
assert landed["depth"] > stated, "and the fixture's head really was behind it"
|
|
|
|
|
|
def test_a_head_beyond_the_story_the_file_carries_is_refused(client):
|
|
"""A file disagreeing with itself is refused, not opened at a guess."""
|
|
m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
detail = refuses(client, dict(payload, headDepth=payload["headDepth"] + 500))
|
|
assert "ends at" in detail
|
|
|
|
|
|
# ------------------------------------------------------------- I03, takes
|
|
|
|
def test_i03_both_futures_survive_and_stay_distinguishable(moved, client):
|
|
"""I03. The abandoned line comes back, still marked as abandoned."""
|
|
assert moved["copy"]["branch_count"] == moved["source"]["branch_count"] == 2
|
|
|
|
stories = _texts(client, moved["copy_id"], every_branch=True)
|
|
assert any("The crypt is dry" in text for text in stories), \
|
|
"the abandoned future is gone"
|
|
assert any("windows are lit" in text for text in stories), \
|
|
"the continuation the reader chose is gone"
|
|
|
|
# Disposition, not merely presence: the line the story left is still the
|
|
# line the story left, at the depth it left it.
|
|
with SessionLocal() as db:
|
|
branches = (
|
|
db.query(models.Branch)
|
|
.filter(models.Branch.adventure_id == moved["copy_id"])
|
|
.order_by(models.Branch.id).all()
|
|
)
|
|
superseded = [b for b in branches if b.superseded_at is not None]
|
|
assert len(superseded) == 1
|
|
assert superseded[0].superseded_depth == 6
|
|
|
|
|
|
def test_a_superseded_take_survives_and_the_selected_one_is_still_selected(moved):
|
|
"""No accepted row disappears for being inactive."""
|
|
source_takes = _takes(moved["source"]["id"])
|
|
copy_takes = _takes(moved["copy_id"])
|
|
assert copy_takes == source_takes
|
|
assert sum(1 for _, live in copy_takes if not live) == 1, \
|
|
"the retained alternate take is gone"
|
|
assert sum(1 for _, live in copy_takes if live) == 1
|
|
|
|
|
|
def test_the_takes_at_one_turn_are_still_grouped_as_one_turn(moved, client):
|
|
"""M9's parentage. The pager reads the same in the copy as in the original.
|
|
|
|
Version 2 exported no parentage, so every imported node landed parentless
|
|
and `attempts.group` fell back to the coordinate. That is right for a simple
|
|
retry and wrong as soon as two takes of one turn each have takes of their
|
|
own beneath them.
|
|
"""
|
|
assert _take_pagers(client, moved["copy_id"]) == \
|
|
_take_pagers(client, client.adv_id)
|
|
with SessionLocal() as db:
|
|
parented = (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == moved["copy_id"],
|
|
models.Action.parent_id.isnot(None))
|
|
.count()
|
|
)
|
|
assert parented > 0, "no imported node knows which turn it belongs to"
|
|
|
|
|
|
# ------------------------------------------------------------------------ I04
|
|
|
|
def test_i04_save_points_survive_with_their_names_notes_and_positions(moved):
|
|
assert moved["copy"]["checkpoints"] == moved["source"]["checkpoints"]
|
|
assert len(moved["copy"]["checkpoints"]) == 2
|
|
|
|
|
|
def test_each_restored_save_point_reaches_the_position_it_names(moved, client):
|
|
"""And restoring one uses M3's head movement, leaving later history alone."""
|
|
copy_id = moved["copy_id"]
|
|
before = len(_all_actions(copy_id))
|
|
for point in client.get(f"/api/adventures/{copy_id}/checkpoints").json():
|
|
assert point["resolved"] is True, f"{point['name']} resolves to nothing"
|
|
restored = client.post(
|
|
f"/api/adventures/{copy_id}/checkpoints/{point['id']}/restore"
|
|
)
|
|
assert restored.status_code == 200, restored.text[:300]
|
|
assert _head(copy_id)["depth"] == point["depth"]
|
|
assert len(_all_actions(copy_id)) == before, "restoring deleted history"
|
|
|
|
|
|
def test_the_two_save_points_restore_to_different_states(moved, client):
|
|
"""They name different positions, so they must restore different states."""
|
|
copy_id = moved["copy_id"]
|
|
seen = []
|
|
for point in client.get(f"/api/adventures/{copy_id}/checkpoints").json():
|
|
client.post(f"/api/adventures/{copy_id}/checkpoints/{point['id']}/restore")
|
|
seen.append(client.get(f"/api/adventures/{copy_id}/state").json()["document"])
|
|
assert seen[0] != seen[1]
|
|
|
|
|
|
def test_a_save_point_naming_a_position_the_file_does_not_hold_is_dropped(client):
|
|
"""Deliberate, documented, and never retargeted somewhere else.
|
|
|
|
A bad bookmark costs the bookmark. Refusing the campaign over it would lose
|
|
the story to save the bookmark, and moving it to a nearby turn would be an
|
|
invention — the reader named a position, and if that position is not in the
|
|
file then no other position is the one they named.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
payload["checkpoints"][0]["depth"] = 9999
|
|
copy_id = bring_back(client, payload)
|
|
names = [c["name"] for c in
|
|
client.get(f"/api/adventures/{copy_id}/checkpoints").json()]
|
|
assert len(names) == 1, "the good Save Point did not survive beside the bad one"
|
|
assert payload["checkpoints"][0]["name"] not in names
|
|
assert not any(
|
|
c["depth"] == 9999
|
|
for c in client.get(f"/api/adventures/{copy_id}/checkpoints").json()
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------- the state and its audit
|
|
|
|
def test_the_accepted_state_events_come_back_with_their_before_values(moved):
|
|
source_events = _events(moved["source"]["id"])
|
|
copy_events = _events(moved["copy_id"])
|
|
assert copy_events == source_events
|
|
assert len(copy_events) == 12
|
|
|
|
|
|
def test_a_manual_correction_is_still_identifiable_as_one(moved):
|
|
"""C04's authority survives the move.
|
|
|
|
This is the state change no narration explains, and with the events omitted
|
|
it was indistinguishable from something the story established — the copy
|
|
showed the fact and could not say who asserted it.
|
|
"""
|
|
corrections = [
|
|
event for event in _events(moved["copy_id"])
|
|
if event["source"] == "manual_correction"
|
|
]
|
|
assert len(corrections) == 1
|
|
assert corrections[0]["event_type"] == "add_fact"
|
|
assert corrections[0]["payload"]["predicate"] == "keeper_of_the_lantern"
|
|
|
|
|
|
def test_the_proposals_come_back_and_the_events_still_name_them(moved):
|
|
"""The two tables arrive linked, not merely both present."""
|
|
with SessionLocal() as db:
|
|
proposals = (
|
|
db.query(models.StateProposal)
|
|
.filter(models.StateProposal.adventure_id == moved["copy_id"])
|
|
.all()
|
|
)
|
|
events = (
|
|
db.query(models.StateEvent)
|
|
.filter(models.StateEvent.adventure_id == moved["copy_id"])
|
|
.all()
|
|
)
|
|
ids = {p.id for p in proposals}
|
|
assert len(proposals) == 12
|
|
linked = [e for e in events if e.proposal_id is not None]
|
|
assert linked, "every event lost the proposal that produced it"
|
|
assert all(e.proposal_id in ids for e in linked), \
|
|
"an event points at a proposal that is not in this campaign"
|
|
# And the proposals point at this campaign's own turns, not the source's.
|
|
theirs = {row.id for row in _all_actions(moved["copy_id"])}
|
|
assert all(p.action_id in theirs for p in proposals if p.action_id is not None)
|
|
|
|
|
|
def test_a_state_event_naming_a_turn_the_file_does_not_hold_is_refused(client):
|
|
"""Unlike a Save Point, and for a stated reason.
|
|
|
|
An audit record that quietly did not arrive leaves a campaign whose state
|
|
cannot be explained, and it is the explanation a reader goes looking for
|
|
exactly when something looks wrong.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
payload["stateEvents"][0]["action"] = 999_999
|
|
detail = refuses(client, payload)
|
|
assert "999999" in detail.replace(",", "")
|
|
|
|
|
|
def test_state_restoration_after_import_is_a_snapshot_read_not_a_replay(moved, client):
|
|
"""L02, and the bound M4 made load-bearing.
|
|
|
|
Undo, Redo and Save Point restore all resolve a coordinate and read the
|
|
state recorded there. If the import had dropped the per-position snapshots
|
|
and left only the events, every one of those would have to replay the
|
|
campaign — so the check is that each position in the copy holds the same
|
|
state as the same position in the original, walked the same way.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
walked_copy, walked_source = [], []
|
|
for _ in range(3):
|
|
for adv_id, out in ((copy_id, walked_copy), (client.adv_id, walked_source)):
|
|
assert client.post(f"/api/adventures/{adv_id}/undo").status_code == 200
|
|
out.append(client.get(f"/api/adventures/{adv_id}/state").json()["document"])
|
|
assert walked_copy == walked_source
|
|
for _ in range(3):
|
|
for adv_id, out in ((copy_id, walked_copy), (client.adv_id, walked_source)):
|
|
assert client.post(f"/api/adventures/{adv_id}/redo").status_code == 200
|
|
out.append(client.get(f"/api/adventures/{adv_id}/state").json()["document"])
|
|
assert walked_copy == walked_source
|
|
|
|
|
|
def test_the_abandoned_line_still_holds_its_own_different_state(moved, client):
|
|
"""E01, after a move. Two futures, two states, and neither leaks."""
|
|
copy_id = moved["copy_id"]
|
|
at_head = client.get(f"/api/adventures/{copy_id}/state").json()["document"]
|
|
assert at_head != moved["source"]["tip_state"], (
|
|
"the fixture's two futures must differ, or this proves nothing"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------- historical prompt provenance (§10)
|
|
|
|
def test_an_old_turn_can_still_show_what_it_was_actually_given(moved, client):
|
|
"""The M8 handoff, closed.
|
|
|
|
Inspect Context on a historical narrator turn in the *copy* returns the
|
|
prompt that turn was assembled from — the same sections, the same text — and
|
|
not a prompt rebuilt from the campaign as it stands now.
|
|
"""
|
|
source_turn, copy_turn = _first_narrator_turn(client, client.adv_id), \
|
|
_first_narrator_turn(client, moved["copy_id"])
|
|
original = _context_of(client, client.adv_id, source_turn)
|
|
restored = _context_of(client, moved["copy_id"], copy_turn)
|
|
assert restored["prompt"] == original["prompt"]
|
|
assert [s["label"] for s in restored["sections"]] == \
|
|
[s["label"] for s in original["sections"]]
|
|
assert restored["tokens"] == original["tokens"]
|
|
|
|
|
|
def test_the_old_turn_still_names_the_passages_it_was_shown(moved, client):
|
|
"""F06's provenance, restored. Including the text each passage supplied."""
|
|
turn = _first_narrator_turn(client, moved["copy_id"])
|
|
knowledge = _context_of(client, moved["copy_id"], turn)["knowledge"]
|
|
assert knowledge["used"], "the restored turn was shown no imported passage"
|
|
for record in knowledge["used"]:
|
|
assert record["text"], "a passage record arrived with no text"
|
|
assert record["classification"] in ("canon", "reference", "inspiration")
|
|
|
|
|
|
def test_the_evidence_outlives_the_source_being_deleted(moved, client):
|
|
"""`IMPORTED-KNOWLEDGE-DESIGN.md` §49-50, across a machine boundary.
|
|
|
|
The record holds the text rather than a pointer to a row that can go away,
|
|
so deleting the source in the copy leaves the old turn still able to say
|
|
what it was told — and the "open this source" link honestly goes quiet
|
|
rather than pointing somewhere else.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
turn = _first_narrator_turn(client, copy_id)
|
|
shown = _context_of(client, copy_id, turn)["knowledge"]["used"]
|
|
was_shown = {record["source_id"] for record in shown}
|
|
assert was_shown, "nothing was shown, so there is nothing to outlive"
|
|
|
|
for source_id in was_shown:
|
|
assert client.delete(
|
|
f"/api/adventures/{copy_id}/knowledge/{source_id}"
|
|
).status_code == 204
|
|
|
|
after = _context_of(client, copy_id, turn)["knowledge"]["used"]
|
|
assert [r["text"] for r in after] == [r["text"] for r in shown]
|
|
assert [r["title"] for r in after] == [r["title"] for r in shown]
|
|
|
|
|
|
def test_a_restored_retrieval_record_points_at_this_machines_source(moved, client):
|
|
"""The one pointer the import translates, and why.
|
|
|
|
`source_id` names a row on the machine that wrote the file. Left alone it
|
|
would point the inspector's "open this source" control at whatever holds
|
|
that id here — nothing, or somebody else's file.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
theirs = {
|
|
source["id"] for source in
|
|
client.get(f"/api/adventures/{copy_id}/knowledge").json()
|
|
}
|
|
turn = _first_narrator_turn(client, copy_id)
|
|
used = _context_of(client, copy_id, turn)["knowledge"]["used"]
|
|
named = [r["source_id"] for r in used if r["source_id"] is not None]
|
|
assert named, "no restored record names a source at all"
|
|
assert set(named) <= theirs, "a record points outside this campaign's library"
|
|
|
|
|
|
def test_a_snapshot_naming_a_source_the_file_does_not_carry_goes_quiet(moved, client):
|
|
"""A source deleted before the export was taken.
|
|
|
|
The record keeps its text and its filename and says `null` for the source,
|
|
which is the honest answer: this passage came from a file that is no longer
|
|
here, and here is what it said. It must not be pointed at a different file.
|
|
"""
|
|
payload = moved["bundle"]
|
|
assert any(m9_fixture.snapshot_in(a) for a in payload["actions"]), \
|
|
"the fixture carries no snapshot to edit"
|
|
edited = copy.deepcopy(payload)
|
|
rewritten = []
|
|
for action in edited["actions"]:
|
|
snapshot = m9_fixture.snapshot_in(action)
|
|
if not isinstance(snapshot, dict):
|
|
rewritten.append(action)
|
|
continue
|
|
for record in (snapshot.get("knowledge") or {}).get("used") or []:
|
|
record["source_id"] = 88_888
|
|
rewritten.append(m9_fixture.with_snapshot(action, snapshot))
|
|
edited["actions"] = rewritten
|
|
copy_id = bring_back(client, edited)
|
|
for row in _all_actions(copy_id):
|
|
snapshot = row.context_snapshot
|
|
if not isinstance(snapshot, dict):
|
|
continue
|
|
for record in (snapshot.get("knowledge") or {}).get("used") or []:
|
|
assert record["source_id"] is None
|
|
assert record["text"], "and the evidence itself is untouched"
|
|
|
|
|
|
def test_a_retried_turn_does_not_multiply_the_prompt(moved):
|
|
"""The prompt is stored once per turn, and travels once per turn.
|
|
|
|
A superseded take keeps only its own slices — its reply, its state proposal,
|
|
its token accounting — so a campaign someone retried twenty times does not
|
|
carry twenty copies of the largest thing in the file.
|
|
"""
|
|
superseded = [
|
|
snapshot for action in moved["bundle"]["actions"]
|
|
if not action.get("live", True)
|
|
for snapshot in [m9_fixture.snapshot_in(action)] if snapshot
|
|
]
|
|
assert superseded, "the fixture's retry left no superseded take with a record"
|
|
for take in superseded:
|
|
assert "sections" not in take
|
|
assert "prompt" not in take
|
|
|
|
|
|
# ------------------------------------------------------------------------ I05
|
|
|
|
def test_i05_the_library_survives_with_its_classifications_and_lifecycle(moved):
|
|
assert moved["copy"]["knowledge"] == moved["source"]["knowledge"]
|
|
assert len(moved["copy"]["knowledge"]) == 5
|
|
|
|
|
|
def test_the_disabled_source_is_still_disabled_and_the_hidden_one_still_hidden(
|
|
moved, client
|
|
):
|
|
library = client.get(f"/api/adventures/{moved['copy_id']}/knowledge").json()
|
|
by_file = {source["original_filename"]: source for source in library}
|
|
assert by_file["draft.md"]["enabled"] is False
|
|
assert by_file["secret.md"]["visibility"] == "hidden"
|
|
assert by_file["canon.md"]["always_include"] is True
|
|
assert by_file["canon.md"]["classification"] == "canon"
|
|
# And the filename really is only metadata: the title is the source's own,
|
|
# derived at import time from its content rather than from its path.
|
|
assert by_file["canon.md"]["title"] == "canon"
|
|
|
|
|
|
def test_the_copy_needs_no_original_file_and_is_searchable_at_once(moved, client):
|
|
"""§11. Nothing here reopens a path on the exporting machine.
|
|
|
|
The content came in the file, and the passages were rebuilt from it before
|
|
the import returned — so retrieval works with no reindex step and no
|
|
explanation owed to the reader.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
|
|
assert all(source["index_state"] == "ready" for source in library)
|
|
assert all(source["chunk_count"] > 0 for source in library)
|
|
# The filename is metadata and nothing else: it never became a path.
|
|
assert all("/" not in source["original_filename"] for source in library)
|
|
|
|
|
|
def test_the_disabled_source_stays_out_of_retrieval_after_the_move(moved, client):
|
|
report = client.get(f"/api/adventures/{moved['copy_id']}/context").json()
|
|
used = {record["filename"] for record in report["knowledge"]["used"]}
|
|
assert "draft.md" not in used
|
|
|
|
|
|
# ----------------------------------------------------- summaries and memories
|
|
|
|
def test_the_summaries_come_back_on_the_coordinates_that_hold_them(moved):
|
|
assert moved["copy"]["summaries"] == moved["source"]["summaries"]
|
|
assert len(moved["copy"]["summaries"]) == 2
|
|
|
|
|
|
def test_an_abandoned_lines_summary_is_still_ineligible_after_the_move(moved):
|
|
"""E03 does not get a second chance through the import.
|
|
|
|
The fixture writes one summary on the line it later abandons and one at the
|
|
head it keeps. Both travel. Eligibility is not a stored flag — it is whether
|
|
the coordinate lies on the active capped lineage — so restoring the
|
|
coordinates restores the answer, including the "no".
|
|
"""
|
|
eligible = {
|
|
(text, trigger): is_eligible
|
|
for text, trigger, is_eligible in moved["copy"]["summaries"]
|
|
}
|
|
assert sorted(eligible.values()) == [False, True], (
|
|
"the copy should hold exactly one eligible and one ineligible summary"
|
|
)
|
|
assert eligible[("Aldric went back to the Lantern instead.", "manual")] is True
|
|
|
|
|
|
def test_the_abandoned_summary_does_not_reach_the_copys_next_prompt(moved, client):
|
|
"""E03, measured where it matters: the prompt the copy would send next.
|
|
|
|
The generated summary sits on the line the fixture abandoned, and the typed
|
|
one sits at the head. Only the second may reach a prompt. The provenance
|
|
record names the coordinate rather than carrying the text, so eligibility is
|
|
checked there and the leak is checked in the assembled prompt itself.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
report = client.get(f"/api/adventures/{copy_id}/context").json()
|
|
prompt = report["prompt"]["system"] + report["prompt"]["story"]
|
|
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, copy_id)
|
|
entitled = summaries.current(db, adventure)
|
|
every = {row.id: row for row in summaries.all_for(db, adventure)}
|
|
assert entitled is not None, "the copy is entitled to no summary at all"
|
|
assert entitled.trigger == "manual", "the abandoned line's summary is eligible"
|
|
|
|
abandoned = [row for row in every.values() if row.id != entitled.id]
|
|
assert abandoned, "the fixture's abandoned summary did not survive the move"
|
|
for row in abandoned:
|
|
assert row.text not in prompt, "an abandoned summary reached the prompt"
|
|
assert report["summary"]["id"] == entitled.id
|
|
|
|
|
|
def test_the_summary_mirror_agrees_with_the_lineage_after_import(moved, client):
|
|
"""M6-F1's defect must not arrive by a new route."""
|
|
copy_head = client.get(f"/api/adventures/{moved['copy_id']}").json()
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, moved["copy_id"])
|
|
entitled = summaries.current(db, adventure)
|
|
assert copy_head["story_summary"] == (entitled.text if entitled else "")
|
|
|
|
|
|
def test_memory_authority_survives_rather_than_being_promoted(client):
|
|
"""F07. A heuristic memory must not become accepted story by being moved."""
|
|
m9_fixture.build(client, client.adv_id)
|
|
with SessionLocal() as db:
|
|
memory = (
|
|
db.query(models.Memory)
|
|
.filter(models.Memory.adventure_id == client.adv_id)
|
|
.order_by(models.Memory.id).first()
|
|
)
|
|
memory.authority = memorybank.HEURISTIC
|
|
db.commit()
|
|
copy_id = bring_back(client, export(client))
|
|
authorities = [row.authority for row in rows(copy_id, models.Memory)]
|
|
assert memorybank.HEURISTIC in authorities
|
|
assert authorities == [row.authority for row in rows(client.adv_id, models.Memory)]
|
|
|
|
|
|
def test_a_memory_comes_back_on_the_node_it_hangs_off(moved):
|
|
source_rows = [(m.text, m.depth) for m in rows(moved["source"]["id"], models.Memory)]
|
|
copy_rows = [(m.text, m.depth) for m in rows(moved["copy_id"], models.Memory)]
|
|
assert copy_rows == source_rows
|
|
|
|
|
|
# ---------------------------------------------------- L04, derived data rebuild
|
|
|
|
def test_l04_deleting_the_derived_indexes_and_rebuilding_changes_no_story(
|
|
moved, client
|
|
):
|
|
"""L04, on a copy rather than on the original.
|
|
|
|
Every physically derived structure is destroyed — passages, the FTS rows and
|
|
the vectors — and rebuilt from the source content the bundle carried. The
|
|
authoritative campaign must be identical either side, and retrieval must
|
|
work again afterwards.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
before = m9_fixture.snapshot_of(client, copy_id)
|
|
with SessionLocal() as db:
|
|
db.query(models.KnowledgeEmbedding).filter(
|
|
models.KnowledgeEmbedding.adventure_id == copy_id
|
|
).delete(synchronize_session=False)
|
|
db.query(models.KnowledgeChunk).filter(
|
|
models.KnowledgeChunk.adventure_id == copy_id
|
|
).delete(synchronize_session=False)
|
|
db.commit()
|
|
|
|
empty = client.get(f"/api/adventures/{copy_id}/knowledge").json()
|
|
assert all(source["chunk_count"] == 0 for source in empty)
|
|
|
|
rebuilt = client.post(f"/api/adventures/{copy_id}/knowledge/reindex")
|
|
assert rebuilt.status_code == 200, rebuilt.text[:300]
|
|
|
|
after = client.get(f"/api/adventures/{copy_id}/knowledge").json()
|
|
assert all(source["chunk_count"] > 0 for source in after)
|
|
assert all(source["index_state"] == "ready" for source in after)
|
|
# Content, classification and lifecycle are untouched by a rebuild.
|
|
assert m9_fixture.snapshot_of(client, copy_id)["knowledge"] == before["knowledge"]
|
|
# And the authoritative campaign did not move.
|
|
assert m9_fixture.snapshot_of(client, copy_id)["transcript"] == before["transcript"]
|
|
assert m9_fixture.snapshot_of(client, copy_id)["state"] == before["state"]
|
|
# Retrieval works again.
|
|
report = client.get(f"/api/adventures/{copy_id}/context").json()
|
|
assert report["knowledge"]["used"]
|
|
|
|
|
|
def test_losing_the_semantic_half_leaves_the_lexical_half_working(moved, client):
|
|
"""M7's rule, which M9 must not regress during a rebuild.
|
|
|
|
Lexical retrieval is a supported production path, not a fallback, so a
|
|
campaign whose vectors are gone still finds its Canon.
|
|
"""
|
|
copy_id = moved["copy_id"]
|
|
with SessionLocal() as db:
|
|
db.query(models.KnowledgeEmbedding).filter(
|
|
models.KnowledgeEmbedding.adventure_id == copy_id
|
|
).delete(synchronize_session=False)
|
|
db.commit()
|
|
report = client.get(f"/api/adventures/{copy_id}/context").json()
|
|
assert report["knowledge"]["used"], "lexical retrieval stopped with the vectors"
|
|
|
|
|
|
def test_deleting_a_campaign_takes_its_lexical_index_with_it(moved, client):
|
|
"""M9 finding: the FTS index is a virtual table and nothing cascades into it.
|
|
|
|
Deleting a campaign dropped its passages and left one index row per passage
|
|
behind. Nothing read them — the search joins through `knowledge_chunks`, and
|
|
those were gone — so the leak was invisible until the id came round again.
|
|
"""
|
|
from sqlalchemy import text as sql
|
|
|
|
copy_id = moved["copy_id"]
|
|
with SessionLocal() as db:
|
|
before = db.execute(sql("SELECT COUNT(*) FROM knowledge_fts")).scalar()
|
|
assert before > 0
|
|
assert client.delete(f"/api/adventures/{copy_id}").status_code == 204
|
|
with SessionLocal() as db:
|
|
orphans = db.execute(sql("""
|
|
SELECT COUNT(*) FROM knowledge_fts
|
|
WHERE rowid NOT IN (SELECT id FROM knowledge_chunks)
|
|
""")).scalar()
|
|
assert orphans == 0
|
|
|
|
|
|
def test_an_orphaned_index_row_does_not_break_the_next_import(client):
|
|
"""The other half, and the one that repairs a database already damaged.
|
|
|
|
SQLite hands out the lowest free primary key, so an orphan left by an older
|
|
build is met head-on by the next campaign to import anything — in a campaign
|
|
with no connection to the one that leaked it. It used to be a 500 from an
|
|
ordinary upload, and Reindex could not clear it either, because
|
|
`clear_index` finds index rows through chunks that no longer exist.
|
|
"""
|
|
from sqlalchemy import text as sql
|
|
|
|
# An orphan, written the way a pre-M9 build would have left one.
|
|
with SessionLocal() as db:
|
|
db.execute(
|
|
sql("INSERT OR REPLACE INTO knowledge_fts (rowid, text) "
|
|
"VALUES (1, 'left behind by a deleted campaign')")
|
|
)
|
|
db.commit()
|
|
|
|
landed = m9_fixture.upload(
|
|
client, client.adv_id, "canon.md", m9_fixture.CANON_MD, "canon",
|
|
)
|
|
library = client.get(f"/api/adventures/{client.adv_id}/knowledge").json()
|
|
source = next(s for s in library if s["id"] == landed)
|
|
assert source["index_state"] == "ready"
|
|
assert source["chunk_count"] > 0
|
|
# And what the orphan said is gone rather than searchable.
|
|
with SessionLocal() as db:
|
|
stale = db.execute(sql(
|
|
"SELECT COUNT(*) FROM knowledge_fts WHERE text LIKE '%left behind%'"
|
|
)).scalar()
|
|
assert stale == 0
|
|
|
|
|
|
def test_reindex_repairs_an_index_that_lost_its_passages(client):
|
|
"""L04's repair path, exercised against the damage it exists to repair.
|
|
|
|
Deleting the chunk rows without the index rows is what a cascade used to do,
|
|
and Reindex is documented as the repair. It has to actually be one.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
with SessionLocal() as db:
|
|
db.query(models.KnowledgeChunk).filter(
|
|
models.KnowledgeChunk.adventure_id == client.adv_id
|
|
).delete(synchronize_session=False)
|
|
db.commit()
|
|
|
|
rebuilt = client.post(f"/api/adventures/{client.adv_id}/knowledge/reindex")
|
|
assert rebuilt.status_code == 200, rebuilt.text[:400]
|
|
assert rebuilt.json()["failed"] == []
|
|
library = client.get(f"/api/adventures/{client.adv_id}/knowledge").json()
|
|
assert all(source["index_state"] == "ready" for source in library)
|
|
assert all(source["chunk_count"] > 0 for source in library)
|
|
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
|
assert report["knowledge"]["used"]
|
|
|
|
|
|
def test_a_rebuilt_index_does_not_reseed_an_abandoned_summary(moved, client):
|
|
"""M6's leak must not return through the rebuild path either."""
|
|
copy_id = moved["copy_id"]
|
|
client.post(f"/api/adventures/{copy_id}/knowledge/reindex")
|
|
eligible = {
|
|
(text, trigger): is_eligible
|
|
for text, trigger, is_eligible in
|
|
m9_fixture.snapshot_of(client, copy_id)["summaries"]
|
|
}
|
|
assert sorted(eligible.values()) == [False, True]
|
|
|
|
|
|
# ------------------------------------------------------------- story cards (§13)
|
|
|
|
def test_story_cards_still_travel_in_both_directions(client):
|
|
"""Compatibility-only, but compatibility means a round trip loses nothing."""
|
|
with SessionLocal() as db:
|
|
db.add(models.StoryCard(
|
|
adventure_id=client.adv_id, type="character", name="Gwen",
|
|
keys="gwen, innkeeper", entry="Gwen keeps the road-house.",
|
|
notes="From an older campaign.",
|
|
))
|
|
db.commit()
|
|
payload = export(client)
|
|
assert payload["storyCards"] == [{
|
|
"type": "character", "name": "Gwen", "keys": "gwen, innkeeper",
|
|
"entry": "Gwen keeps the road-house.", "notes": "From an older campaign.",
|
|
}]
|
|
copy_id = bring_back(client, payload)
|
|
assert [(c.name, c.entry) for c in rows(copy_id, models.StoryCard)] == \
|
|
[("Gwen", "Gwen keeps the road-house.")]
|
|
|
|
|
|
def test_a_story_card_no_longer_reaches_the_narrator(client):
|
|
"""§73: not an alternate untracked path around knowledge authority.
|
|
|
|
Before M9 a keyword match put `World Lore: <entry>` in front of the narrator
|
|
with no class, no visibility, no source, no way to switch it off and no row
|
|
in the context inspector. The rows stay and the round trip stays; the
|
|
injection does not.
|
|
"""
|
|
with SessionLocal() as db:
|
|
db.add(models.StoryCard(
|
|
adventure_id=client.adv_id, type="location", name="The road-house",
|
|
keys="road-house, roadhouse",
|
|
entry="The road-house at Fen Cross has burned down.",
|
|
))
|
|
db.commit()
|
|
m9_fixture._play(client, client.adv_id, "ask about the road-house",
|
|
"She shrugs and pours another.")
|
|
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
|
prompt = report["prompt"]["system"] + report["prompt"]["story"]
|
|
assert "World Lore" not in prompt
|
|
assert "has burned down" not in prompt
|
|
assert report["cards"] == []
|
|
|
|
|
|
def test_a_historical_snapshot_that_carried_cards_still_renders_them(client):
|
|
"""The `cards` key stays in the report shape, for the old evidence.
|
|
|
|
An old turn's record says story cards were included, and M9 has just made
|
|
that record portable. Removing the key would make a restored campaign's own
|
|
history unreadable.
|
|
"""
|
|
m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
for position, action in enumerate(payload["actions"]):
|
|
snapshot = m9_fixture.snapshot_in(action)
|
|
if isinstance(snapshot, dict) and "cards" in snapshot:
|
|
snapshot["cards"] = [
|
|
{"id": 7, "name": "Gwen", "keyword": "gwen", "included": True}
|
|
]
|
|
payload["actions"][position] = m9_fixture.with_snapshot(action, snapshot)
|
|
break
|
|
else:
|
|
pytest.fail("no snapshot in the fixture bundle to edit")
|
|
copy_id = bring_back(client, payload)
|
|
found = [
|
|
row.context_snapshot["cards"] for row in _all_actions(copy_id)
|
|
if isinstance(row.context_snapshot, dict)
|
|
and row.context_snapshot.get("cards")
|
|
]
|
|
assert found == [[{"id": 7, "name": "Gwen", "keyword": "gwen", "included": True}]]
|
|
|
|
|
|
# ----------------------------------------------- scene / media metadata (§14)
|
|
|
|
def test_the_scene_section_of_the_state_document_round_trips(moved):
|
|
"""There are no media tables in this build, and none were invented.
|
|
|
|
What exists is the `scene` section of the authoritative narrative state
|
|
document — `SPECIFICATION.md` §14's scene snapshot as this build represents
|
|
it — and it travels with the document, per position, like the rest of it.
|
|
"""
|
|
for action in moved["bundle"]["actions"]:
|
|
state = action.get("narrativeStateAfter")
|
|
if isinstance(state, dict):
|
|
assert "scene" in state
|
|
break
|
|
else:
|
|
pytest.fail("no per-position state document in the bundle")
|
|
assert "scene" in moved["bundle"]["narrativeState"]
|
|
|
|
|
|
# ------------------------------------------------------- I06 and local-only
|
|
|
|
def test_i06_the_export_carries_no_credential_of_any_kind(moved, client):
|
|
"""I06. Checked against the file's text, not against a list of columns.
|
|
|
|
The application needs no cloud key, which makes this easy — and is exactly
|
|
why it is worth testing rather than assuming. The inert `api_key` column is
|
|
still in the schema, and a future field could reach the bundle by being
|
|
added to a model the exporter walks.
|
|
"""
|
|
import json
|
|
|
|
with SessionLocal() as db:
|
|
settings = db.query(models.Settings).first()
|
|
settings.api_key = "enc:this-must-never-be-exported"
|
|
settings.endpoint_url = "http://127.0.0.1:11434/v1"
|
|
db.commit()
|
|
|
|
text = json.dumps(export(client))
|
|
assert "enc:this-must-never-be-exported" not in text
|
|
assert "api_key" not in text
|
|
assert "apiKey" not in text
|
|
# And no endpoint, model host or absolute filesystem path travels either.
|
|
assert "11434" not in text
|
|
assert "/home/" not in text
|
|
|
|
|
|
def test_the_bundle_names_no_path_that_could_become_one(moved):
|
|
"""H08, for the import direction. A filename is metadata, never a path."""
|
|
for source in moved["bundle"]["knowledge"]:
|
|
assert "/" not in source["originalFilename"]
|
|
assert "\\" not in source["originalFilename"]
|
|
assert ".." not in source["originalFilename"]
|
|
|
|
|
|
def test_a_bundle_carrying_a_traversal_filename_is_sanitised(client):
|
|
m9_fixture.build(client, client.adv_id)
|
|
payload = export(client)
|
|
payload["knowledge"][0]["originalFilename"] = "../../../etc/passwd"
|
|
copy_id = bring_back(client, payload)
|
|
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
|
|
assert all(".." not in source["original_filename"] for source in library)
|
|
assert all("/" not in source["original_filename"] for source in library)
|
|
|
|
|
|
# ------------------------------------------------------------------- helpers
|
|
|
|
def _texts(client, adv_id, every_branch=False) -> list[str]:
|
|
if not every_branch:
|
|
return [a["text"] for a in
|
|
client.get(f"/api/adventures/{adv_id}").json()["actions"]]
|
|
out = []
|
|
for branch in client.get(f"/api/adventures/{adv_id}/branches").json():
|
|
client.post(f"/api/adventures/{adv_id}/branches/{branch['id']}/switch")
|
|
out.extend(_texts(client, adv_id))
|
|
return out
|
|
|
|
|
|
def _head(adv_id) -> dict:
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, adv_id)
|
|
return {"branch_id": adventure.head_branch_id, "depth": adventure.head_depth}
|
|
|
|
|
|
def _all_actions(adv_id) -> list[models.Action]:
|
|
with SessionLocal() as db:
|
|
from sqlalchemy.orm import undefer
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == adv_id)
|
|
.options(undefer(models.Action.context_snapshot))
|
|
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
|
|
|
|
def _takes(adv_id) -> list[tuple[str, bool]]:
|
|
"""Every attempt at the retried turn, as text and liveness."""
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, adv_id)
|
|
rows_at = (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == adv_id,
|
|
models.Action.type == "ai")
|
|
.order_by(models.Action.branch_id, models.Action.depth,
|
|
models.Action.id)
|
|
.all()
|
|
)
|
|
seen = {}
|
|
for row in rows_at:
|
|
seen.setdefault((row.branch_id, row.depth), []).append(row)
|
|
for group in seen.values():
|
|
if len(group) > 1:
|
|
return [(row.text, bool(row.live)) for row in group]
|
|
return []
|
|
|
|
|
|
def _take_pagers(client, adv_id) -> list[tuple[int, int]]:
|
|
"""The take pager under every narrator message, as (position, total)."""
|
|
page = client.get(f"/api/adventures/{adv_id}").json()
|
|
return [
|
|
(action.get("take_index"), action.get("take_count"))
|
|
for action in page["actions"] if action["type"] == "ai"
|
|
]
|
|
|
|
|
|
def _events(adv_id) -> list[dict]:
|
|
"""The accepted state events, with the ids left out."""
|
|
with SessionLocal() as db:
|
|
return [
|
|
{"event_type": row.event_type, "payload": row.payload,
|
|
"before": row.before, "source": row.source, "depth": row.depth,
|
|
"sequence": row.sequence}
|
|
for row in db.query(models.StateEvent)
|
|
.filter(models.StateEvent.adventure_id == adv_id)
|
|
.order_by(models.StateEvent.id).all()
|
|
]
|
|
|
|
|
|
def _first_narrator_turn(client, adv_id) -> int:
|
|
"""The id of the earliest narrator action that has a stored prompt."""
|
|
for row in _all_actions(adv_id):
|
|
if row.type == "ai" and row.live and isinstance(row.context_snapshot, dict) \
|
|
and row.context_snapshot.get("prompt"):
|
|
return row.id
|
|
raise AssertionError(f"campaign {adv_id} has no turn with a stored prompt")
|
|
|
|
|
|
def _context_of(client, adv_id, action_id) -> dict:
|
|
response = client.get(f"/api/adventures/{adv_id}/actions/{action_id}/context")
|
|
assert response.status_code == 200, response.text[:300]
|
|
return response.json()
|