Files
interactive-story/backend/tests/test_starter_adventure.py
T
parththakkar106andClaude Opus 5 c2d3f0d8b9 Show the starter adventure the artwork of the demo it came from
An adventure has no cover art of its own and inherits its scenario's, and a
bundle carries no scenario id, because an id means nothing in another database.
The starter card therefore fell back to a monogram tile while the demo beside
it showed the Pokeball.

The starter file names its source under `scenarioTitle`, and the copy is linked
to the seeded scenario with that title. If no seed answers to the name, the
adventure keeps a NULL `scenario_id`, which is the state every imported bundle
is in and costs only the artwork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 19:19:23 +05:30

160 lines
5.6 KiB
Python

"""The pre-played adventure every new guest is given: app/starter.py.
The starter is a shipped export bundle, so the two things that can break it are
the file and the import path. A bundle that no longer plans, or a story trimmed
to a state a player cannot continue from, both leave the guest worse off than an
empty account would.
python -m pytest tests/test_starter_adventure.py -v
"""
import json
import os
import tempfile
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app import bundle, models, seed, starter
from app.database import Base
from app.migrations import bootstrap
@pytest.fixture()
def db(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}",
connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _fk(dbapi_connection, _record):
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
bootstrap(engine)
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
yield session
session.close()
@pytest.fixture()
def guest(db):
user = models.User(is_guest=True)
db.add(user)
db.commit()
return user
def payload() -> dict:
return json.loads(starter.STARTER_FILE.read_text(encoding="utf-8"))
def test_the_shipped_file_is_a_bundle_this_build_can_import():
"""The file is written by an export, so a format change can strand it."""
data = payload()
version = bundle.check_format(data)
assert version == bundle.FORMAT
story = bundle.plan(data, version)
assert story["nodes"]
def test_a_guest_gets_the_adventure_and_owns_it(db, guest):
adventure = starter.give(db, guest)
db.commit()
assert adventure is not None
assert adventure.user_id == guest.id
assert "Pokemon" in adventure.title
assert len(adventure.actions) == len(payload()["actions"])
def test_the_story_cards_come_with_it(db, guest):
"""A copy without the cards would drop out of character on the next turn."""
adventure = starter.give(db, guest)
db.commit()
assert {c.name for c in adventure.story_cards} == {
c["name"] for c in payload()["storyCards"]
}
def test_the_turns_carry_what_the_engine_recorded(db, guest):
"""The point of shipping a played story: the summaries have chips in them.
An applied change, a refused one, and a milestone all appear in the first
two exchanges, which is what a visitor sees before spending a demo turn.
"""
adventure = starter.give(db, guest)
db.commit()
chips = [chip for action in adventure.actions for chip in action.world_changes]
kinds = {chip["kind"] for chip in chips}
assert "stat" in kinds
assert "rejected" in kinds
assert "milestone" in kinds
def test_the_state_left_behind_can_be_played_from(db, guest):
"""The opponent has to be on the field with HP, or every hit is refused.
A Pokemon sent in at 0 HP sits at the floor of `active_hp`, so the engine
refuses each later change and the story stops moving. That state reached a
playtest once, and it must not be what a guest inherits.
"""
adventure = starter.give(db, guest)
db.commit()
milo = adventure.world_state["npc"]["milo"]
assert milo["active_hp"] > 0
assert milo["active_pokemon"]
assert adventure.actions[-1].world_state_after["npc"]["milo"] == milo
def test_a_missing_file_costs_the_guest_nothing(db, guest, monkeypatch):
"""A packaging mistake must not stop an account from being created."""
monkeypatch.setattr(starter, "STARTER_FILE", starter.STARTER_FILE.with_name("gone.json"))
assert starter.give(db, guest) is None
def test_a_broken_bundle_leaves_no_half_written_adventure(db, guest, monkeypatch):
"""The savepoint: a failure partway through discards the rows it wrote."""
monkeypatch.setattr(bundle, "materialize",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert starter.give(db, guest) is None
db.commit()
assert db.query(models.Adventure).count() == 0
def test_the_copy_inherits_the_demo_scenario_art(db, guest):
"""An adventure has no cover art of its own; it inherits the scenario's.
A bundle carries no scenario id, so the link is made by title. Without it
the starter card shows a monogram while the demo it came from shows its
artwork.
"""
title = payload()["scenarioTitle"]
data = json.loads((seed.SEED_DIR / "05-league-championship.json").read_text(encoding="utf-8"))
# The demo the copy came from, as the seeder would have written it. The
# seeder itself opens its own session against the app's engine, so it
# cannot be pointed at this fixture's database.
assert data["title"] == title, "the starter names a scenario no seed file ships"
db.add(models.Scenario(user_id=None, is_public=True, title=title, image=data["image"]))
db.commit()
adventure = starter.give(db, guest)
db.commit()
scenario = seed.find_seeded(db, title)
assert scenario is not None and scenario.image
assert adventure.scenario_id == scenario.id
def test_a_missing_demo_scenario_only_costs_the_art(db, guest):
"""Nothing seeds the scenarios in this fixture, so the link finds nothing."""
adventure = starter.give(db, guest)
db.commit()
assert adventure is not None
assert adventure.scenario_id is None