festifun/backend/tests/test_capsule.py
type-two 8f2a488c51 foundation3 (M18): complete Friend-Tracks foundation — fixture ground truth, contracts, stubs
Completes M18 on top of the WIP checkpoint (6a703c3). Three lanes (H/J/K) depend only on
what this freezes.

- synthetic.py: base testsrc2 saturation/brightness-muted so its magenta/cyan colour bars
  don't swamp lane H's colour detector; the two moving markers stay the only detectable blobs.
- Frontend (step 6): friendTracks.js stub (never-throws, fx.js pattern) wired into main.js;
  hidden #btn-friends + #friends-panel; state.tracks loaded when manifest.has_tracks.
- Tests (step 8): test_tracks_foundation.py (20) — DB helpers, /api/tracks routes, solve
  degradation, manifest.has_tracks, frozen OOK protocol + encoders, track_truth shape, the
  projection round-trip (rendered pixels back-project to 3D truth, worst err <1e-3 → lane H's
  median<0.3 reachable by construction), and a real-ffmpeg render test proving marker A is the
  dominant magenta blob after base-muting.
- CR-6: the two sanctioned M18 test-lock edits (test_api manifest key-set +has_tracks;
  test_capsule bundle paths +api/tracks). Additive; flagged for integration3 ratification.

Suite: 14 pre-existing files sum to 189 (floor held) + 20 new = 209 passed, exit 0.
Frontend `npm run build` clean. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).

Recovered by the coordinator session after the foundation3 agent's isolation worktree was
removed mid-run, leaving the work uncommitted; every file independently reviewed, full suite +
build re-run green, then committed. No work redone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:18:53 +10:00

340 lines
14 KiB
Python

