Files
interactive-story/backend/tests/test_scenario_art.py
T
parththakkar106andClaude Opus 5 57c24a07d8 Redesign the library UI and add scenario cover art
Rework the home page into a single landing surface and give the app a
consistent visual language, chosen as "illuminated tome" over two other
pitched directions because it builds on the existing Cinzel + gold identity
instead of replacing it.

Home is now Continue (up to 4 in-progress stories, each showing where you
left off) over a scenario shelf, each section with a "See all" link. The
full adventure list moves to /adventures.

Scenario cover art has three tiers, in precedence order: an uploaded picture
(downscaled client-side to 400px WebP before storing), an emoji, or gradient
art generated from a hash of the title so no card is ever an empty box.
Adventures inherit their scenario's art. Images live in the row rather than
on disk because Render's free tier has no persistent volume, and it keeps
export bundles self-contained; list responses carry a cacheable
/api/scenarios/{id}/image URL rather than the base64.

Also: ambient drifting motes behind the app, loading skeletons, staggered
card entrance, ornamental scene breaks and a drop cap in the story, a
"Weaving" thinking indicator, and a toast system replacing every alert().

Two fixes found along the way:

- Importing a scenario bundle with no "tags" key returned a 500. Column
  defaults are not applied until flush, so the attribute was still None
  when the width clamp sliced it.
- Anything meaning "the story's latest narration" was missing action type
  "start", which is the only text a freshly created adventure has, so new
  adventures looked empty. Collected as NARRATION_TYPES.

Migrations 30 and 31 add scenarios.image and scenarios.icon; both are
additive with a '' default and were verified against a database stamped at
29. vite.config.js now reads AIDND_API_PORT so the recurring port-8000
clash with another local app needs no file edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
2026-07-26 13:17:46 +05:30

211 lines
8.0 KiB
Python

