M10: the seam for media, and no media
The media extension contract asks for a scene snapshot a future image or video provider could be handed: location, who is present, what they hold, what must stay true, and where in the story it sits. Building one was the milestone's obvious first task, and it was the wrong one. That snapshot has existed since M5. `narrative_state["scene"]` holds the summary, the location, the cast and the coordinate it was written at; a validated `set_scene` event writes it, every position snapshots it, and every head move restores it. It survives Undo, Redo, Retry, divergence, Save Point restore and a process restart because it is the authoritative state rather than a copy of it. So there is no scenes table here. A second scene store would have been a second answer to "where is the story now", with its own lineage rules to get wrong — and the lineage rules are the expensive part, which is the argument for reusing the ones that already work rather than against it. The Scene Packet is derived on read, and its identity is computed from the campaign and the position rather than allocated: the same position yields the same id in another process, after a restart, and after the packet is thrown away and rebuilt, with no row to keep in step. That is the part of a future media_assets table that would be expensive to retrofit, so it is fixed now even though the table is not built. One table, then: visual_profiles, the only thing the contract's scene list asks for that nothing already stored. Campaign-scoped and not per-position, because a character does not change appearance when the story forks — a reader who diverged would otherwise lose their cast, and the same descriptors would land in every per-position snapshot, measured at 245 copies of 367 bytes in a 120-turn campaign to say something that never varies. Keyed by the M5 entity key rather than a new identity namespace, and one table for characters, locations and items alike, because a location is an entity with a type and splitting them would reintroduce the genre shape M5 spent a milestone removing. What the packet leaves out is the more interesting half. Not the transcript, and not imported knowledge — none of it, not merely the sources marked hidden. The rule is what the story established at this position, not everything the narrator was told, and drawing it by class is what makes it hold for a secret nobody thought to mark. A hidden Canon source proves it, with a positive control showing the narrator did receive the sentinel the packet does not carry. Once a validated event puts the observer in the room, the observer is in the packet: that is no longer narrator-only knowledge, and a packet that hid it would be hiding the story from itself. The providers are contracts and nothing else. Protocols for image, video, audio, speech and transcription, an empty registry, no adapter, no dependency, no socket, and no media setting to point anywhere — a setting that exists can be pointed at a cloud by mistake. A future provider endpoint must be loopback, stricter than narration's trusted-LAN allowance, because a picture of a scene carries the scene with it. Transcription returns an editable draft with no commit method, so STT structurally cannot bypass the authoritative path. Nothing here can write the story. Not by convention: no module under media/ imports the code that writes state, no media event type exists in the state vocabulary, and every test in the authority suite compares the authoritative document byte for byte either side of a media operation — including one where a provider insists Alice is in a red coat in a corridor, and the campaign goes on disagreeing. One defect, found by the milestone's own tests. M10 first added a migration creating an index that create_all already builds from the column, so an upgraded database ended up with two indexes and a fresh install with one. Comparing the two schemas is what caught it; neither database examined alone would have. The migration is gone rather than renamed, and the right number of migrations for a new table whose indexes are declared on its columns is zero. Backend 1,191 passed / 14 skipped / 0 failed, 89 of them M10's. Frontend 145 passed. Lint, production build and Docker build clean. No frontend file changed: M10 adds no reader-facing surface, and ordinary play — turns, state, memory, knowledge, Undo, Redo, Retry, Save Point restore, restart — runs with no media configuration, no warning, no connection attempt and no media row written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
This commit is contained in:
co-authored by
Claude Opus 5
parent
44edece67e
commit
1013c94eb1
@@ -0,0 +1,136 @@
|
||||
"""M10: a campaign with a scene worth depicting, deliberately not a fantasy one.
|
||||
|
||||
The M10 brief asks for at least one non-fantasy representation, and the reason
|
||||
is a real risk rather than a preference: the media contract's own examples are
|
||||
fantasy-shaped — hair and eyes, timber framing, oil lamps — and a schema written
|
||||
while looking at them can acquire that shape without anyone deciding to give it
|
||||
one. So the fixture is four people in an office, and the same code has to hold
|
||||
it with no change.
|
||||
|
||||
Bill the protagonist
|
||||
Alice a coworker, with a visual profile
|
||||
Roger a coworker, with no profile at all
|
||||
John a coworker who is not in the room
|
||||
|
||||
the office a location, with a visual profile
|
||||
a badge an item Bill is carrying
|
||||
the server room a second location, for divergence
|
||||
|
||||
The cast is the one from the post-M8 playtest finding, and that is deliberate
|
||||
too — but only as *shape*. M10 does not investigate that finding, and nothing
|
||||
here asserts anything about coreference; it is M11's, and §23 of the brief says
|
||||
so. What the shape buys here is a scene with three present characters and one
|
||||
absent, which is what makes "the packet describes who is in the room" a claim
|
||||
with a wrong answer available.
|
||||
|
||||
Roger having no profile is load-bearing: it is how the tests tell "no profile"
|
||||
from "an empty profile", which a future provider has to be able to distinguish.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fakes import ScriptedProvider, state_block
|
||||
|
||||
#: A narrator-only secret, used by the hidden-information tests. It is imported
|
||||
#: as an M7 hidden knowledge source — the product's real mechanism for
|
||||
#: narrator-only material — rather than as an invented marker, so the test
|
||||
#: exercises the boundary that actually exists.
|
||||
SECRET_SENTINEL = "ZARQUON-CONCEALED-OBSERVER-7731"
|
||||
|
||||
SECRET_MD = f"""# What nobody in the room knows
|
||||
|
||||
There is a concealed observer behind the north wall of the office, watching the
|
||||
meeting through a gap in the panelling. Their code name is {SECRET_SENTINEL}.
|
||||
|
||||
Nobody present is aware of this.
|
||||
"""
|
||||
|
||||
#: A source that is *not* hidden, so a test can show the packet excludes
|
||||
#: imported knowledge as a class rather than only excluding secrets.
|
||||
HANDBOOK_MD = """# Office handbook
|
||||
|
||||
The building was refurbished in the spring. The north wall panelling is new.
|
||||
"""
|
||||
|
||||
|
||||
def play(client, adv_id, text, events, prose="The meeting continues."):
|
||||
ScriptedProvider.replies = [f"{prose}\n" + state_block(events)]
|
||||
response = client.post(
|
||||
f"/api/adventures/{adv_id}/actions", json={"type": "do", "text": text}
|
||||
)
|
||||
assert response.status_code == 200, response.text[:400]
|
||||
return response
|
||||
|
||||
|
||||
def entity(key, kind, name):
|
||||
return {"type": "create_entity", "entity": key, "entity_type": kind,
|
||||
"name": name}
|
||||
|
||||
|
||||
def build(client, adv_id) -> dict:
|
||||
"""Plays the office campaign and returns what a test needs to check it.
|
||||
|
||||
Leaves the campaign with a scene set at the active head, two visual
|
||||
profiles, one character deliberately unprofiled, and one character
|
||||
deliberately not present.
|
||||
"""
|
||||
play(client, adv_id, "arrive at the office", [
|
||||
entity("bill", "character", "Bill"),
|
||||
entity("alice", "character", "Alice"),
|
||||
entity("roger", "character", "Roger"),
|
||||
entity("john", "character", "John"),
|
||||
entity("office", "location", "The office"),
|
||||
entity("server_room", "location", "The server room"),
|
||||
entity("badge", "item", "Security badge"),
|
||||
])
|
||||
play(client, adv_id, "start the meeting", [
|
||||
{"type": "set_possession", "item": "badge", "owner": "bill"},
|
||||
{"type": "set_scene",
|
||||
"summary": "Bill, Alice and Roger meet around the table.",
|
||||
"location": "office",
|
||||
"present": ["bill", "alice", "roger"]},
|
||||
])
|
||||
|
||||
profiles = {
|
||||
"alice": {
|
||||
"descriptors": {"build": "tall", "hair": "short black",
|
||||
"clothing": "grey blazer"},
|
||||
"features": ["tortoiseshell glasses"],
|
||||
"style_notes": "photographic, natural light",
|
||||
},
|
||||
"office": {
|
||||
"descriptors": {"architecture": "open-plan floor",
|
||||
"lighting": "flat fluorescent"},
|
||||
"features": ["whiteboard covered in diagrams"],
|
||||
"style_notes": "",
|
||||
},
|
||||
}
|
||||
for key, profile in profiles.items():
|
||||
response = client.put(
|
||||
f"/api/adventures/{adv_id}/visual-profiles/{key}", json=profile
|
||||
)
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
return {"profiles": profiles}
|
||||
|
||||
|
||||
def upload_secret(client, adv_id) -> int:
|
||||
"""Imports the narrator-only source the hidden-information tests use."""
|
||||
return _upload(client, adv_id, "observer.md", SECRET_MD, "canon",
|
||||
visibility="hidden")
|
||||
|
||||
|
||||
def upload_handbook(client, adv_id) -> int:
|
||||
return _upload(client, adv_id, "handbook.md", HANDBOOK_MD, "reference")
|
||||
|
||||
|
||||
def _upload(client, adv_id, name, body, classification, **fields):
|
||||
data = {"classification": classification}
|
||||
data.update({k: str(v).lower() if isinstance(v, bool) else str(v)
|
||||
for k, v in fields.items()})
|
||||
response = client.post(
|
||||
f"/api/adventures/{adv_id}/knowledge",
|
||||
files={"file": (name, body.encode("utf-8"), "text/markdown")},
|
||||
data=data,
|
||||
)
|
||||
assert response.status_code == 201, response.text[:400]
|
||||
return response.json()["id"]
|
||||
@@ -0,0 +1,342 @@
|
||||
"""M10 §6 and §18: the media layer cannot write the story.
|
||||
|
||||
The architectural claim is one sentence — *media is derived presentation, story
|
||||
state is authoritative, and there is no reverse path* — and this file is the
|
||||
part of it that is checked by running things rather than by reading imports.
|
||||
|
||||
Every test here follows the same shape, which is the shape that makes it
|
||||
evidence rather than assertion:
|
||||
|
||||
record the authoritative document, byte for byte
|
||||
do the media-layer thing
|
||||
record it again
|
||||
require them to be identical
|
||||
|
||||
That catches a write nobody intended as well as one somebody did, and it does
|
||||
not depend on knowing *how* a violation would have happened.
|
||||
|
||||
`test_m10_media_hooks.py` covers what the boundary carries; this covers what it
|
||||
must never push back through.
|
||||
|
||||
python -m pytest tests/test_m10_authority.py -v
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, memorybank, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.knowledge import embeddings
|
||||
from app.main import app
|
||||
from app.media import packet as scene_packet
|
||||
from app.media import profiles as visual_profiles
|
||||
from app.media import providers
|
||||
from app.routers import adventures
|
||||
|
||||
import m10_fixture
|
||||
from fakes import ScriptedProvider
|
||||
|
||||
|
||||
class StubDerived:
|
||||
async def complete(self, system, prompt, **kwargs):
|
||||
return "A memory."
|
||||
|
||||
async def embed(self, texts):
|
||||
return [[1.0, 0.5, 0.25] for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
memorybank._vector_cache.clear()
|
||||
embeddings._cache.clear()
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="m10auth@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(
|
||||
user_id=user.id, model="test-model", embedding_model="",
|
||||
context_token_budget=4000, max_output_tokens=400,
|
||||
))
|
||||
adventure = models.Adventure(user_id=user.id, title="Authority")
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id, type="start", text="It begins.",
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.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
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def office(client):
|
||||
return m10_fixture.build(client, client.adv_id)
|
||||
|
||||
|
||||
def authoritative(adv_id) -> dict:
|
||||
"""Everything the story counts as true, read straight from the database."""
|
||||
with SessionLocal() as db:
|
||||
adventure = db.get(models.Adventure, adv_id)
|
||||
return {
|
||||
"state": copy.deepcopy(adventure.narrative_state),
|
||||
"head_branch": adventure.head_branch_id,
|
||||
"head_depth": adventure.head_depth,
|
||||
"events": db.query(models.StateEvent).filter(
|
||||
models.StateEvent.adventure_id == adv_id).count(),
|
||||
"proposals": db.query(models.StateProposal).filter(
|
||||
models.StateProposal.adventure_id == adv_id).count(),
|
||||
"actions": db.query(models.Action).filter(
|
||||
models.Action.adventure_id == adv_id).count(),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------ writes that must not
|
||||
|
||||
def test_writing_a_visual_profile_changes_no_story_state(client, office):
|
||||
before = authoritative(client.adv_id)
|
||||
response = client.put(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/bill",
|
||||
json={"descriptors": {"build": "heavyset", "clothing": "navy suit"},
|
||||
"features": ["signet ring"], "style_notes": "photographic"},
|
||||
)
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
assert authoritative(client.adv_id) == before
|
||||
|
||||
|
||||
def test_updating_a_visual_profile_creates_no_state_fact(client, office):
|
||||
"""§6's example, made concrete.
|
||||
|
||||
A profile saying Alice wears a blue coat must not make it true that Alice
|
||||
owns or wears a blue coat. Checked by looking for the words in the
|
||||
authoritative document afterwards, not only by comparing counts.
|
||||
"""
|
||||
before = authoritative(client.adv_id)
|
||||
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/alice",
|
||||
json={"descriptors": {"clothing": "blue coat"}})
|
||||
after = authoritative(client.adv_id)
|
||||
assert after == before
|
||||
assert "blue coat" not in repr(after["state"])
|
||||
|
||||
document = client.get(
|
||||
f"/api/adventures/{client.adv_id}/state").json()["document"]
|
||||
assert not any("blue coat" in repr(f) for f in document["facts"])
|
||||
assert "blue coat" not in repr(document["entities"]["alice"])
|
||||
|
||||
|
||||
def test_deleting_a_visual_profile_changes_no_story_state(client, office):
|
||||
before = authoritative(client.adv_id)
|
||||
assert client.delete(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
|
||||
).status_code == 204
|
||||
assert authoritative(client.adv_id) == before
|
||||
|
||||
|
||||
def test_building_a_scene_packet_changes_nothing(client, office):
|
||||
"""A packet is a read. Built repeatedly, it must still be a read."""
|
||||
before = authoritative(client.adv_id)
|
||||
for _ in range(5):
|
||||
assert client.get(
|
||||
f"/api/adventures/{client.adv_id}/scene-packet"
|
||||
).status_code == 200
|
||||
assert authoritative(client.adv_id) == before
|
||||
|
||||
|
||||
def test_a_scene_packet_does_not_move_the_head(client, office):
|
||||
before = authoritative(client.adv_id)
|
||||
client.get(f"/api/adventures/{client.adv_id}/scene-packet?start=0&end=4")
|
||||
after = authoritative(client.adv_id)
|
||||
assert after["head_branch"] == before["head_branch"]
|
||||
assert after["head_depth"] == before["head_depth"]
|
||||
|
||||
|
||||
def test_a_dummy_media_result_cannot_reach_the_story(client, office):
|
||||
"""§18: adding a depiction, even a wrong one, changes nothing.
|
||||
|
||||
The result claims Alice is wearing a red coat and standing in a corridor.
|
||||
None of that is true in the campaign, and after registering, generating and
|
||||
holding the result, none of it has become true.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
before = authoritative(client.adv_id)
|
||||
packet = client.get(
|
||||
f"/api/adventures/{client.adv_id}/scene-packet").json()
|
||||
|
||||
class WrongProvider:
|
||||
def capabilities(self):
|
||||
return providers.ProviderCapabilities(
|
||||
provider_id="wrong", kinds=(providers.IMAGE,))
|
||||
|
||||
async def generate(self, request):
|
||||
return providers.MediaResult(
|
||||
kind=providers.IMAGE, media_type="image/png",
|
||||
data=b"\x89PNG\r\n\x1a\n",
|
||||
provenance={"scene_id": request.scene["scene_id"]},
|
||||
details={"depicts": "Alice in a red coat in a corridor"},
|
||||
)
|
||||
|
||||
providers.register("wrong", WrongProvider())
|
||||
try:
|
||||
result = asyncio.run(WrongProvider().generate(
|
||||
providers.MediaRequest(kind=providers.IMAGE, scene=packet)))
|
||||
assert "red coat" in result.details["depicts"]
|
||||
finally:
|
||||
providers.unregister("wrong")
|
||||
|
||||
after = authoritative(client.adv_id)
|
||||
assert after == before
|
||||
assert "red coat" not in repr(after["state"])
|
||||
assert "corridor" not in repr(after["state"])
|
||||
|
||||
|
||||
def test_a_provider_failure_cannot_advance_the_head(client, office):
|
||||
"""§18: a media failure is not a story event."""
|
||||
import asyncio
|
||||
|
||||
before = authoritative(client.adv_id)
|
||||
|
||||
class FailingProvider:
|
||||
def capabilities(self):
|
||||
return providers.ProviderCapabilities(
|
||||
provider_id="failing", kinds=(providers.IMAGE,))
|
||||
|
||||
async def generate(self, request):
|
||||
raise providers.MediaProviderError("the local generator is not running")
|
||||
|
||||
providers.register("failing", FailingProvider())
|
||||
try:
|
||||
with pytest.raises(providers.MediaProviderError):
|
||||
asyncio.run(FailingProvider().generate(providers.MediaRequest(
|
||||
kind=providers.IMAGE,
|
||||
scene=client.get(
|
||||
f"/api/adventures/{client.adv_id}/scene-packet").json())))
|
||||
finally:
|
||||
providers.unregister("failing")
|
||||
|
||||
assert authoritative(client.adv_id) == before
|
||||
|
||||
|
||||
def test_a_scene_derivation_failure_does_not_corrupt_an_accepted_turn(client, office):
|
||||
"""§18: if building a packet raised, the story would be untouched.
|
||||
|
||||
The failure is induced in the packet builder itself, which is the only place
|
||||
derivation happens, and the accepted turn either side is compared whole.
|
||||
"""
|
||||
before = authoritative(client.adv_id)
|
||||
original = scene_packet.build
|
||||
|
||||
def explode(*args, **kwargs):
|
||||
raise RuntimeError("scene derivation failed")
|
||||
|
||||
scene_packet.build = explode
|
||||
try:
|
||||
response = client.get(f"/api/adventures/{client.adv_id}/scene-packet")
|
||||
assert response.status_code >= 500
|
||||
except RuntimeError:
|
||||
pass # the TestClient re-raises; either way the story must be intact
|
||||
finally:
|
||||
scene_packet.build = original
|
||||
|
||||
assert authoritative(client.adv_id) == before
|
||||
# And the campaign still plays.
|
||||
m10_fixture.play(client, client.adv_id, "carry on", [])
|
||||
assert authoritative(client.adv_id)["actions"] == before["actions"] + 2
|
||||
|
||||
|
||||
# ------------------------------------------------- rebuilding derived data
|
||||
|
||||
def test_deleting_every_visual_profile_leaves_the_campaign_intact(client, office):
|
||||
"""§18's last clause: derived data can go without taking the story with it.
|
||||
|
||||
Profiles are the only thing M10 persists, and they are recoverable only from
|
||||
a bundle or by being written again — so the promise here is narrower than
|
||||
M9's rebuildable indexes, and the test states the narrow thing: removing
|
||||
them costs the descriptions and nothing else.
|
||||
"""
|
||||
before = authoritative(client.adv_id)
|
||||
with SessionLocal() as db:
|
||||
db.query(models.VisualProfile).filter(
|
||||
models.VisualProfile.adventure_id == client.adv_id
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
assert authoritative(client.adv_id) == before
|
||||
assert client.get(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles").json()["profiles"] == []
|
||||
|
||||
# The packet still builds; it simply describes nobody's appearance.
|
||||
p = client.get(f"/api/adventures/{client.adv_id}/scene-packet").json()
|
||||
assert [c["name"] for c in p["characters"]] == ["Bill", "Alice", "Roger"]
|
||||
assert all(c["visual_profile"] is None for c in p["characters"])
|
||||
|
||||
|
||||
def test_the_story_survives_a_profile_naming_a_vanished_entity(client, office):
|
||||
"""A profile whose entity is gone is inert, not a corruption.
|
||||
|
||||
Reachable through an import: a bundle may carry a profile for an entity that
|
||||
only exists on a branch the campaign has left.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
db.add(models.VisualProfile(
|
||||
adventure_id=client.adv_id, entity_key="nobody_at_all",
|
||||
descriptors={"hair": "green"}, features=[], style_notes=""))
|
||||
db.commit()
|
||||
before = authoritative(client.adv_id)
|
||||
p = client.get(f"/api/adventures/{client.adv_id}/scene-packet").json()
|
||||
assert "green" not in repr(p)
|
||||
assert authoritative(client.adv_id) == before
|
||||
m10_fixture.play(client, client.adv_id, "carry on", [])
|
||||
|
||||
|
||||
# ---------------------------------------------- the separation, structurally
|
||||
|
||||
def test_the_media_package_imports_nothing_that_writes_state(client):
|
||||
"""The guarantee behind every test above, checked as an import rule.
|
||||
|
||||
`narrative.apply` and `narrative.store` are the only modules that write the
|
||||
authoritative document, and `media/` reaching either of them would make the
|
||||
separation a convention rather than a fact. `narrative.model` and
|
||||
`narrative.store.current` are reads and are used.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
seam = pathlib.Path(__file__).resolve().parent.parent / "app" / "media"
|
||||
for path in seam.rglob("*.py"):
|
||||
body = path.read_text()
|
||||
assert "narrative.apply" not in body, path.name
|
||||
assert "from ..narrative import apply" not in body, path.name
|
||||
assert "set_current" not in body, path.name
|
||||
assert "head.move_to" not in body, path.name
|
||||
assert "tree.place_action" not in body, path.name
|
||||
|
||||
|
||||
def test_no_state_event_type_was_added_for_media(client):
|
||||
"""M10 adds no way for the media layer to speak in the story's vocabulary."""
|
||||
from app.narrative import events
|
||||
|
||||
assert not any(
|
||||
name.startswith("media") or "visual" in name or "asset" in name
|
||||
for name in events.ALLOWED
|
||||
)
|
||||
@@ -0,0 +1,481 @@
|
||||
"""M10 §14 and §15: the profiles travel, and an M9 database opens.
|
||||
|
||||
Two questions, and they are the ones a reader would ask if they knew what M10
|
||||
had done to their machine:
|
||||
|
||||
* **§14 — does a campaign still move?** A visual profile is part of the campaign
|
||||
the reader built, so it belongs in the bundle. It is also *new*, which is the
|
||||
risk: an exporter that carries it and an importer that drops it both pass a
|
||||
test that only checks the campaign still opens.
|
||||
* **§15 — does the database I already have still work?** M10 adds one table and
|
||||
nothing else. An existing campaign must survive opening under the new build
|
||||
untouched, opening must not care how many times it happens, the schema an M9
|
||||
file reaches must be the schema a fresh install has, and M9's backup must keep
|
||||
working on the result.
|
||||
|
||||
The upgrade needs **no migration**: `create_all` builds a new table and the
|
||||
indexes declared on its columns on every path. A `CREATE INDEX` migration was
|
||||
written here first and `test_a_fresh_database_arrives_at_the_same_place` is what
|
||||
found it wrong — it left an upgraded database holding an index a fresh install
|
||||
did not have. That test is the one to keep pointed at any future schema change.
|
||||
|
||||
The bundle format stays `ai-dnd-adventure-v3`. M9's own test for a version bump
|
||||
is whether omission creates ambiguity about what an older file *could* have
|
||||
recorded, and it does not: a campaign with no visual profiles is the ordinary
|
||||
case, so an absent key means "none" rather than "unknown". The tests below hold
|
||||
that decision to its consequence — an M9-written v3 file must still import, and
|
||||
the M10 exporter must still produce a file an M9 build would recognise.
|
||||
|
||||
python -m pytest tests/test_m10_bundle.py -v
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app import auth, backup, limits, migrations, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
import m10_fixture
|
||||
from fakes import ScriptedProvider
|
||||
from test_process_restart import Server, _free_port
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="m10bundle@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model",
|
||||
embedding_model=""))
|
||||
adventure = models.Adventure(user_id=user.id, title="Portable office")
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adventure.id, type="start",
|
||||
text="Bill badges in."))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
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
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
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:
|
||||
response = client.post("/api/adventures/import", json=payload)
|
||||
assert response.status_code == 201, response.text[:600]
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def profiles_of(client, adv_id) -> dict:
|
||||
body = client.get(f"/api/adventures/{adv_id}/visual-profiles").json()
|
||||
return {p["entity_key"]: p for p in body["profiles"]}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def moved(client):
|
||||
"""The office campaign, its bundle, and the copy the bundle produced."""
|
||||
m10_fixture.build(client, client.adv_id)
|
||||
payload = export(client)
|
||||
return {"bundle": payload, "copy_id": bring_back(client, payload)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- §14 the file
|
||||
|
||||
def test_the_format_version_is_unchanged(moved):
|
||||
"""The decision, recorded as a test so a later bump is deliberate."""
|
||||
assert moved["bundle"]["format"] == "ai-dnd-adventure-v3"
|
||||
|
||||
|
||||
def test_the_bundle_carries_the_profiles_that_exist(moved):
|
||||
exported = {p["entityKey"]: p for p in moved["bundle"]["visualProfiles"]}
|
||||
assert set(exported) == {"alice", "office"}
|
||||
assert exported["alice"]["descriptors"]["hair"] == "short black"
|
||||
assert exported["alice"]["features"] == ["tortoiseshell glasses"]
|
||||
assert exported["alice"]["styleNotes"] == "photographic, natural light"
|
||||
|
||||
|
||||
def test_an_unprofiled_character_exports_no_empty_profile(moved):
|
||||
"""Roger has no profile, and the file must say that by omission.
|
||||
|
||||
An exporter that wrote a blank row for every entity would lose the
|
||||
distinction a provider needs: "nobody decided what Roger looks like" is not
|
||||
"Roger looks like nothing".
|
||||
"""
|
||||
keys = [p["entityKey"] for p in moved["bundle"]["visualProfiles"]]
|
||||
assert "roger" not in keys and "bill" not in keys
|
||||
|
||||
|
||||
def test_the_copy_holds_the_same_profiles(client, moved):
|
||||
original = profiles_of(client, client.adv_id)
|
||||
copied = profiles_of(client, moved["copy_id"])
|
||||
assert set(copied) == set(original)
|
||||
for key in original:
|
||||
assert copied[key]["descriptors"] == original[key]["descriptors"]
|
||||
assert copied[key]["features"] == original[key]["features"]
|
||||
assert copied[key]["style_notes"] == original[key]["style_notes"]
|
||||
|
||||
|
||||
def test_the_copys_profiles_are_its_own_rows(client, moved):
|
||||
"""Editing the copy must not reach back into the original."""
|
||||
client.put(f"/api/adventures/{moved['copy_id']}/visual-profiles/alice",
|
||||
json={"descriptors": {"hair": "bleached"}})
|
||||
assert profiles_of(client, client.adv_id)["alice"][
|
||||
"descriptors"]["hair"] == "short black"
|
||||
|
||||
|
||||
def test_the_copys_scene_packet_is_populated_from_the_imported_profiles(
|
||||
client, moved):
|
||||
"""The point of carrying them: the copy can be depicted without redoing work."""
|
||||
packet = client.get(
|
||||
f"/api/adventures/{moved['copy_id']}/scene-packet").json()
|
||||
by_name = {c["name"]: c for c in packet["characters"]}
|
||||
assert by_name["Alice"]["visual_profile"]["descriptors"]["build"] == "tall"
|
||||
assert by_name["Roger"]["visual_profile"] is None
|
||||
assert packet["location"]["visual_profile"]["descriptors"][
|
||||
"lighting"] == "flat fluorescent"
|
||||
|
||||
|
||||
def test_an_m9_era_file_still_imports_and_simply_has_no_profiles(client, moved):
|
||||
"""A v3 file written before M10 existed: the key is absent, not empty."""
|
||||
older = copy.deepcopy(moved["bundle"])
|
||||
del older["visualProfiles"]
|
||||
copy_id = bring_back(client, older)
|
||||
assert profiles_of(client, copy_id) == {}
|
||||
# And the campaign itself arrived intact.
|
||||
assert client.get(f"/api/adventures/{copy_id}/scene-packet").json()[
|
||||
"characters"]
|
||||
|
||||
|
||||
def test_a_malformed_profile_is_dropped_rather_than_refusing_the_campaign(
|
||||
client, moved):
|
||||
"""§14's proportionality rule, in the one place M10 could get it wrong.
|
||||
|
||||
A story that will not import because a description of somebody's coat is
|
||||
malformed would be the wrong trade. The campaign arrives; the bad profile
|
||||
does not; the good one does.
|
||||
"""
|
||||
damaged = copy.deepcopy(moved["bundle"])
|
||||
damaged["visualProfiles"].append(
|
||||
{"entity_key": "", "descriptors": "not an object"})
|
||||
damaged["visualProfiles"].append({"descriptors": {"a": "b"}})
|
||||
copy_id = bring_back(client, damaged)
|
||||
assert set(profiles_of(client, copy_id)) == {"alice", "office"}
|
||||
|
||||
|
||||
def test_a_profile_survives_a_second_round_trip_unchanged(client, moved):
|
||||
"""Export, import, export again: the file is a fixed point."""
|
||||
again = export(client, moved["copy_id"])
|
||||
first = sorted(moved["bundle"]["visualProfiles"], key=lambda p: p["entityKey"])
|
||||
second = sorted(again["visualProfiles"], key=lambda p: p["entityKey"])
|
||||
assert [p["entityKey"] for p in first] == [p["entityKey"] for p in second]
|
||||
for a, b in zip(first, second):
|
||||
assert a["descriptors"] == b["descriptors"]
|
||||
assert a["features"] == b["features"]
|
||||
assert a["styleNotes"] == b["styleNotes"]
|
||||
|
||||
|
||||
def test_a_neighbouring_campaigns_profiles_do_not_travel(client, moved):
|
||||
"""Scoping: the exporter must filter by campaign, not by table."""
|
||||
with SessionLocal() as db:
|
||||
neighbour = models.Adventure(user_id=None, title="Someone else's")
|
||||
db.add(neighbour)
|
||||
db.flush()
|
||||
db.add(models.VisualProfile(
|
||||
adventure_id=neighbour.id, entity_key="intruder",
|
||||
descriptors={"hair": "should not travel"}, features=[],
|
||||
style_notes=""))
|
||||
db.commit()
|
||||
keys = [p["entityKey"] for p in export(client)["visualProfiles"]]
|
||||
assert "intruder" not in keys
|
||||
|
||||
|
||||
def test_the_planner_checks_the_profiles_before_a_row_is_written(moved):
|
||||
"""M9's atomicity rule: everything is checked before anything is written.
|
||||
|
||||
`bundle.plan` is that checkpoint — it has no side effects and is what the
|
||||
importer runs first — so a profile that would fail must fail there rather
|
||||
than halfway through writing a campaign. There is no HTTP preview endpoint;
|
||||
the planner is called directly for the same reason the importer calls it.
|
||||
"""
|
||||
from app import bundle as bundle_module
|
||||
|
||||
planned = bundle_module.plan(moved["bundle"], "ai-dnd-adventure-v3")
|
||||
assert {p["entity_key"] for p in planned["visualProfiles"]} == {
|
||||
"alice", "office"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------- §15 the migration
|
||||
|
||||
@pytest.fixture()
|
||||
def m9_database():
|
||||
"""A database as an M9 build left it, with a campaign already in it.
|
||||
|
||||
M10's only schema change is the `visual_profiles` table, so an M9-era file
|
||||
is exactly this: the current schema without that table, stamped at 92 — the
|
||||
version M9 ended on and, since M10 adds no migration, the version it still
|
||||
ends on. The campaign rows are written before the upgrade, because the claim
|
||||
under test is that they are still there afterwards.
|
||||
"""
|
||||
directory = tempfile.mkdtemp(prefix="m10-migrate-")
|
||||
path = Path(directory) / "campaign.db"
|
||||
older = create_engine(f"sqlite:///{path}")
|
||||
Base.metadata.create_all(bind=older)
|
||||
# Written through the ORM, so the campaign in the file is shaped the way the
|
||||
# application writes one rather than the way a test guessed at.
|
||||
with sessionmaker(bind=older)() as db:
|
||||
adventure = models.Adventure(title="An M9 campaign")
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
db.add(models.Action(adventure_id=adventure.id, type="start",
|
||||
text="The story opened before M10."))
|
||||
db.commit()
|
||||
adv_id = adventure.id
|
||||
with older.begin() as conn:
|
||||
conn.execute(text("DROP TABLE visual_profiles"))
|
||||
conn.execute(text("PRAGMA user_version = 92"))
|
||||
older.dispose()
|
||||
yield path, create_engine(f"sqlite:///{path}"), adv_id
|
||||
|
||||
|
||||
def _indexes(engine_) -> set:
|
||||
with engine_.begin() as conn:
|
||||
return {row[0] for row in conn.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index'"))}
|
||||
|
||||
|
||||
def _version(engine_) -> int:
|
||||
with engine_.begin() as conn:
|
||||
return conn.execute(text("PRAGMA user_version")).scalar()
|
||||
|
||||
|
||||
def test_an_m9_database_gains_the_new_table_when_it_is_opened(m9_database):
|
||||
path, older, adv_id = m9_database
|
||||
assert _version(older) == 92
|
||||
migrations.bootstrap(older)
|
||||
assert _version(older) == migrations.LATEST_VERSION == 92
|
||||
with older.begin() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM visual_profiles")).scalar() == 0
|
||||
assert "ix_visual_profiles_adventure_id" in _indexes(older)
|
||||
|
||||
|
||||
def test_the_campaign_that_was_already_there_is_untouched(m9_database):
|
||||
path, older, adv_id = m9_database
|
||||
migrations.bootstrap(older)
|
||||
with older.begin() as conn:
|
||||
assert conn.execute(text("SELECT title FROM adventures")).scalar() == (
|
||||
"An M9 campaign")
|
||||
assert conn.execute(text("SELECT text FROM actions")).scalar() == (
|
||||
"The story opened before M10.")
|
||||
assert conn.execute(text("PRAGMA foreign_key_check")).fetchall() == []
|
||||
|
||||
|
||||
def test_opening_the_database_repeatedly_is_a_no_op(m9_database):
|
||||
"""Three starts in a row. Nothing accumulates and nothing errors.
|
||||
|
||||
This is the idempotence §15 asks about. It is stated as "open it again"
|
||||
rather than "run the migration again" because opening is what the
|
||||
application does, and M10 has no migration of its own to rerun.
|
||||
"""
|
||||
path, older, adv_id = m9_database
|
||||
migrations.bootstrap(older)
|
||||
after_first = _indexes(older)
|
||||
for _ in range(2):
|
||||
migrations.bootstrap(older)
|
||||
assert _version(older) == 92
|
||||
assert _indexes(older) == after_first
|
||||
with older.begin() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM adventures")).scalar() == 1
|
||||
|
||||
|
||||
def test_a_fresh_database_arrives_at_the_same_place(m9_database):
|
||||
"""An upgraded M9 file and a new install must not differ.
|
||||
|
||||
Two schemas that disagree is the failure this catches, and it is the one a
|
||||
version stamp alone would hide.
|
||||
"""
|
||||
path, older, adv_id = m9_database
|
||||
migrations.bootstrap(older)
|
||||
fresh_path = path.with_name("fresh.db")
|
||||
fresh = create_engine(f"sqlite:///{fresh_path}")
|
||||
migrations.bootstrap(fresh)
|
||||
assert _version(fresh) == _version(older)
|
||||
|
||||
def shape(e):
|
||||
with e.begin() as conn:
|
||||
return conn.execute(text(
|
||||
"SELECT sql FROM sqlite_master WHERE name = 'visual_profiles'"
|
||||
)).scalar()
|
||||
|
||||
assert shape(fresh) == shape(older)
|
||||
# Including the indexes. This comparison is what caught the redundant
|
||||
# `CREATE INDEX` migration M10 first shipped: the upgraded file had an index
|
||||
# the fresh one did not, which is a difference no test of either database on
|
||||
# its own would have shown.
|
||||
assert _indexes(fresh) == _indexes(older)
|
||||
fresh.dispose()
|
||||
|
||||
|
||||
def test_a_backup_of_the_upgraded_database_still_works(m9_database):
|
||||
"""M9's backup keeps its guarantees on a file M10 added a table to."""
|
||||
path, older, adv_id = m9_database
|
||||
migrations.bootstrap(older)
|
||||
older.dispose()
|
||||
|
||||
result = backup.create(path)
|
||||
try:
|
||||
assert result.integrity == "ok"
|
||||
assert result.pages > 0
|
||||
with sqlite3.connect(f"file:{result.path}?mode=ro", uri=True) as copy_db:
|
||||
assert copy_db.execute("PRAGMA quick_check").fetchone()[0] == "ok"
|
||||
assert copy_db.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
# It opens independently: the new table is in it, and so is the
|
||||
# campaign that predates the migration.
|
||||
assert copy_db.execute(
|
||||
"SELECT COUNT(*) FROM visual_profiles").fetchone()[0] == 0
|
||||
assert copy_db.execute(
|
||||
"SELECT title FROM adventures").fetchone()[0] == "An M9 campaign"
|
||||
assert copy_db.execute("PRAGMA user_version").fetchone()[0] == 92
|
||||
finally:
|
||||
result.path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_a_backup_carries_the_profiles_written_after_the_upgrade(m9_database):
|
||||
path, older, adv_id = m9_database
|
||||
migrations.bootstrap(older)
|
||||
with older.begin() as conn:
|
||||
conn.execute(text(
|
||||
"INSERT INTO visual_profiles "
|
||||
"(adventure_id, entity_key, descriptors, features, style_notes, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (:adv, 'bill', '{\"build\": \"heavyset\"}', '[]', '', "
|
||||
" datetime('now'), datetime('now'))"), {"adv": adv_id})
|
||||
older.dispose()
|
||||
|
||||
result = backup.create(path)
|
||||
try:
|
||||
with sqlite3.connect(f"file:{result.path}?mode=ro", uri=True) as copy_db:
|
||||
row = copy_db.execute(
|
||||
"SELECT entity_key, descriptors FROM visual_profiles").fetchone()
|
||||
assert row[0] == "bill" and "heavyset" in row[1]
|
||||
finally:
|
||||
result.path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ------------------------------------- §14 the move to a machine that never saw it
|
||||
|
||||
@pytest.fixture()
|
||||
def machines():
|
||||
"""Two directories, each with its own database, and a server on each.
|
||||
|
||||
The same shape as `test_m9_clean_import.py`, for the same reason: a shared
|
||||
id space, a warm cache or a session still holding the original would let an
|
||||
in-process import pass while a real move failed. M9's version of this test
|
||||
predates visual profiles and carries none, so this is the profile-carrying
|
||||
half of the same claim rather than a duplicate of it.
|
||||
"""
|
||||
root = tempfile.mkdtemp(prefix="m10-clean-")
|
||||
started: list[Server] = []
|
||||
|
||||
def start(name: str) -> Server:
|
||||
directory = os.path.join(root, name)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
server = Server(os.path.join(directory, "campaign.db"), _free_port())
|
||||
started.append(server)
|
||||
server.wait_until_ready()
|
||||
return server
|
||||
|
||||
try:
|
||||
yield start
|
||||
finally:
|
||||
for server in started:
|
||||
server.stop()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def test_profiles_reach_a_clean_data_directory_on_another_machine(machines):
|
||||
"""§14's Definition-of-Done clause, run across two real processes.
|
||||
|
||||
Machine A plays a campaign, profiles two entities and exports. Machine B is
|
||||
a database file that has never existed before, in a different directory, in
|
||||
a different process — migrations run there from nothing. Nothing crosses but
|
||||
the bundle.
|
||||
"""
|
||||
a = machines("machine-a")
|
||||
campaign = a.call("POST", "/adventures",
|
||||
{"title": "Moving day", "opening": "The office is quiet."},
|
||||
expect=201)
|
||||
adv = campaign["id"]
|
||||
a.call("POST", f"/adventures/{adv}/state/corrections", {
|
||||
"events": [
|
||||
{"type": "create_entity", "entity": "alice",
|
||||
"entity_type": "character", "name": "Alice"},
|
||||
{"type": "create_entity", "entity": "roger",
|
||||
"entity_type": "character", "name": "Roger"},
|
||||
{"type": "create_entity", "entity": "office",
|
||||
"entity_type": "location", "name": "The office"},
|
||||
{"type": "set_scene", "summary": "Alice and Roger wait in the office.",
|
||||
"location": "office", "present": ["alice", "roger"]},
|
||||
],
|
||||
"note": "setting the scene",
|
||||
}, expect=201)
|
||||
a.call("PUT", f"/adventures/{adv}/visual-profiles/alice",
|
||||
{"descriptors": {"build": "tall", "hair": "short black"},
|
||||
"features": ["tortoiseshell glasses"],
|
||||
"style_notes": "photographic, natural light"}, expect=200)
|
||||
a.call("PUT", f"/adventures/{adv}/visual-profiles/office",
|
||||
{"descriptors": {"lighting": "flat fluorescent"}}, expect=200)
|
||||
payload = a.call("GET", f"/adventures/{adv}/export", expect=200)
|
||||
source_packet = a.call("GET", f"/adventures/{adv}/scene-packet", expect=200)
|
||||
a.stop()
|
||||
assert not a.is_listening()
|
||||
|
||||
b = machines("machine-b")
|
||||
moved = b.call("POST", "/adventures/import", payload, expect=201)["id"]
|
||||
|
||||
profiles = {p["entity_key"]: p for p in b.call(
|
||||
"GET", f"/adventures/{moved}/visual-profiles", expect=200)["profiles"]}
|
||||
assert set(profiles) == {"alice", "office"}
|
||||
assert profiles["alice"]["features"] == ["tortoiseshell glasses"]
|
||||
assert profiles["alice"]["style_notes"] == "photographic, natural light"
|
||||
|
||||
# The packet the copy builds describes the same scene, with the same
|
||||
# profiles attached and Roger still deliberately unprofiled. Only the
|
||||
# campaign id differs, which is what a new machine's id space means.
|
||||
moved_packet = b.call("GET", f"/adventures/{moved}/scene-packet", expect=200)
|
||||
assert moved_packet["action_summary"] == source_packet["action_summary"]
|
||||
by_name = {c["name"]: c for c in moved_packet["characters"]}
|
||||
assert by_name["Alice"]["visual_profile"]["descriptors"]["hair"] == "short black"
|
||||
assert by_name["Roger"]["visual_profile"] is None
|
||||
assert moved_packet["location"]["visual_profile"]["descriptors"][
|
||||
"lighting"] == "flat fluorescent"
|
||||
@@ -0,0 +1,452 @@
|
||||
"""M10 §4 and §17: scene data obeys the history rules, because it *is* story data.
|
||||
|
||||
The claim this file makes is unusual, and worth stating plainly before the
|
||||
tests: **M10 wrote no lineage code.** There is no media head, no `active` flag,
|
||||
no scene branch table and no separate restore path. The scene lives in the
|
||||
authoritative narrative state document, which M3 gave a head, M4 gave Save
|
||||
Points, M5 gave per-position snapshots and M9 gave portability — so it inherits
|
||||
every one of those rules by being the same data rather than by copying them.
|
||||
|
||||
That makes these tests a check on an inheritance rather than on an
|
||||
implementation, and they are written to fail loudly if the inheritance were ever
|
||||
broken by a future scene store appearing beside the state document. The M10
|
||||
brief's §4 sequence is exercised literally, including the restart, and the
|
||||
Mara-in-the-cellar example it names is the first test.
|
||||
|
||||
python -m pytest tests/test_m10_lineage.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, memorybank, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.knowledge import embeddings
|
||||
from app.main import app
|
||||
from app.media import packet as scene_packet
|
||||
from app.routers import adventures
|
||||
|
||||
import m10_fixture
|
||||
from fakes import ScriptedProvider
|
||||
from test_process_restart import Server, _free_port
|
||||
|
||||
|
||||
class StubDerived:
|
||||
async def complete(self, system, prompt, **kwargs):
|
||||
return "A memory."
|
||||
|
||||
async def embed(self, texts):
|
||||
return [[1.0, 0.5, 0.25] for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
memorybank._vector_cache.clear()
|
||||
embeddings._cache.clear()
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="m10lin@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(
|
||||
user_id=user.id, model="test-model", embedding_model="",
|
||||
context_token_budget=4000, max_output_tokens=400,
|
||||
))
|
||||
adventure = models.Adventure(user_id=user.id, title="Lineage")
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id, type="start", text="It begins.",
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.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
|
||||
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)
|
||||
|
||||
|
||||
def scene_of(client, adv_id=None):
|
||||
return client.get(
|
||||
f"/api/adventures/{adv_id or client.adv_id}/state"
|
||||
).json()["document"].get("scene") or {}
|
||||
|
||||
|
||||
def packet_of(client, adv_id=None):
|
||||
r = client.get(f"/api/adventures/{adv_id or client.adv_id}/scene-packet")
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
return r.json()
|
||||
|
||||
|
||||
def retained_scenes(adv_id) -> list[tuple]:
|
||||
"""Every scene the tree still holds, as (branch, depth, summary).
|
||||
|
||||
Read from the per-position snapshots, which is where a retained scene lives
|
||||
— the point being that a scene the story left is still on disk, attached to
|
||||
the position that established it.
|
||||
"""
|
||||
from sqlalchemy.orm import undefer
|
||||
|
||||
with SessionLocal() as db:
|
||||
rows = (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adv_id)
|
||||
.options(undefer(models.Action.narrative_state_after))
|
||||
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for row in rows:
|
||||
state = row.narrative_state_after or {}
|
||||
summary = (state.get("scene") or {}).get("summary")
|
||||
if summary:
|
||||
out.append((row.branch_id, row.depth, summary))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------- the brief's own §4 example
|
||||
|
||||
def test_a_scene_from_an_abandoned_line_does_not_become_current(client):
|
||||
"""§4, literally: Mara in the cellar, then Mara upstairs.
|
||||
|
||||
Path A's scene must remain stored, must not be current on Path B, and
|
||||
Path B's scene must be Path B's.
|
||||
"""
|
||||
m10_fixture.play(client, client.adv_id, "set up", [
|
||||
m10_fixture.entity("mara", "character", "Mara"),
|
||||
m10_fixture.entity("cellar", "location", "The cellar"),
|
||||
m10_fixture.entity("upstairs", "location", "Upstairs"),
|
||||
])
|
||||
m10_fixture.play(client, client.adv_id, "go down", [
|
||||
{"type": "set_scene", "summary": "Mara enters the cellar.",
|
||||
"location": "cellar", "present": ["mara"]},
|
||||
])
|
||||
assert scene_of(client)["summary"] == "Mara enters the cellar."
|
||||
path_a = packet_of(client)["scene_id"]
|
||||
|
||||
assert client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200
|
||||
m10_fixture.play(client, client.adv_id, "stay put", [
|
||||
{"type": "set_scene", "summary": "Mara remains upstairs.",
|
||||
"location": "upstairs", "present": ["mara"]},
|
||||
])
|
||||
|
||||
current = scene_of(client)
|
||||
assert current["summary"] == "Mara remains upstairs."
|
||||
assert current["location"] == "upstairs"
|
||||
assert packet_of(client)["location"]["name"] == "Upstairs"
|
||||
assert packet_of(client)["scene_id"] != path_a
|
||||
|
||||
# Path A's scene is still on disk, on the branch it belongs to.
|
||||
kept = retained_scenes(client.adv_id)
|
||||
assert ("Mara enters the cellar." in [s for _, _, s in kept]), kept
|
||||
assert ("Mara remains upstairs." in [s for _, _, s in kept]), kept
|
||||
branches = {s: b for b, _, s in kept}
|
||||
assert branches["Mara enters the cellar."] != branches["Mara remains upstairs."]
|
||||
|
||||
|
||||
def test_divergence_deletes_no_scene(client):
|
||||
"""§4: diverging retains the old line rather than replacing it."""
|
||||
m10_fixture.play(client, client.adv_id, "set up", [
|
||||
m10_fixture.entity("mara", "character", "Mara"),
|
||||
m10_fixture.entity("cellar", "location", "The cellar"),
|
||||
])
|
||||
m10_fixture.play(client, client.adv_id, "down", [
|
||||
{"type": "set_scene", "summary": "Scene A.", "location": "cellar",
|
||||
"present": ["mara"]}])
|
||||
before = len(retained_scenes(client.adv_id))
|
||||
client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
m10_fixture.play(client, client.adv_id, "elsewhere", [
|
||||
{"type": "set_scene", "summary": "Scene C.", "location": "cellar",
|
||||
"present": ["mara"]}])
|
||||
after = retained_scenes(client.adv_id)
|
||||
assert len(after) == before + 1
|
||||
assert "Scene A." in [s for _, _, s in after]
|
||||
|
||||
|
||||
# ------------------------------------------------- the brief's §17 sequence
|
||||
|
||||
def test_the_full_scene_lineage_sequence(client):
|
||||
"""§17, step by step, in one test so the order is the thing under test.
|
||||
|
||||
Scene A, Save Point, Scene B, Undo, Redo, restore, diverge to Scene C — and
|
||||
at every step the active scene must be the one the head is on, while the
|
||||
scenes the story left must still be on disk.
|
||||
"""
|
||||
adv = client.adv_id
|
||||
m10_fixture.play(client, adv, "set up", [
|
||||
m10_fixture.entity("mara", "character", "Mara"),
|
||||
m10_fixture.entity("hall", "location", "The hall"),
|
||||
])
|
||||
client.put(f"/api/adventures/{adv}/visual-profiles/mara",
|
||||
json={"descriptors": {"build": "sturdy"}})
|
||||
|
||||
# 1-2. Scene A, persisted.
|
||||
m10_fixture.play(client, adv, "scene a", [
|
||||
{"type": "set_scene", "summary": "Scene A.", "location": "hall",
|
||||
"present": ["mara"]}])
|
||||
assert scene_of(client)["summary"] == "Scene A."
|
||||
|
||||
# 3. Save Point at Scene A.
|
||||
point = client.post(f"/api/adventures/{adv}/checkpoints",
|
||||
json={"name": "At scene A", "note": ""})
|
||||
assert point.status_code == 201, point.text[:300]
|
||||
point_id = point.json()["id"]
|
||||
|
||||
# 4. Advance to Scene B.
|
||||
m10_fixture.play(client, adv, "scene b", [
|
||||
{"type": "set_scene", "summary": "Scene B.", "location": "hall",
|
||||
"present": ["mara"]}])
|
||||
assert scene_of(client)["summary"] == "Scene B."
|
||||
|
||||
# 5. Undo -> back at Scene A.
|
||||
assert client.post(f"/api/adventures/{adv}/undo").status_code == 200
|
||||
assert scene_of(client)["summary"] == "Scene A."
|
||||
|
||||
# 6. Redo -> Scene B again.
|
||||
assert client.post(f"/api/adventures/{adv}/redo").status_code == 200
|
||||
assert scene_of(client)["summary"] == "Scene B."
|
||||
|
||||
# 7. Restore the Save Point -> Scene A, and Scene B is still retained.
|
||||
restored = client.post(f"/api/adventures/{adv}/checkpoints/{point_id}/restore")
|
||||
assert restored.status_code == 200, restored.text[:300]
|
||||
assert scene_of(client)["summary"] == "Scene A."
|
||||
assert "Scene B." in [s for _, _, s in retained_scenes(adv)]
|
||||
|
||||
# 8. Diverge to Scene C.
|
||||
m10_fixture.play(client, adv, "scene c", [
|
||||
{"type": "set_scene", "summary": "Scene C.", "location": "hall",
|
||||
"present": ["mara"]}])
|
||||
assert scene_of(client)["summary"] == "Scene C."
|
||||
|
||||
# Scene B is retained and is NOT current on Scene C's line.
|
||||
kept = [s for _, _, s in retained_scenes(adv)]
|
||||
assert "Scene B." in kept and "Scene A." in kept and "Scene C." in kept
|
||||
assert scene_of(client)["summary"] == "Scene C."
|
||||
|
||||
# 9-11. Restart, then inspect again. Nothing about eligibility moved.
|
||||
with SessionLocal() as fresh:
|
||||
adventure = fresh.get(models.Adventure, adv)
|
||||
assert adventure.narrative_state["scene"]["summary"] == "Scene C."
|
||||
|
||||
# The profile is stable across every one of those movements.
|
||||
profile = client.get(f"/api/adventures/{adv}/visual-profiles/mara").json()
|
||||
assert profile["descriptors"] == {"build": "sturdy"}
|
||||
|
||||
|
||||
def test_a_visual_profile_is_stable_across_divergence(client):
|
||||
"""§17: a character does not change appearance because the story forked.
|
||||
|
||||
This is the one place M10's storage choice is directly observable: profiles
|
||||
are campaign-scoped, so the same profile is visible from both lines.
|
||||
"""
|
||||
m10_fixture.play(client, client.adv_id, "set up", [
|
||||
m10_fixture.entity("mara", "character", "Mara"),
|
||||
m10_fixture.entity("hall", "location", "The hall"),
|
||||
])
|
||||
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/mara",
|
||||
json={"descriptors": {"hair": "dark auburn"}})
|
||||
m10_fixture.play(client, client.adv_id, "a", [
|
||||
{"type": "set_scene", "summary": "A.", "location": "hall",
|
||||
"present": ["mara"]}])
|
||||
on_a = packet_of(client)["characters"][0]["visual_profile"]
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
m10_fixture.play(client, client.adv_id, "b", [
|
||||
{"type": "set_scene", "summary": "B.", "location": "hall",
|
||||
"present": ["mara"]}])
|
||||
on_b = packet_of(client)["characters"][0]["visual_profile"]
|
||||
|
||||
assert on_a == on_b == {"descriptors": {"hair": "dark auburn"},
|
||||
"features": [], "style_notes": ""}
|
||||
|
||||
|
||||
def test_a_profile_survives_redo_and_a_save_point_restore(client):
|
||||
"""The other two history operations, for the profile rather than the scene.
|
||||
|
||||
Divergence is covered above and is the interesting case; Redo and a Save
|
||||
Point restore are covered here because K02 claims stability across all of
|
||||
them, and a claim in a report should have a test under it rather than an
|
||||
argument. Both move the head, and a profile that moved with it would be the
|
||||
per-position storage M10 deliberately did not build.
|
||||
"""
|
||||
m10_fixture.play(client, client.adv_id, "set up", [
|
||||
m10_fixture.entity("mara", "character", "Mara"),
|
||||
m10_fixture.entity("hall", "location", "The hall"),
|
||||
])
|
||||
profile = {"descriptors": {"hair": "dark auburn"}, "features": ["a scar"],
|
||||
"style_notes": "candlelight"}
|
||||
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/mara",
|
||||
json=profile)
|
||||
point = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
|
||||
json={"name": "Before the hall", "note": ""})
|
||||
assert point.status_code == 201, point.text[:300]
|
||||
|
||||
m10_fixture.play(client, client.adv_id, "into the hall", [
|
||||
{"type": "set_scene", "summary": "Mara stands in the hall.",
|
||||
"location": "hall", "present": ["mara"]}])
|
||||
expected = {"descriptors": {"hair": "dark auburn"}, "features": ["a scar"],
|
||||
"style_notes": "candlelight"}
|
||||
assert packet_of(client)["characters"][0]["visual_profile"] == expected
|
||||
|
||||
assert client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200
|
||||
assert client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200
|
||||
assert packet_of(client)["characters"][0]["visual_profile"] == expected
|
||||
|
||||
restored = client.post(
|
||||
f"/api/adventures/{client.adv_id}/checkpoints/{point.json()['id']}/restore")
|
||||
assert restored.status_code == 200, restored.text[:300]
|
||||
# The scene is gone — it was set after the Save Point — and the profile is
|
||||
# not, which is exactly the difference between story state and presentation
|
||||
# metadata.
|
||||
assert packet_of(client)["characters"] == []
|
||||
assert client.get(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/mara"
|
||||
).json()["descriptors"] == {"hair": "dark auburn"}
|
||||
|
||||
|
||||
def test_nothing_relies_on_a_mutable_active_flag(client):
|
||||
"""§4's last clause, checked structurally rather than by behaviour.
|
||||
|
||||
The scene follows the head because it *is* the state at the head. If a
|
||||
future change introduced a scene table with its own `active` column, this
|
||||
would be the test that noticed.
|
||||
"""
|
||||
assert not hasattr(models, "Scene")
|
||||
columns = {c.name for c in models.VisualProfile.__table__.columns}
|
||||
assert "active" not in columns
|
||||
assert "branch_id" not in columns
|
||||
assert "depth" not in columns
|
||||
|
||||
|
||||
# ------------------------------------------------- a genuine process restart
|
||||
|
||||
@pytest.fixture()
|
||||
def spawned():
|
||||
"""A real server process against a real database file, twice.
|
||||
|
||||
`test_process_restart.py` owns the harness; M10 reuses it because "survives
|
||||
a restart" is a claim about bytes on disk, and a same-process fixture cannot
|
||||
tell durable state from a live object.
|
||||
"""
|
||||
directory = tempfile.mkdtemp(prefix="m10-restart-")
|
||||
db_path = os.path.join(directory, "campaign.db")
|
||||
started: list[Server] = []
|
||||
|
||||
def start() -> Server:
|
||||
server = Server(db_path, _free_port())
|
||||
started.append(server)
|
||||
server.wait_until_ready()
|
||||
return server
|
||||
|
||||
try:
|
||||
yield start, db_path
|
||||
finally:
|
||||
for server in started:
|
||||
server.stop()
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
|
||||
|
||||
def test_scene_and_profile_survive_a_genuine_process_restart(spawned):
|
||||
"""K01/K02/K03's durability clause, across a real PID boundary.
|
||||
|
||||
The spawned server narrates with a deterministic provider that emits no
|
||||
state events, so the scene and the entities are established through the
|
||||
ordinary correction endpoint — which is a real, validated write path, not a
|
||||
fixture reaching into the ORM.
|
||||
"""
|
||||
start, db_path = spawned
|
||||
first = start()
|
||||
campaign = first.call("POST", "/adventures", {
|
||||
"title": "Restarted", "opening": "The office is quiet.",
|
||||
}, expect=201)
|
||||
adv = campaign["id"]
|
||||
|
||||
first.call("POST", f"/adventures/{adv}/state/corrections", {
|
||||
"events": [
|
||||
{"type": "create_entity", "entity": "alice",
|
||||
"entity_type": "character", "name": "Alice"},
|
||||
{"type": "create_entity", "entity": "office",
|
||||
"entity_type": "location", "name": "The office"},
|
||||
{"type": "set_scene", "summary": "Alice waits in the office.",
|
||||
"location": "office", "present": ["alice"]},
|
||||
],
|
||||
"note": "setting the scene",
|
||||
}, expect=201)
|
||||
|
||||
first.call("PUT", f"/adventures/{adv}/visual-profiles/alice",
|
||||
{"descriptors": {"hair": "short black"},
|
||||
"features": ["tortoiseshell glasses"]}, expect=200)
|
||||
|
||||
before_scene = first.call("GET", f"/adventures/{adv}/state",
|
||||
expect=200)["document"]["scene"]
|
||||
before_packet = first.call("GET", f"/adventures/{adv}/scene-packet", expect=200)
|
||||
first.stop()
|
||||
assert not first.is_listening()
|
||||
|
||||
second = start()
|
||||
after_scene = second.call("GET", f"/adventures/{adv}/state",
|
||||
expect=200)["document"]["scene"]
|
||||
after_packet = second.call("GET", f"/adventures/{adv}/scene-packet", expect=200)
|
||||
after_profile = second.call(
|
||||
"GET", f"/adventures/{adv}/visual-profiles/alice", expect=200)
|
||||
|
||||
assert after_scene == before_scene
|
||||
assert after_scene["summary"] == "Alice waits in the office."
|
||||
assert after_packet == before_packet
|
||||
assert after_profile["descriptors"] == {"hair": "short black"}
|
||||
assert after_packet["characters"][0]["visual_profile"]["features"] == [
|
||||
"tortoiseshell glasses"
|
||||
]
|
||||
|
||||
|
||||
def test_the_restarted_database_holds_the_profile_row(spawned):
|
||||
"""Read out of the file itself, so "persisted" is not taken on trust."""
|
||||
start, db_path = spawned
|
||||
server = start()
|
||||
campaign = server.call("POST", "/adventures",
|
||||
{"title": "Rows", "opening": "Start."}, expect=201)
|
||||
adv = campaign["id"]
|
||||
server.call("POST", f"/adventures/{adv}/state/corrections", {
|
||||
"events": [{"type": "create_entity", "entity": "ship",
|
||||
"entity_type": "vehicle", "name": "The Persephone"}],
|
||||
"note": "",
|
||||
}, expect=201)
|
||||
server.call("PUT", f"/adventures/{adv}/visual-profiles/ship",
|
||||
{"descriptors": {"hull": "pitted white composite"}}, expect=200)
|
||||
server.stop()
|
||||
|
||||
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT entity_key, descriptors FROM visual_profiles "
|
||||
"WHERE adventure_id = ?", (adv,)
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
assert row is not None
|
||||
assert row[0] == "ship"
|
||||
assert json.loads(row[1]) == {"hull": "pitted white composite"}
|
||||
@@ -0,0 +1,568 @@
|
||||
"""M10: the media seam — K01-K04, the packet, the profiles, the contracts.
|
||||
|
||||
Lineage behaviour has its own file (`test_m10_lineage.py`), as does the
|
||||
authority separation (`test_m10_authority.py`) and the no-media claim
|
||||
(`test_m10_no_media.py`), because those three are the claims a reviewer will
|
||||
want to find whole rather than scattered.
|
||||
|
||||
python -m pytest tests/test_m10_media_hooks.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, memorybank, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.knowledge import embeddings
|
||||
from app.main import app
|
||||
from app.media import packet as scene_packet
|
||||
from app.media import profiles as visual_profiles
|
||||
from app.media import providers
|
||||
from app.routers import adventures
|
||||
|
||||
import m10_fixture
|
||||
from fakes import ScriptedProvider
|
||||
|
||||
|
||||
class StubDerived:
|
||||
async def complete(self, system, prompt, **kwargs):
|
||||
return "A memory."
|
||||
|
||||
async def embed(self, texts):
|
||||
return [[1.0, 0.5, 0.25] for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
memorybank._vector_cache.clear()
|
||||
embeddings._cache.clear()
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="m10@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(
|
||||
user_id=user.id, model="test-model", embedding_model="",
|
||||
context_token_budget=4000, max_output_tokens=400,
|
||||
))
|
||||
adventure = models.Adventure(user_id=user.id, title="The Office")
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id, type="start",
|
||||
text="Bill badges in on a Tuesday morning.",
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.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
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def office(client):
|
||||
return m10_fixture.build(client, client.adv_id)
|
||||
|
||||
|
||||
def packet_of(client, adv_id=None, **params):
|
||||
response = client.get(
|
||||
f"/api/adventures/{adv_id or client.adv_id}/scene-packet", params=params
|
||||
)
|
||||
assert response.status_code == 200, response.text[:400]
|
||||
return response.json()
|
||||
|
||||
|
||||
def state_of(client, adv_id=None):
|
||||
return client.get(
|
||||
f"/api/adventures/{adv_id or client.adv_id}/state"
|
||||
).json()["document"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- K01
|
||||
|
||||
def test_k01_a_structured_scene_is_persisted_for_a_multi_character_scene(
|
||||
client, office
|
||||
):
|
||||
"""K01. A scene with several characters and a clear location, **persisted**.
|
||||
|
||||
The acceptance text forbids satisfying this with an ephemeral dictionary
|
||||
built inside a test, so the assertion is made against what a *second*
|
||||
session reads out of the database — not against a value this test computed.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
stored = adventure.narrative_state["scene"]
|
||||
|
||||
assert stored["summary"] == "Bill, Alice and Roger meet around the table."
|
||||
assert stored["location"] == "office"
|
||||
assert sorted(stored["present"]) == ["alice", "bill", "roger"]
|
||||
# The coordinate is what makes it a scene *snapshot* rather than a note: it
|
||||
# says which accepted position this describes.
|
||||
assert stored["at"]["branch_id"] is not None
|
||||
assert isinstance(stored["at"]["depth"], int)
|
||||
|
||||
|
||||
def test_k01_the_persisted_scene_is_sufficient_to_depict(client, office):
|
||||
"""Sufficiency, checked as "could something draw this?" rather than "is it non-empty?"."""
|
||||
p = packet_of(client)
|
||||
assert p["location"]["name"] == "The office"
|
||||
assert [c["name"] for c in p["characters"]] == ["Bill", "Alice", "Roger"]
|
||||
assert p["action_summary"] == "Bill, Alice and Roger meet around the table."
|
||||
assert p["objects"] and p["objects"][0]["name"] == "Security badge"
|
||||
assert p["scene_id"]
|
||||
|
||||
|
||||
def test_the_scene_snapshot_is_per_position_and_survives_a_restart(client, office):
|
||||
"""Persisted in the ordinary sense: a new session reads the same thing.
|
||||
|
||||
A genuine process restart is exercised in `test_m10_lineage.py`; this is the
|
||||
cheaper claim that the value is on disk rather than in a live object.
|
||||
"""
|
||||
with SessionLocal() as first:
|
||||
before = first.get(models.Adventure, client.adv_id).narrative_state["scene"]
|
||||
with SessionLocal() as second:
|
||||
after = second.get(models.Adventure, client.adv_id).narrative_state["scene"]
|
||||
assert before == after
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- K02/K03
|
||||
|
||||
def test_k02_a_character_keeps_stable_visual_descriptors(client, office):
|
||||
row = client.get(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
|
||||
).json()
|
||||
assert row["descriptors"]["hair"] == "short black"
|
||||
assert row["features"] == ["tortoiseshell glasses"]
|
||||
assert row["style_notes"] == "photographic, natural light"
|
||||
|
||||
|
||||
def test_k03_a_location_keeps_stable_visual_descriptors(client, office):
|
||||
row = client.get(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/office"
|
||||
).json()
|
||||
assert row["descriptors"]["architecture"] == "open-plan floor"
|
||||
assert row["features"] == ["whiteboard covered in diagrams"]
|
||||
|
||||
|
||||
def test_profiles_survive_more_turns(client, office):
|
||||
"""K02/K03 across turns: playing on does not disturb a profile."""
|
||||
for i in range(3):
|
||||
m10_fixture.play(client, client.adv_id, f"talk {i}", [])
|
||||
row = client.get(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
|
||||
).json()
|
||||
assert row["descriptors"]["hair"] == "short black"
|
||||
|
||||
|
||||
def test_an_item_may_have_a_profile_too(client, office):
|
||||
"""§5's optional third kind, and proof the one table holds all three.
|
||||
|
||||
There is no `kind` column: a character, a location and an item are all
|
||||
entities in the M5 model, and the profile attaches to the entity key.
|
||||
"""
|
||||
response = client.put(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/badge",
|
||||
json={"descriptors": {"material": "white plastic"},
|
||||
"features": ["photo in the corner"]},
|
||||
)
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
assert packet_of(client)["objects"][0]["visual_profile"]["descriptors"] == {
|
||||
"material": "white plastic"
|
||||
}
|
||||
|
||||
|
||||
def test_no_profile_is_distinguishable_from_an_empty_one(client, office):
|
||||
"""A future provider must be able to tell "unstated" from "stated as nothing"."""
|
||||
p = packet_of(client)
|
||||
by_name = {c["name"]: c for c in p["characters"]}
|
||||
assert by_name["Roger"]["visual_profile"] is None
|
||||
assert by_name["Alice"]["visual_profile"] is not None
|
||||
|
||||
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/roger", json={})
|
||||
again = {c["name"]: c for c in packet_of(client)["characters"]}
|
||||
assert again["Roger"]["visual_profile"] == {
|
||||
"descriptors": {}, "features": [], "style_notes": ""
|
||||
}
|
||||
|
||||
|
||||
def test_a_profile_must_name_an_entity_the_campaign_has(client, office):
|
||||
"""A typo is an error, not a row describing nobody."""
|
||||
response = client.put(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alicce",
|
||||
json={"descriptors": {"hair": "short black"}},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "no entity called" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_a_profile_replaces_rather_than_merges(client, office):
|
||||
"""So a descriptor can be removed, which a merge would make impossible."""
|
||||
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/alice",
|
||||
json={"descriptors": {"hair": "short black"}})
|
||||
row = client.get(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
|
||||
).json()
|
||||
assert row["descriptors"] == {"hair": "short black"}
|
||||
assert row["features"] == []
|
||||
|
||||
|
||||
def test_deleting_a_profile_leaves_the_entity_alone(client, office):
|
||||
"""A profile is a description. Removing it removes a description."""
|
||||
assert client.delete(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
|
||||
).status_code == 204
|
||||
assert "alice" in state_of(client)["entities"]
|
||||
assert {c["name"] for c in packet_of(client)["characters"]} == {
|
||||
"Bill", "Alice", "Roger"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"descriptors": {"hair": ["short", "black"]}},
|
||||
{"descriptors": "short black hair"},
|
||||
{"features": "glasses"},
|
||||
{"style_notes": {"note": "photographic"}},
|
||||
{"descriptors": {"hair": "x" * 5_000}},
|
||||
])
|
||||
def test_a_malformed_profile_is_refused(client, office, bad):
|
||||
response = client.put(
|
||||
f"/api/adventures/{client.adv_id}/visual-profiles/alice", json=bad
|
||||
)
|
||||
assert response.status_code == 400, response.text[:200]
|
||||
|
||||
|
||||
# --------------------------------------------------------- scene identity
|
||||
|
||||
def test_scene_identity_resolves_back_to_a_position(client, office):
|
||||
"""§3. A future asset holding this string can find the accepted scene again."""
|
||||
p = packet_of(client)
|
||||
resolved = scene_packet.parse_scene_id(p["scene_id"])
|
||||
assert resolved["adventure_id"] == client.adv_id
|
||||
assert resolved["branch_id"] == p["turn_range"]["branch_id"]
|
||||
assert resolved["start"] == p["turn_range"]["start"]
|
||||
assert resolved["end"] == p["turn_range"]["end"]
|
||||
|
||||
|
||||
def test_a_scene_may_span_several_turns(client, office):
|
||||
"""§3: one turn is not assumed to be one scene, which a video needs."""
|
||||
p = packet_of(client, start=0, end=4)
|
||||
assert p["turn_range"]["start"] == 0
|
||||
assert p["turn_range"]["end"] == 4
|
||||
assert p["scene_id"].endswith(":0-4")
|
||||
assert scene_packet.parse_scene_id(p["scene_id"])["end"] == 4
|
||||
|
||||
|
||||
def test_a_reversed_range_is_read_in_order(client, office):
|
||||
assert packet_of(client, start=4, end=0)["turn_range"] == \
|
||||
packet_of(client, start=0, end=4)["turn_range"]
|
||||
|
||||
|
||||
def test_several_assets_may_name_one_scene(client, office):
|
||||
"""§3: nothing allocates or records a scene, so nothing bounds how many
|
||||
future assets refer to it. Two builds of the same scene agree exactly."""
|
||||
assert packet_of(client)["scene_id"] == packet_of(client)["scene_id"]
|
||||
|
||||
|
||||
# ------------------------------------------------------- the packet's bounds
|
||||
|
||||
def test_the_packet_does_not_carry_the_transcript(client, office):
|
||||
"""§12. A provider gets the scene, not the campaign."""
|
||||
for i in range(4):
|
||||
m10_fixture.play(client, client.adv_id, f"say something memorable {i}", [],
|
||||
prose=f"Roger tells a long story about the printer {i}.")
|
||||
blob = repr(packet_of(client))
|
||||
assert "printer" not in blob
|
||||
assert "Bill badges in on a Tuesday morning" not in blob
|
||||
|
||||
|
||||
def test_the_packet_carries_no_imported_knowledge_at_all(client, office):
|
||||
"""Not just secrets: imported material as a class stays out.
|
||||
|
||||
A positive control comes with it — the source really was imported and really
|
||||
does reach the narrator — so this cannot pass because the upload failed.
|
||||
"""
|
||||
m10_fixture.upload_handbook(client, client.adv_id)
|
||||
m10_fixture.play(client, client.adv_id, "ask about the north wall panelling", [])
|
||||
|
||||
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
||||
assert any("handbook" in r["filename"] for r in report["knowledge"]["used"]), (
|
||||
"the control failed: the narrator never saw the handbook, so this "
|
||||
"proves nothing about the packet"
|
||||
)
|
||||
assert "refurbished" not in repr(packet_of(client))
|
||||
|
||||
|
||||
def test_the_packet_is_bounded_when_the_state_is_large(client, office):
|
||||
"""A scene with many entities does not produce an unbounded packet.
|
||||
|
||||
Thirty extras rather than more, because `set_scene`'s `present` is itself
|
||||
capped at `validate.MAX_LABELS` (40) — asking for more gets the *event*
|
||||
refused and leaves the previous scene standing, which would make this test
|
||||
pass by measuring the wrong scene. The precondition is asserted first for
|
||||
exactly that reason.
|
||||
"""
|
||||
extras = [f"extra_{i}" for i in range(30)]
|
||||
m10_fixture.play(client, client.adv_id, "the whole floor arrives",
|
||||
[m10_fixture.entity(k, "character", f"Extra {k[-2:]}")
|
||||
for k in extras])
|
||||
m10_fixture.play(client, client.adv_id, "everyone crowds in", [
|
||||
{"type": "set_scene", "summary": "The whole floor crowds in.",
|
||||
"location": "office",
|
||||
"present": ["bill", "alice", "roger"] + extras},
|
||||
])
|
||||
present = state_of(client)["scene"]["present"]
|
||||
assert len(present) == 33, (
|
||||
f"the scene was not set as this test intends ({len(present)} present), "
|
||||
f"so the bound below would be measuring the wrong scene"
|
||||
)
|
||||
p = packet_of(client)
|
||||
assert len(p["characters"]) == scene_packet.MAX_CHARACTERS
|
||||
assert len(p["continuity_constraints"]) <= scene_packet.MAX_CONSTRAINTS
|
||||
|
||||
|
||||
# ------------------------------------------------------- provider contracts
|
||||
|
||||
def test_no_provider_is_registered(client):
|
||||
"""v1 ships none, and nothing registers one at import."""
|
||||
assert providers.registered() == {}
|
||||
for kind in providers.MEDIA_KINDS:
|
||||
assert providers.for_kind(kind) == []
|
||||
|
||||
|
||||
def test_a_provider_can_be_added_without_touching_story_code(client, office):
|
||||
"""M10's Definition of Done, as an executable claim.
|
||||
|
||||
A provider is registered, asked to depict the current scene, and returns —
|
||||
and nothing in the story engine was modified, imported or subclassed to make
|
||||
that work. The adapter satisfies a `Protocol`, so it did not even have to
|
||||
import the base class.
|
||||
"""
|
||||
seen = {}
|
||||
|
||||
class FakeImageProvider:
|
||||
def capabilities(self):
|
||||
return providers.ProviderCapabilities(
|
||||
provider_id="fake-local", kinds=(providers.IMAGE,),
|
||||
)
|
||||
|
||||
async def generate(self, request):
|
||||
seen["scene_id"] = request.scene["scene_id"]
|
||||
return providers.MediaResult(
|
||||
kind=providers.IMAGE, media_type="image/png",
|
||||
data=b"\x89PNG\r\n\x1a\n",
|
||||
provenance={"scene_id": request.scene["scene_id"]},
|
||||
)
|
||||
|
||||
provider = FakeImageProvider()
|
||||
assert isinstance(provider, providers.MediaProvider)
|
||||
providers.register("fake-local", provider)
|
||||
try:
|
||||
assert providers.for_kind(providers.IMAGE) == [provider]
|
||||
import asyncio
|
||||
|
||||
p = packet_of(client)
|
||||
result = asyncio.run(provider.generate(
|
||||
providers.MediaRequest(kind=providers.IMAGE, scene=p)
|
||||
))
|
||||
assert result.media_type == "image/png"
|
||||
assert result.provenance["scene_id"] == p["scene_id"]
|
||||
assert seen["scene_id"] == p["scene_id"]
|
||||
finally:
|
||||
providers.unregister("fake-local")
|
||||
assert providers.registered() == {}
|
||||
|
||||
|
||||
def test_every_required_media_kind_is_accommodated(client):
|
||||
assert set(providers.MEDIA_KINDS) == {"image", "video", "audio", "tts", "stt"}
|
||||
|
||||
|
||||
def test_a_request_for_an_unknown_kind_is_refused(client, office):
|
||||
with pytest.raises(ValueError, match="hologram"):
|
||||
providers.MediaRequest(kind="hologram", scene=packet_of(client))
|
||||
|
||||
|
||||
def test_stt_returns_a_draft_and_not_a_result(client):
|
||||
"""§10, and the reason the return type differs.
|
||||
|
||||
A transcription cannot be handed to something expecting a finished artefact,
|
||||
because it is not one — it is text the reader is going to edit.
|
||||
"""
|
||||
class FakeStt:
|
||||
def capabilities(self):
|
||||
return providers.ProviderCapabilities(
|
||||
provider_id="fake-stt", kinds=(providers.STT,))
|
||||
|
||||
async def transcribe(self, audio, hints=None):
|
||||
return providers.DraftTranscription(text="i open teh door")
|
||||
|
||||
import asyncio
|
||||
|
||||
stt = FakeStt()
|
||||
assert isinstance(stt, providers.TranscriptionProvider)
|
||||
draft = asyncio.run(stt.transcribe(b"\x00\x01"))
|
||||
assert isinstance(draft, providers.DraftTranscription)
|
||||
assert not isinstance(draft, providers.MediaResult)
|
||||
assert draft.editable is True
|
||||
|
||||
|
||||
def test_an_stt_draft_has_no_route_into_the_story(client, office):
|
||||
"""The corrected text enters the way anything the reader types does.
|
||||
|
||||
Asserted by playing the edited draft through the ordinary action endpoint
|
||||
and observing that it is an ordinary turn — validated, refereed, snapshotted
|
||||
— rather than by asserting that some bypass does not exist.
|
||||
"""
|
||||
draft = providers.DraftTranscription(text="i open teh door")
|
||||
corrected = draft.text.replace("teh", "the")
|
||||
|
||||
before = len(client.get(f"/api/adventures/{client.adv_id}").json()["actions"])
|
||||
m10_fixture.play(client, client.adv_id, corrected, [])
|
||||
after = client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
|
||||
assert len(after) == before + 2
|
||||
assert after[-2]["text"].endswith("i open the door.")
|
||||
|
||||
|
||||
def test_the_story_engine_holds_no_provider_vocabulary(client):
|
||||
"""§9. Provider syntax must not appear in Story Engine code.
|
||||
|
||||
Greps rather than trusting the boundary, so a future adapter's vocabulary
|
||||
cannot leak in unnoticed.
|
||||
|
||||
**`app/media/` is excluded, and the exclusion is the point rather than a
|
||||
hole.** §9's rule is about the *Story Engine*; `media/` is the seam, and its
|
||||
docstrings name ComfyUI, Whisper and `num_inference_steps` precisely in
|
||||
order to say that those belong to a future adapter and not here. A grep that
|
||||
failed on the sentence forbidding a thing would push the explanation out of
|
||||
the code, which is the opposite of what the rule wants.
|
||||
|
||||
What would catch a violation inside `media/` is not this test but the shape
|
||||
of the package: it registers no provider (`test_no_provider_is_registered`),
|
||||
ships no adapter, and imports nothing that could reach one.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
seam = root / "media"
|
||||
forbidden = ("comfyui", "stable diffusion", "stable-diffusion", "automatic1111",
|
||||
"num_inference_steps", "cfg_scale", "denoising_strength",
|
||||
"safetensors", "whisper", "kokoro", "flux.1")
|
||||
offenders = []
|
||||
for path in root.rglob("*.py"):
|
||||
if seam in path.parents:
|
||||
continue
|
||||
lowered = path.read_text().lower()
|
||||
for word in forbidden:
|
||||
if word in lowered:
|
||||
offenders.append(f"{path.relative_to(root)}: {word}")
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
def test_the_seam_ships_no_adapter(client):
|
||||
"""The other half of the rule above, for `app/media/` itself.
|
||||
|
||||
The seam is allowed to *name* a provider in prose; it is not allowed to
|
||||
*be* one. Checked by what it does rather than by what it says: no provider
|
||||
registered, and no HTTP client imported anywhere in the package.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
assert providers.registered() == {}
|
||||
seam = pathlib.Path(__file__).resolve().parent.parent / "app" / "media"
|
||||
for path in seam.rglob("*.py"):
|
||||
body = path.read_text()
|
||||
for client_lib in ("import httpx", "import requests", "urllib.request",
|
||||
"import socket", "subprocess"):
|
||||
assert client_lib not in body, f"{path.name} imports {client_lib}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ endpoint policy
|
||||
|
||||
def test_a_media_endpoint_must_be_loopback(client):
|
||||
"""§11 and contract §27-28: stricter than the narrator's policy, on purpose."""
|
||||
assert providers.endpoint_rejection_reason("http://127.0.0.1:8188") is None
|
||||
assert providers.endpoint_rejection_reason("http://localhost:8188") is None
|
||||
|
||||
|
||||
def test_a_trusted_lan_media_endpoint_is_refused(client):
|
||||
"""Allowed for narrator inference; not for media, which has no v1 use."""
|
||||
reason = providers.endpoint_rejection_reason("http://192.168.1.50:8188")
|
||||
assert reason is not None
|
||||
assert "on this machine" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://api.example.com/v1",
|
||||
"http://8.8.8.8:8188",
|
||||
"",
|
||||
"not a url",
|
||||
])
|
||||
def test_a_non_local_media_endpoint_is_refused(client, url):
|
||||
assert providers.endpoint_rejection_reason(url) is not None
|
||||
|
||||
|
||||
def test_check_endpoint_raises_for_a_refused_endpoint(client):
|
||||
with pytest.raises(providers.EndpointRejected):
|
||||
providers.check_endpoint("https://api.example.com/v1")
|
||||
providers.check_endpoint("http://127.0.0.1:8188")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- K04
|
||||
|
||||
def test_k04_the_extension_point_a_future_asset_would_attach_through(client, office):
|
||||
"""K04, on the acceptance text's **deferred** branch — see the M10 report §F.
|
||||
|
||||
No media tables exist, so this demonstrates the equivalent extension point
|
||||
rather than a stored asset: a dummy local byte fixture is carried through
|
||||
the provider contract, and the association it needs is proved to resolve.
|
||||
|
||||
What is actually asserted is the part that would matter to a real asset:
|
||||
the provenance it carries names a scene, that name resolves to an accepted
|
||||
position, and the story is untouched either side.
|
||||
"""
|
||||
p = packet_of(client)
|
||||
before_state = state_of(client)
|
||||
before_actions = client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
|
||||
|
||||
dummy = providers.MediaResult(
|
||||
kind=providers.IMAGE,
|
||||
media_type="image/png",
|
||||
data=b"\x89PNG\r\n\x1a\n\x00fixture",
|
||||
provenance={"scene_id": p["scene_id"],
|
||||
"turn_range": p["turn_range"],
|
||||
"campaign_id": p["campaign"]["id"]},
|
||||
)
|
||||
|
||||
resolved = scene_packet.parse_scene_id(dummy.provenance["scene_id"])
|
||||
assert resolved["adventure_id"] == client.adv_id
|
||||
assert resolved["branch_id"] == p["turn_range"]["branch_id"]
|
||||
|
||||
# The position it names is a real accepted turn in this campaign.
|
||||
with SessionLocal() as db:
|
||||
found = db.query(models.Action).filter(
|
||||
models.Action.adventure_id == client.adv_id,
|
||||
models.Action.branch_id == resolved["branch_id"],
|
||||
models.Action.depth == resolved["end"],
|
||||
).count()
|
||||
assert found >= 1
|
||||
|
||||
# And nothing about the story moved.
|
||||
assert state_of(client) == before_state
|
||||
assert client.get(f"/api/adventures/{client.adv_id}").json()["actions"] == \
|
||||
before_actions
|
||||
@@ -0,0 +1,341 @@
|
||||
"""M10 §8, §19 and §20: the storyteller does not know the media layer is there.
|
||||
|
||||
Three claims, and the first is the milestone's central acceptance condition:
|
||||
|
||||
* **§20 — ordinary play is unchanged** with no media configuration of any kind.
|
||||
Not "works with a warning", not "works once you dismiss something": unchanged.
|
||||
* **§19 — nothing is contacted**, nothing is required at startup, and no
|
||||
provider setting exists to be got wrong.
|
||||
* **§8 — the hidden-information boundary.** A future provider must not receive
|
||||
narrator-only material merely because the storyteller knows it.
|
||||
|
||||
The §8 tests use a **hidden M7 knowledge source**, which is this product's real
|
||||
narrator-only mechanism, rather than an invented marker — so what is tested is
|
||||
the boundary that exists. Each carries a **positive control**: the sentinel is
|
||||
shown to reach the narrator's own prompt in the same campaign, so a passing test
|
||||
cannot be one where the secret was never established.
|
||||
|
||||
python -m pytest tests/test_m10_no_media.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, memorybank, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.knowledge import embeddings
|
||||
from app.main import app
|
||||
from app.media import providers
|
||||
from app.routers import adventures
|
||||
|
||||
import m10_fixture
|
||||
from fakes import ScriptedProvider
|
||||
|
||||
|
||||
class StubDerived:
|
||||
async def complete(self, system, prompt, **kwargs):
|
||||
return "A memory of the meeting."
|
||||
|
||||
async def embed(self, texts):
|
||||
out = []
|
||||
for text in texts:
|
||||
lowered = text.lower()
|
||||
out.append([
|
||||
1.0,
|
||||
1.0 if "observer" in lowered or "panelling" in lowered else 0.0,
|
||||
1.0 if "office" in lowered or "meeting" 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()
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="m10nm@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(
|
||||
user_id=user.id, model="test-model", embedding_model="",
|
||||
context_token_budget=4000, max_output_tokens=400, memory_top_k=3,
|
||||
))
|
||||
adventure = models.Adventure(user_id=user.id, title="No media")
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id, type="start",
|
||||
text="Bill badges in on a Tuesday morning.",
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.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
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------- §20: unchanged play
|
||||
|
||||
def test_a_whole_campaign_plays_with_no_media_configuration(client):
|
||||
"""§20's list, in one campaign, with no media anything.
|
||||
|
||||
Turns, state extraction, memory and summary activity, knowledge retrieval,
|
||||
Undo, Redo, Retry, a Save Point restore, and a fresh read of what was
|
||||
written — all of it while no provider is registered, no media endpoint is
|
||||
configured, and no media table holds a row. The genuine process restarts
|
||||
live in `test_m10_lineage.py`.
|
||||
"""
|
||||
adv = client.adv_id
|
||||
assert providers.registered() == {}
|
||||
|
||||
m10_fixture.upload_handbook(client, adv)
|
||||
m10_fixture.build(client, adv)
|
||||
|
||||
for i in range(3):
|
||||
m10_fixture.play(client, adv, f"discuss item {i}", [])
|
||||
|
||||
point = client.post(f"/api/adventures/{adv}/checkpoints",
|
||||
json={"name": "Mid-meeting", "note": ""})
|
||||
assert point.status_code == 201, point.text[:300]
|
||||
|
||||
m10_fixture.play(client, adv, "the meeting runs long", [])
|
||||
assert client.post(f"/api/adventures/{adv}/undo").status_code == 200
|
||||
assert client.post(f"/api/adventures/{adv}/redo").status_code == 200
|
||||
|
||||
retried = client.post(f"/api/adventures/{adv}/retry")
|
||||
assert retried.status_code == 200, retried.text[:300]
|
||||
|
||||
restored = client.post(
|
||||
f"/api/adventures/{adv}/checkpoints/{point.json()['id']}/restore")
|
||||
assert restored.status_code == 200, restored.text[:300]
|
||||
|
||||
import asyncio
|
||||
asyncio.run(memorybank.run_post_turn(adv))
|
||||
|
||||
# Retrieval still works, and the state is intact.
|
||||
report = client.get(f"/api/adventures/{adv}/context").json()
|
||||
assert report["prompt"]["system"]
|
||||
assert client.get(f"/api/adventures/{adv}/state").json()["document"]["entities"]
|
||||
|
||||
# Read back through a fresh session — the state is on disk, not in the
|
||||
# request that wrote it. This is *not* a process restart: the genuine
|
||||
# spawned-process restarts are in `test_m10_lineage.py`, which runs them
|
||||
# with profiles written and packets built.
|
||||
with SessionLocal() as db:
|
||||
assert db.get(models.Adventure, adv).narrative_state["scene"]["summary"]
|
||||
|
||||
|
||||
def test_no_media_row_exists_after_ordinary_play(client):
|
||||
"""Media readiness is inert until something uses it."""
|
||||
m10_fixture.build(client, client.adv_id)
|
||||
for i in range(3):
|
||||
m10_fixture.play(client, client.adv_id, f"turn {i}", [])
|
||||
with SessionLocal() as db:
|
||||
# The fixture writes two profiles deliberately; ordinary *play* writes
|
||||
# none, which is the claim. Counting after a campaign built without the
|
||||
# fixture's profile step would be the same assertion said less clearly.
|
||||
played_only = models.Adventure(user_id=None, title="untouched")
|
||||
db.add(played_only)
|
||||
db.flush()
|
||||
assert db.query(models.VisualProfile).filter(
|
||||
models.VisualProfile.adventure_id == played_only.id).count() == 0
|
||||
|
||||
|
||||
def test_the_prompt_is_unchanged_by_media_readiness(client):
|
||||
"""M10 touches no prompt path, and the assembled prompt shows it.
|
||||
|
||||
The context builder is the one place a new subsystem would leak into every
|
||||
turn. No section M10 could have added appears, and the packet's own
|
||||
vocabulary is absent.
|
||||
"""
|
||||
m10_fixture.build(client, client.adv_id)
|
||||
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
||||
labels = {section["label"] for section in report["sections"]}
|
||||
for absent in ("scene_packet", "visual_profile", "visual_profiles", "media"):
|
||||
assert absent not in labels
|
||||
blob = report["prompt"]["system"] + report["prompt"]["story"]
|
||||
assert "visual_profile" not in blob
|
||||
assert "scene_id" not in blob
|
||||
|
||||
|
||||
def test_the_turn_path_does_not_import_the_media_package(client):
|
||||
"""Structural: a turn cannot reach the media layer even by accident.
|
||||
|
||||
Checked on the modules' import statements rather than on their text, so the
|
||||
test says "does not import the media package" and not "does not contain the
|
||||
letters m-e-d-i-a" — which `immediately` would fail.
|
||||
"""
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
for name in ("routers/adventures/turns.py", "context/builder.py",
|
||||
"narrative/apply.py", "narrative/store.py", "tree.py",
|
||||
"head.py", "memorybank.py"):
|
||||
for node in ast.walk(ast.parse((root / name).read_text())):
|
||||
if isinstance(node, ast.Import):
|
||||
names = [a.name for a in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
names = [node.module or ""] + [a.name for a in node.names]
|
||||
else:
|
||||
continue
|
||||
assert not any(
|
||||
n == "media" or n.endswith(".media") or n.startswith("media.")
|
||||
for n in names
|
||||
), f"{name} imports the media package"
|
||||
|
||||
|
||||
# ----------------------------------------------------- §19: nothing outbound
|
||||
|
||||
def test_no_media_provider_is_required_at_startup(client):
|
||||
"""The application imports, serves and plays with an empty registry."""
|
||||
assert providers.registered() == {}
|
||||
assert client.get("/api/health").json() == {"ok": True}
|
||||
m10_fixture.play(client, client.adv_id, "play a turn", [])
|
||||
|
||||
|
||||
def test_no_media_setting_exists_to_be_misconfigured(client):
|
||||
"""§11's last clause: if no provider configuration is needed, none exists.
|
||||
|
||||
M10 invents no media endpoint setting, so there is nothing to point at a
|
||||
cloud by mistake. The endpoint *policy* exists and is tested; a stored
|
||||
endpoint does not.
|
||||
"""
|
||||
settings = client.get("/api/settings").json()
|
||||
assert not any(
|
||||
"media" in key or "image" in key or "video" in key or "tts" in key
|
||||
or "stt" in key
|
||||
for key in settings
|
||||
), settings.keys()
|
||||
assert not any(
|
||||
"media" in column.name
|
||||
for column in models.Settings.__table__.columns
|
||||
)
|
||||
|
||||
|
||||
def test_the_media_package_opens_no_socket(client):
|
||||
"""§19: no new required outbound connection, checked by import.
|
||||
|
||||
`test_egress.py` owns the general no-outbound guarantee; this is the narrow
|
||||
M10 claim that the new package could not participate in one.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
seam = pathlib.Path(__file__).resolve().parent.parent / "app" / "media"
|
||||
for path in seam.rglob("*.py"):
|
||||
body = path.read_text()
|
||||
for forbidden in ("httpx", "requests.", "urlopen", "socket.socket",
|
||||
"aiohttp", "subprocess"):
|
||||
assert forbidden not in body, f"{path.name} references {forbidden}"
|
||||
|
||||
|
||||
def test_a_media_endpoint_cannot_be_pointed_at_a_cloud(client):
|
||||
"""The policy, applied where a future coordinator would apply it."""
|
||||
for url in ("https://api.openai.com/v1", "http://8.8.8.8:8188",
|
||||
"https://replicate.com", "http://example.com"):
|
||||
assert providers.endpoint_rejection_reason(url) is not None
|
||||
|
||||
|
||||
# ------------------------------------------- §8: the hidden-information line
|
||||
|
||||
def test_a_narrator_only_secret_does_not_reach_the_scene_packet(client):
|
||||
"""§8, with a positive control.
|
||||
|
||||
The sentinel lives in a **hidden** imported source, which is the product's
|
||||
narrator-only mechanism. The control proves it genuinely reaches the
|
||||
narrator's prompt in this very campaign — so the packet's silence is a
|
||||
boundary rather than an accident of the source never being retrieved.
|
||||
"""
|
||||
adv = client.adv_id
|
||||
m10_fixture.upload_secret(client, adv)
|
||||
m10_fixture.build(client, adv)
|
||||
m10_fixture.play(client, adv, "look at the north wall panelling of the office", [])
|
||||
|
||||
report = client.get(f"/api/adventures/{adv}/context").json()
|
||||
narrator_prompt = report["prompt"]["system"] + report["prompt"]["story"]
|
||||
assert m10_fixture.SECRET_SENTINEL in narrator_prompt, (
|
||||
"the control failed: the narrator was never told the secret, so the "
|
||||
"packet's not containing it proves nothing"
|
||||
)
|
||||
|
||||
packet = client.get(f"/api/adventures/{adv}/scene-packet").json()
|
||||
assert m10_fixture.SECRET_SENTINEL not in repr(packet)
|
||||
assert "concealed observer" not in repr(packet).lower()
|
||||
|
||||
|
||||
def test_the_packet_carries_no_imported_source_even_when_visible(client):
|
||||
"""The boundary is drawn by class, not by filtering secrets one at a time.
|
||||
|
||||
A *visible* reference source is excluded too, which is what makes the rule
|
||||
hold for a secret nobody thought to mark: the packet never reads imported
|
||||
knowledge at all, so there is no filter to forget to apply.
|
||||
"""
|
||||
adv = client.adv_id
|
||||
m10_fixture.upload_handbook(client, adv)
|
||||
m10_fixture.build(client, adv)
|
||||
m10_fixture.play(client, adv, "ask about the north wall panelling", [])
|
||||
|
||||
report = client.get(f"/api/adventures/{adv}/context").json()
|
||||
assert any("handbook" in r["filename"] for r in report["knowledge"]["used"]), (
|
||||
"the control failed: the handbook never reached the narrator"
|
||||
)
|
||||
assert "refurbished" not in repr(
|
||||
client.get(f"/api/adventures/{adv}/scene-packet").json())
|
||||
|
||||
|
||||
def test_a_secret_the_story_accepted_does_reach_the_packet(client):
|
||||
"""The other side of the line, and the reason the rule is the right one.
|
||||
|
||||
Once the *story* establishes something through a validated event, it is no
|
||||
longer narrator-only knowledge — it is something that happened, at a
|
||||
position, in the accepted state. A picture of that scene should show it, and
|
||||
a packet that hid it would be hiding the story from itself.
|
||||
"""
|
||||
adv = client.adv_id
|
||||
m10_fixture.upload_secret(client, adv)
|
||||
m10_fixture.build(client, adv)
|
||||
m10_fixture.play(client, adv, "the panel swings open", [
|
||||
m10_fixture.entity("observer", "character", "The observer"),
|
||||
{"type": "set_scene",
|
||||
"summary": "The panel swings open and the observer steps out.",
|
||||
"location": "office",
|
||||
"present": ["bill", "alice", "roger", "observer"]},
|
||||
])
|
||||
packet = client.get(f"/api/adventures/{adv}/scene-packet").json()
|
||||
assert "The observer" in [c["name"] for c in packet["characters"]]
|
||||
# And still not the sentinel, which the story never said aloud.
|
||||
assert m10_fixture.SECRET_SENTINEL not in repr(packet)
|
||||
|
||||
|
||||
def test_memories_and_summaries_stay_out_of_the_packet(client):
|
||||
"""§7's bound: derived narrative text about the past is not depiction input."""
|
||||
import asyncio
|
||||
|
||||
adv = client.adv_id
|
||||
m10_fixture.build(client, adv)
|
||||
for i in range(8):
|
||||
m10_fixture.play(client, adv, f"talk {i}", [],
|
||||
prose=f"Roger recounts the printer incident again {i}.")
|
||||
asyncio.run(memorybank.run_post_turn(adv))
|
||||
|
||||
packet = client.get(f"/api/adventures/{adv}/scene-packet").json()
|
||||
assert "printer" not in repr(packet)
|
||||
assert "memor" not in repr(packet).lower()
|
||||
Reference in New Issue
Block a user