"""M17 memory capsule (lane G): bundle structure, baked-JSON validity, serve.py Range.
Builds a real capsule from the synthetic project (temp data dir per conftest) against a
fabricated frontend dist (spec allows this — running npm inside tests would be neither
hermetic nor fast), then boots the bundled ``serve.py`` in a subprocess and exercises it
over HTTP: 200s, JSON content types on the extension-less baked API files, and the 206
Range behavior video seeking depends on (phase-5 pitfall #4).
"""
from __future__ import annotations
import json
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required to build the synthetic capsule fixture",
)
CAMPATH = {
"version": 1,
"keyframes": [
{"t_global": 0.2, "pos": [0.0, 1.0, 5.0], "quat": [0.0, 0.0, 0.0, 1.0], "fov": 50.0},
{"t_global": 0.8, "pos": [2.0, 1.0, 4.0], "quat": [0.0, 0.5, 0.0, 0.8660254], "fov": 42.0},
],
}
BEATS = {"tempo_bpm": 120.0, "beats_s": [0.25, 0.75], "onsets": [],
"generated_by": "festival4d.audio_features"}
SPLAT_BYTES = b"ply\nfake-splat-for-tests\n"
API_SNIPPET = '<script>window.__F4D_API_BASE__=""</script>'
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def project():
"""Synthetic project + every optional artifact seeded (beats, splat, a saved path)."""
from festival4d import config, db, synthetic
synthetic.build(duration_s=1.0, run_ffmpeg=True) # into the temp data dir (conftest)
db.init_engine()
db.init_db()
config.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
config.BEATS_JSON.write_text(json.dumps(BEATS))
config.SPLAT_PLY.write_bytes(SPLAT_BYTES)
saved = db.add_path("capsule sweep", json.dumps(CAMPATH))
yield {"path_id": saved.id}
# Leave the shared session data dir as later test modules expect to find it.
config.BEATS_JSON.unlink(missing_ok=True)
config.SPLAT_PLY.unlink(missing_ok=True)
db.delete_path(saved.id)
def _make_dist(root):
"""A minimal Vite-shaped dist: index.html with a module <script> + one asset."""
root.mkdir(parents=True, exist_ok=True)
(root / "assets").mkdir(exist_ok=True)
(root / "assets" / "index.js").write_text("console.log('festival4d test dist');\n")
(root / "index.html").write_text(
"<!doctype html>\n<html>\n <head>\n <meta charset=\"UTF-8\" />\n"
" <title>Festival 4D</title>\n"
" <script type=\"module\" crossorigin src=\"/assets/index.js\"></script>\n"
" </head>\n <body><div id=\"app\"></div></body>\n</html>\n"
)
return root
@pytest.fixture(scope="module")
def fake_dist(tmp_path_factory):
return _make_dist(tmp_path_factory.mktemp("frontend") / "dist")
@pytest.fixture(scope="module")
def bundle(project, fake_dist, tmp_path_factory):
from festival4d import capsule
out = tmp_path_factory.mktemp("capsule") / "bundle"
summary = capsule.build_capsule(out_dir=out, dist_dir=fake_dist)
return out, summary
@pytest.fixture(scope="module")
def server(bundle):
"""The bundle's own serve.py, running as a real subprocess in the bundle root."""
out, _ = bundle
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
proc = subprocess.Popen(
[sys.executable, "serve.py", "--port", str(port)],
cwd=out, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
base = f"http://127.0.0.1:{port}"
try:
deadline = time.monotonic() + 15
while True:
try:
urllib.request.urlopen(f"{base}/api/manifest", timeout=1).read()
break
except OSError:
if proc.poll() is not None or time.monotonic() > deadline:
raise RuntimeError(
f"serve.py did not come up: {proc.stdout.read().decode(errors='replace')}"
)
time.sleep(0.1)
yield base
finally:
proc.terminate()
proc.wait(timeout=5)
def _get(url, headers=None):
"""GET returning (status, headers, body); headers stay a case-insensitive Message."""
req = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, resp.headers, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.headers, err.read()
# ---------------------------------------------------------------------------
# Bundle structure
# ---------------------------------------------------------------------------
def test_bundle_structure(bundle):
from festival4d import db
out, summary = bundle
for rel in ("index.html", "serve.py", "assets/index.js",
"api/manifest", "api/events", "api/anchors",
# phase 6 (foundation3 / M18): friend tracks baked read-only — CR-6.
"api/tracks",
"api/beats", "api/pointcloud", "api/splat",
# the paths listing lives at paths/index.html so per-id files can
# coexist under the same URL prefix (see capsule.py)
"api/paths/index.html"):
assert (out / rel).is_file(), f"missing {rel}"
for v in db.get_videos():
assert (out / "media" / v.filename).is_file()
assert (out / "api" / "videos" / str(v.id) / "poses").is_file()
assert (out / "api" / "audio" / str(v.id)).is_file()
assert summary["bundle"] == str(out)
assert summary["files"] >= 10
assert summary["bytes"] > 0
def test_baked_manifest_is_capsule_true_and_urls_resolve(bundle):
out, _ = bundle
manifest = json.loads((out / "api" / "manifest").read_text())
assert manifest["capsule"] is True
assert manifest["has_capture"] is False # capture routes never exist in a static bundle
assert manifest["has_poses"] is True and manifest["has_splat"] is True
assert manifest["t_global_max"] > 0
assert len(manifest["videos"]) >= 2
for v in manifest["videos"]:
# Every manifest URL must resolve inside the bundle (API_BASE == "" ⇒ root-relative).
assert v["url"].startswith("/media/")
assert (out / v["url"].lstrip("/")).is_file()
def test_baked_json_matches_live_api_shapes(bundle, project):
from festival4d import api, db
out, _ = bundle
manifest = json.loads((out / "api" / "manifest").read_text())
live = api.manifest()
assert set(manifest) == set(live) # key set identical to the live route
assert json.loads((out / "api" / "events").read_text()) == api.get_events()
assert json.loads((out / "api" / "anchors").read_text()) == api.get_anchors()
for v in db.get_videos():
baked = json.loads((out / "api" / "videos" / str(v.id) / "poses").read_text())
assert baked == api.video_poses(v.id)
assert len(baked) > 0
assert json.loads((out / "api" / "beats").read_text()) == BEATS
assert (out / "api" / "splat").read_bytes() == SPLAT_BYTES
assert (out / "api" / "pointcloud").read_bytes()[:3] == b"ply"
# Saved camera paths (M16) baked read-only so the load dropdown works zero-backend.
listing = json.loads((out / "api" / "paths" / "index.html").read_text())
assert [p["name"] for p in listing] == ["capsule sweep"]
pid = project["path_id"]
assert json.loads((out / "api" / "paths" / str(pid)).read_text())["json"] == CAMPATH
def test_index_html_injects_api_base_before_app_script(bundle):
out, _ = bundle
html = (out / "index.html").read_text()
assert html.count(API_SNIPPET) == 1
assert html.index(API_SNIPPET) < html.index('type="module"')
def test_optional_artifacts_omitted_when_absent(project, fake_dist, tmp_path):
"""Degrade, don't block: no beats/splat ⇒ those files are simply not baked."""
from festival4d import capsule, config
beats, splat = config.BEATS_JSON.read_text(), config.SPLAT_PLY.read_bytes()
config.BEATS_JSON.unlink()
config.SPLAT_PLY.unlink()
try:
out = tmp_path / "bare"
capsule.build_capsule(out_dir=out, dist_dir=fake_dist)
assert not (out / "api" / "beats").exists()
assert not (out / "api" / "splat").exists()
manifest = json.loads((out / "api" / "manifest").read_text())
assert manifest["has_splat"] is False
assert manifest["capsule"] is True
finally:
config.BEATS_JSON.write_text(beats)
config.SPLAT_PLY.write_bytes(splat)
def test_missing_dist_errors_clearly(tmp_path):
from festival4d import capsule
with pytest.raises(RuntimeError, match="npm run build"):
capsule.build_capsule(out_dir=tmp_path / "never", dist_dir=tmp_path / "no-dist")
assert not (tmp_path / "never").exists()
def test_rebuild_overwrites_previous_capsule_but_not_strangers(bundle, fake_dist, project, tmp_path):
from festival4d import capsule
# A non-capsule, non-empty directory is refused (don't eat someone's folder).
stranger = tmp_path / "stranger"
stranger.mkdir()
(stranger / "precious.txt").write_text("do not delete")
with pytest.raises(RuntimeError, match="refusing"):
capsule.build_capsule(out_dir=stranger, dist_dir=fake_dist)
assert (stranger / "precious.txt").exists()
# Rebuilding over a previous capsule works and never double-injects the snippet.
out, _ = bundle
capsule.build_capsule(out_dir=out, dist_dir=fake_dist)
assert (out / "index.html").read_text().count(API_SNIPPET) == 1
def test_cli_capsule_dispatch(project, fake_dist, tmp_path, monkeypatch):
"""`python -m festival4d capsule` (CR-4: the stub exit-2 contract is retired)."""
from festival4d import cli, config
# The CLI has no --dist flag; point the repo-root lookup at a fabricated tree.
fake_root = tmp_path / "repo"
_make_dist(fake_root / "frontend" / "dist")
monkeypatch.setattr(config, "REPO_ROOT", fake_root)
out = tmp_path / "cli-bundle"
assert cli.main(["capsule", "--out", str(out)]) == 0
assert (out / "serve.py").is_file()
assert json.loads((out / "api" / "manifest").read_text())["capsule"] is True
# And the no-dist error path is a clear message, not a stub exit.
monkeypatch.setattr(config, "REPO_ROOT", tmp_path / "empty-repo")
with pytest.raises(RuntimeError, match="npm run build"):
cli.main(["capsule", "--out", str(tmp_path / "nope")])
# ---------------------------------------------------------------------------
# serve.py over real HTTP
# ---------------------------------------------------------------------------
def test_serve_index_and_json_content_types(server):
status, headers, body = _get(f"{server}/")
assert status == 200
assert headers["Content-Type"].startswith("text/html")
assert API_SNIPPET.encode() in body
status, headers, body = _get(f"{server}/api/manifest")
assert status == 200
assert headers["Content-Type"] == "application/json" # extension-less baked file
assert json.loads(body)["capsule"] is True
for rel, ctype in (("api/events", "application/json"),
("api/paths", "application/json"),
("api/pointcloud", "application/octet-stream"),
("api/splat", "application/octet-stream")):
status, headers, _ = _get(f"{server}/{rel}")
assert (status, headers["Content-Type"]) == (200, ctype), rel
def test_serve_video_range_206(server, bundle):
out, _ = bundle
manifest = json.loads((out / "api" / "manifest").read_text())
url = manifest["videos"][0]["url"] # "/media/<filename>"
size = (out / url.lstrip("/")).stat().st_size
# Plain GET: 200, full body, and Range support advertised (browsers probe this).
status, headers, body = _get(f"{server}{url}")
assert status == 200
assert headers["Accept-Ranges"] == "bytes"
assert int(headers["Content-Length"]) == size == len(body)
# bytes=0-100 ⇒ 206 with exactly 101 bytes and a correct Content-Range.
status, headers, body = _get(f"{server}{url}", {"Range": "bytes=0-100"})
assert status == 206
assert headers["Content-Range"] == f"bytes 0-100/{size}"
assert len(body) == 101
assert body[4:8] == b"ftyp" # mp4 magic — it's really the video's first bytes
# Open-ended seek (bytes=N-) ⇒ the tail from N.
mid = size // 2
status, headers, body = _get(f"{server}{url}", {"Range": f"bytes={mid}-"})
assert status == 206
assert headers["Content-Range"] == f"bytes {mid}-{size - 1}/{size}"
assert len(body) == size - mid
# Suffix form (bytes=-N) ⇒ the final N bytes (mp4 moov probing does this).
status, headers, body = _get(f"{server}{url}", {"Range": "bytes=-50"})
assert status == 206
assert headers["Content-Range"] == f"bytes {size - 50}-{size - 1}/{size}"
assert len(body) == 50
# Start past EOF ⇒ 416 with the total size.
status, headers, _ = _get(f"{server}{url}", {"Range": f"bytes={size + 10}-"})
assert status == 416
assert headers["Content-Range"] == f"bytes */{size}"
def test_serve_audio_bytes_and_range(server, bundle):
status, headers, body = _get(f"{server}/api/audio/1")
assert status == 200
assert headers["Content-Type"] == "audio/mp4"
assert body[4:8] == b"ftyp" # m4a container magic
# The WebAudio clock fetches whole files, but Range must still work on audio.
status, headers, part = _get(f"{server}/api/audio/1", {"Range": "bytes=0-15"})
assert status == 206 and part == body[:16]
def test_serve_missing_file_404(server):
assert _get(f"{server}/api/nope")[0] == 404
assert _get(f"{server}/media/ghost.mp4")[0] == 404