"""Scenario cover art + the Continue-card snippet.
Unit tests for the data-URI handling in app/images.py, then HTTP tests that the
list endpoints advertise a cacheable `image_url` (never the inline base64), that
the image route serves real bytes, and that an adventure's snippet comes from
the latest *narration* rather than the player's last line.
python -m pytest tests/test_scenario_art.py -v
"""
import base64
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 fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, images, limits, models, schemas
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
# Smallest valid PNG: a single transparent pixel.
PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
)
PNG_URI = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
# --------------------------------------------------------------------------- #
# app/images.py
# --------------------------------------------------------------------------- #
def test_decode_returns_bytes_and_content_type():
assert images.decode(PNG_URI) == (PNG_BYTES, "image/png")
def test_decode_tolerates_wrapped_base64():
"""A hand-pasted URI can carry newlines; b64decode(validate=True) won't."""
wrapped = "data:image/png;base64," + "\n".join(
base64.b64encode(PNG_BYTES).decode()[i:i + 24] for i in range(0, 100, 24)
)
# Only asserting it doesn't raise and doesn't silently return a partial
# decode of a truncated payload — the wrapped prefix here is not the whole
# image, so a None result is also acceptable; what matters is no exception.
images.decode(wrapped)
def test_decode_rejects_non_data_uris_and_garbage():
assert images.decode("https://example.com/a.png") is None
assert images.decode("data:image/png;base64,!!!not base64!!!") is None
assert images.decode("") is None
assert images.decode(None) is None
def test_decode_rejects_svg():
"""SVG can carry script and these bytes are served from our own origin."""
svg = "data:image/svg+xml;base64," + base64.b64encode(b"<svg/>").decode()
assert images.decode(svg) is None
def test_public_url_points_at_the_endpoint_for_data_uris():
class Stamp:
def timestamp(self):
return 1700000000.0
assert images.public_url(7, PNG_URI, Stamp()) == "/api/scenarios/7/image?v=1700000000"
def test_public_url_passes_through_https_but_not_http():
stamp = None
assert images.public_url(1, "https://cdn.example.com/a.png", stamp) == \
"https://cdn.example.com/a.png"
# http:// would be blocked as mixed content on the deployed https app.
assert images.public_url(1, "http://cdn.example.com/a.png", stamp) == ""
assert images.public_url(1, "", stamp) == ""
def test_sanitize_rejects_hostile_and_oversized_values():
assert images.sanitize("javascript:alert(1)", 1000) == ""
assert images.sanitize("http://example.com/a.png", 1000) == ""
assert images.sanitize(PNG_URI, len(PNG_URI) - 1) == "" # over the cap
assert images.sanitize(12345, 1000) == ""
assert images.sanitize(None, 1000) == ""
# Well-formed values survive.
assert images.sanitize(PNG_URI, schemas.IMAGE_MAX) == PNG_URI
assert images.sanitize("https://example.com/a.png", 1000) == "https://example.com/a.png"
# --------------------------------------------------------------------------- #
# HTTP
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="art@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
# One scenario with an uploaded picture, one with only an emoji.
pictured = models.Scenario(user_id=user.id, title="Pictured", image=PNG_URI)
emoji_only = models.Scenario(user_id=user.id, title="Emoji", icon="🐉")
setup.add_all([pictured, emoji_only])
setup.flush()
adventure = models.Adventure(
user_id=user.id, scenario_id=pictured.id, title="A Run",
)
setup.add(adventure)
setup.flush()
# A full turn: opening narration, the player's line, then the AI's reply.
setup.add_all([
models.Action(adventure_id=adventure.id, index=0, type="ai",
text="The door groans open."),
models.Action(adventure_id=adventure.id, index=1, type="do",
text="I draw my sword."),
models.Action(adventure_id=adventure.id, index=2, type="ai",
text="Steel rings. The\ncorridor answers."),
])
setup.commit()
ids = {"scenario": pictured.id, "emoji": emoji_only.id, "adventure": adventure.id}
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.ids = ids
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_scenario_list_advertises_a_url_and_hides_the_base64(client):
rows = {row["title"]: row for row in client.get("/api/scenarios").json()}
pictured = rows["Pictured"]
assert pictured["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
# The whole point: a list response must never carry the inline image.
assert "image" not in pictured
assert rows["Emoji"]["image_url"] == ""
assert rows["Emoji"]["icon"] == "🐉"
def test_image_endpoint_serves_the_decoded_bytes(client):
resp = client.get(f"/api/scenarios/{client.ids['scenario']}/image")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert resp.content == PNG_BYTES
assert "immutable" in resp.headers["cache-control"]
def test_image_endpoint_404s_without_an_upload(client):
resp = client.get(f"/api/scenarios/{client.ids['emoji']}/image")
assert resp.status_code == 404
def test_single_scenario_still_returns_the_raw_uri_for_editing(client):
"""The editor needs the actual value to preview and to clear."""
body = client.get(f"/api/scenarios/{client.ids['scenario']}").json()
assert body["image"] == PNG_URI
def test_adventure_list_carries_snippet_and_inherited_art(client):
row = next(r for r in client.get("/api/adventures").json()
if r["id"] == client.ids["adventure"])
# Latest narration, whitespace collapsed — not the player's "I draw my sword."
assert row["snippet"] == "Steel rings. The corridor answers."
assert row["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
assert row["action_count"] == 3
def test_snippet_is_truncated_on_a_word_boundary(client):
from app.routers.adventures import SNIPPET_MAX, _snippet
long_text = "word " * 200
out = _snippet(long_text)
assert len(out) <= SNIPPET_MAX + 1 # +1 for the ellipsis
assert out.endswith("…")
assert "wor…" not in out # never cuts mid-word
def test_scenario_export_import_round_trips_the_art(client):
bundle = client.get(f"/api/scenarios/{client.ids['scenario']}/export").json()
assert bundle["image"] == PNG_URI
created = client.post("/api/scenarios/import", json=bundle).json()["scenario"]
assert created["image"] == PNG_URI
def test_import_drops_a_hostile_image_value(client):
bundle = {"title": "Sneaky", "image": "javascript:alert(1)"}
created = client.post("/api/scenarios/import", json=bundle).json()["scenario"]
assert created["image"] == ""