"""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 = '' # --------------------------------------------------------------------------- # 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 \n" " \n
\n\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/" 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