Every test module carried the same eight-line prologue redirecting the database to a temp file. Only the first one to be imported ever took effect: `app.database` reads `AIDND_DB_PATH` at import and builds `engine` from it once, so by the time the second module ran the engine already existed. The other 34 copies created a temp file that nothing opened and nothing deleted, and leaked one per module per run. `conftest.py` now does it once, which is early enough because pytest imports conftest before any test module. It also deletes the file when the run ends. The tests still share one database, exactly as they already did: each `client` fixture calls `create_all` on setup and `drop_all` on teardown, so no test sees another test's rows. `tests/fakes.py` holds the one `ScriptedProvider`. Nine modules each had a copy, and the copies had drifted into four feature sets, so a test that needed to raise a provider error had to be written in one of the files whose copy supported that. The shared one is the superset. The two `FakeProvider` copies were the same class with a fixed reply, so they use it too. `test_chat.py` keeps its own, which implements `chat` rather than `generate` and records what it was constructed with. An autouse fixture resets the fake's class state between tests, so a stale reply list can no longer reach the next test. 435 lines out of the suite. 549 tests pass. Verified live by sabotage: breaking the shared fake fails 13 tests across four modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
153 lines
5.4 KiB
Python
153 lines
5.4 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 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
|