From 0314daec1a77104dc985b13a9412d18875c5a4e8 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:42:19 +1000 Subject: [PATCH 1/9] =?UTF-8?q?lane=20G:=20CR-4=20=E2=80=94=20retire=20the?= =?UTF-8?q?=20obsolete=20capsule=20stub=20assertion=20(M17=20landed;=20CR-?= =?UTF-8?q?1=20precedent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/tests/test_phase5_api.py | 7 ++++--- plan/CHANGE_REQUESTS.md | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_phase5_api.py b/backend/tests/test_phase5_api.py index 92f630b..d8d9886 100644 --- a/backend/tests/test_phase5_api.py +++ b/backend/tests/test_phase5_api.py @@ -99,9 +99,10 @@ def test_anchor_patch_label_and_color(client): def test_cli_dispatches_stubs_gracefully(): - # The three new subcommands exist and degrade per the house pattern: exit code 2, - # no traceback, while their lane entrypoints are stubs. + # The new subcommands exist and degrade per the house pattern: exit code 2, + # no traceback, while their lane entrypoints are stubs. `capsule` landed + # (lane G / M17, CR-4) — its CLI dispatch is covered in test_capsule.py. from festival4d import cli - for cmd in (["features"], ["direct"], ["capsule"]): + for cmd in (["features"], ["direct"]): assert cli.main(cmd) == 2 diff --git a/plan/CHANGE_REQUESTS.md b/plan/CHANGE_REQUESTS.md index 2f9ff9f..29817d0 100644 --- a/plan/CHANGE_REQUESTS.md +++ b/plan/CHANGE_REQUESTS.md @@ -72,3 +72,18 @@ Instead append an entry here and work around it locally; the integration agent a pre-existing route/shape/column and the timebase + pose contracts are unchanged; the annotation response only gained a field. Suite 101 → **103** (added supersede + delete tests). - **Decision:** **APPLIED** (phase4, 2026-07-16). Fixes the sole Round-3 wart; strictly additive. + +### CR-4 — lane G — 2026-07-17 — Drop obsolete `capsule` stub assertion in test_phase5_api.py +- **File:** `backend/tests/test_phase5_api.py::test_cli_dispatches_stubs_gracefully` +- **Problem:** The foundation2 test asserts `cli.main(["capsule"]) == 2`, i.e. the *stub* + contract (`build_capsule` raises `NotImplementedError`). With M17 landed the entrypoint is + real, so the CLI no longer exits 2 — the assertion fails by design, exactly like CR-1 when + lane D landed M7. +- **Proposed change:** remove only `["capsule"]` from the loop (features/direct stay — lane E + still stubbed) with a comment pointing at the landed coverage. The capsule CLI dispatch is + now covered for real in `backend/tests/test_capsule.py` (builds a bundle via + `cli.main(["capsule", "--out", ...])`, and asserts the clear no-dist error path). +- **Workaround in place:** none possible without perverting `build_capsule` semantics (the + spec requires a clear "run npm run build" error, not `NotImplementedError`); applied the + minimal edit directly per the CR-1 precedent, flagged here for integration2 to ratify. +- **Decision:** (integration2 fills this) From 1c0422a02550c79b50ab4b2417905fc36a94577c Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:42:19 +1000 Subject: [PATCH 2/9] =?UTF-8?q?lane=20G=20M16:=20pathsStore=20=E2=80=94=20?= =?UTF-8?q?save/load/delete=20server-side=20camera=20paths=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unhides #btn-path-save / #path-load-select / #btn-path-del; save prompts a name and POSTs camPath.toJSON(), load fetches + camPath.fromJSON, delete confirms; dropdown refreshes after every mutation. Capsule mode: write buttons hide via write-ui, the load dropdown keeps working against baked api/paths (hides itself if unavailable). Co-Authored-By: Claude Fable 5 --- frontend/src/pathsStore.js | 121 ++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/frontend/src/pathsStore.js b/frontend/src/pathsStore.js index 7f8b2a6..e43cbc7 100644 --- a/frontend/src/pathsStore.js +++ b/frontend/src/pathsStore.js @@ -1,4 +1,4 @@ -// Server-side camera paths UI (spec M16, lane G). STUB — lane G fills THIS FILE ONLY. +// Server-side camera paths UI (spec M16, lane G). // // Pre-wired by foundation2: the hidden controls in index.html — #btn-path-save (💾), // #path-load-select (dropdown), #btn-path-del (🗑) — this module's import + init() call in @@ -6,13 +6,120 @@ // GET /api/paths/{id} ({..., json: }), POST /api/paths {name, json:} // (422 on invalid), DELETE /api/paths/{id}. // -// When implementing: unhide the controls, set `ready = true`. Save = prompt for a name, -// POST camPath.toJSON(); load = fetch the selected path and camPath.fromJSON(json); delete = -// DELETE the selected id. Refresh the dropdown after every mutation. #btn-path-save and -// #btn-path-del already carry the `write-ui` class, so capsule mode (contract #5) hides -// them for free — the load dropdown may stay if you bake paths in, else hide it too. +// Save = prompt for a name, POST camPath.toJSON(); load = fetch the selected path and +// camPath.fromJSON(json); delete = DELETE the selected id; the dropdown refreshes after +// every mutation. #btn-path-save / #btn-path-del carry `write-ui`, so capsule mode +// (contract #5) hides them for free; the load dropdown stays — the capsule bakes +// api/paths + api/paths/{id}, so loading keeps working zero-backend. If the listing +// fetch fails in a capsule (older bundle without baked paths), the dropdown hides too. + +import { state } from "./state.js"; +import { camPath } from "./camPath.js"; + +const PLACEHOLDER = "— saved paths —"; + +function el(id) { + return document.getElementById(id); +} + +async function api(path, options) { + const resp = await fetch(state.apiBase + path, options); + if (!resp.ok) throw new Error(`${path} -> HTTP ${resp.status}`); + return resp.json(); +} export const pathsStore = { ready: false, - init() {}, // ponytail: stub — lane G replaces the body + + init() { + const saveBtn = el("btn-path-save"); + const select = el("path-load-select"); + const delBtn = el("btn-path-del"); + if (!saveBtn || !select || !delBtn) return; + + saveBtn.addEventListener("click", () => this.save()); + delBtn.addEventListener("click", () => this.remove()); + select.addEventListener("change", () => this.load()); + + saveBtn.style.display = ""; + select.style.display = ""; + delBtn.style.display = ""; + this.ready = true; + this.refresh(); // async initial fill of the dropdown + }, + + /** Rebuild the dropdown from GET /api/paths, keeping the selection when it survives. */ + async refresh(selectId = null) { + const select = el("path-load-select"); + const keep = selectId != null ? String(selectId) : select.value; + let paths; + try { + paths = await api("/api/paths"); + } catch (err) { + console.warn("[festival4d] saved-paths listing unavailable", err); + if (state.capsule) select.style.display = "none"; // baked bundle without paths + return; + } + select.innerHTML = ""; + const ph = document.createElement("option"); + ph.value = ""; + ph.textContent = PLACEHOLDER; + select.appendChild(ph); + for (const p of paths) { + const opt = document.createElement("option"); + opt.value = String(p.id); + opt.textContent = p.name; + opt.title = p.created_at ?? ""; + select.appendChild(opt); + } + select.value = [...select.options].some((o) => o.value === keep) ? keep : ""; + }, + + /** 💾 Save the current camera path under a prompted name. */ + async save() { + if (!camPath.keyframes.length) { + window.alert("No keyframes to save — press K (or ⏺+) to add some first."); + return; + } + const name = window.prompt("Name this camera path:", `path ${new Date().toLocaleString()}`); + if (!name || !name.trim()) return; + try { + const created = await api("/api/paths", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), json: camPath.toJSON() }), + }); + await this.refresh(created.id); // leave the new path selected + } catch (err) { + console.error("[festival4d] saving camera path failed", err); + } + }, + + /** Load the selected saved path into camPath. */ + async load() { + const id = el("path-load-select").value; + if (!id) return; + try { + const data = await api(`/api/paths/${id}`); + camPath.fromJSON(data.json); + } catch (err) { + console.error("[festival4d] loading camera path failed", err); + this.refresh(); // it may have been deleted elsewhere — resync the dropdown + } + }, + + /** 🗑 Delete the selected saved path. */ + async remove() { + const select = el("path-load-select"); + const id = select.value; + if (!id) return; + const name = select.options[select.selectedIndex]?.textContent ?? id; + if (!window.confirm(`Delete saved path "${name}"?`)) return; + try { + await api(`/api/paths/${id}`, { method: "DELETE" }); + } catch (err) { + console.error("[festival4d] deleting camera path failed", err); + } + await this.refresh(); + }, }; From b37f1fab0a16b89021eaf09791e4737599d8d1bc Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:48:22 +1000 Subject: [PATCH 3/9] lane G M16: deepen paths-route coverage (30 tests) Roundtrip fidelity (float precision, 500-keyframe lists, extra keys ride along), multiple coexisting paths + id-ordered listing, duplicate/unicode/whitespace names, ISO created_at, 404s, and an 18-case 422 matrix over the frozen camPath contract. Cleans up after itself so test_phase5_api still sees an empty table. Co-Authored-By: Claude Fable 5 --- backend/tests/test_paths.py | 198 ++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 backend/tests/test_paths.py diff --git a/backend/tests/test_paths.py b/backend/tests/test_paths.py new file mode 100644 index 0000000..6b1b7a5 --- /dev/null +++ b/backend/tests/test_paths.py @@ -0,0 +1,198 @@ +"""Deepened coverage of the M16 saved camera-path routes (lane G). + +foundation2's basic shape tests live in test_phase5_api.py; this module hardens the +contract: deep-equal roundtrips (including float fidelity and large keyframe lists), +multiple coexisting paths, name handling, 404s, and the 422 validation surface of the +frozen camPath JSON (plan/20-phase5.md contract #2). + +Pure DB/API surface — no synthetic fixture or ffmpeg needed. The test session's data +dir is a temp dir (conftest), and every test cleans up the rows it created so later +modules (test_phase5_api.py runs after this one alphabetically) still see an empty table. +""" + +from __future__ import annotations + +import json +import math + +import pytest + + +def _campath(keyframes): + return {"version": 1, "keyframes": keyframes} + + +def _kf(t, pos=(0.0, 1.0, 5.0), quat=(0.0, 0.0, 0.0, 1.0), fov=50.0): + return {"t_global": t, "pos": list(pos), "quat": list(quat), "fov": fov} + + +SIMPLE = _campath([_kf(1.0), _kf(4.0, pos=(2, 1, 4), quat=(0, 0.3826834, 0, 0.9238795), fov=42)]) + + +@pytest.fixture(scope="module") +def client(): + from fastapi.testclient import TestClient + + from festival4d import api + + with TestClient(api.app) as c: + yield c + + +@pytest.fixture(autouse=True) +def _clean_paths(client): + """Leave the paths table exactly as found (empty) — later modules assert on it.""" + yield + for p in client.get("/api/paths").json(): + client.delete(f"/api/paths/{p['id']}") + + +def _post(client, name, obj_or_text): + text = obj_or_text if isinstance(obj_or_text, str) else json.dumps(obj_or_text) + return client.post("/api/paths", json={"name": name, "json": text}) + + +# --------------------------------------------------------------------------- +# Roundtrip fidelity +# --------------------------------------------------------------------------- +def test_roundtrip_preserves_float_precision(client): + # Awkward floats must survive POST -> store-as-text -> GET deep-equal (the live UI + # depends on "reload the page, load the path, land on the exact same keyframes"). + path = _campath([ + _kf(0.123456789012345, pos=(-1.5e-7, 2.0000000001, math.pi), fov=49.99999999), + _kf(1e3 + 1e-9, quat=(0.5, -0.5, 0.5, -0.5), fov=1e2 / 3), + ]) + created = _post(client, "precise", path).json() + assert created["json"] == path + assert client.get(f"/api/paths/{created['id']}").json()["json"] == path + + +def test_roundtrip_large_keyframe_list(client): + # A long recorded flythrough: hundreds of keyframes with non-round values. + path = _campath([ + _kf(i * 0.0333 + 0.0001 * i * i, + pos=(math.sin(i), 1 + 0.01 * i, math.cos(i)), + quat=(0.0, math.sin(i / 500), 0.0, math.cos(i / 500)), + fov=30 + (i % 40) * 0.77) + for i in range(500) + ]) + created = _post(client, "marathon", path).json() + got = client.get(f"/api/paths/{created['id']}").json()["json"] + assert got == path + assert len(got["keyframes"]) == 500 + + +def test_empty_keyframes_is_a_valid_path(client): + # The director degrades to {"version":1,"keyframes":[]} — that must be saveable. + resp = _post(client, "empty", _campath([])) + assert resp.status_code == 200 + assert resp.json()["json"] == _campath([]) + + +def test_extra_keys_in_campath_survive_roundtrip(client): + # Validation is a floor, not a strainer: unknown fields (e.g. the director's "note") + # ride along verbatim because the JSON is stored as text. + path = {**_campath([_kf(2.0)]), "note": "auto-director v1"} + created = _post(client, "annotated", path).json() + assert created["json"] == path + + +# --------------------------------------------------------------------------- +# Listing / multiple paths / name handling +# --------------------------------------------------------------------------- +def test_multiple_paths_coexist_and_list_in_id_order(client): + ids = [_post(client, name, SIMPLE).json()["id"] for name in ("a", "b", "c")] + listing = client.get("/api/paths").json() + assert [p["id"] for p in listing] == sorted(ids) + assert [p["name"] for p in listing] == ["a", "b", "c"] + # Listing is the summary shape only — the (potentially large) JSON stays off it. + assert all(set(p) == {"id", "name", "created_at"} for p in listing) + + # Deleting the middle one leaves the neighbors intact and loadable. + assert client.delete(f"/api/paths/{ids[1]}").status_code == 200 + left = client.get("/api/paths").json() + assert [p["id"] for p in left] == [ids[0], ids[2]] + assert client.get(f"/api/paths/{ids[2]}").json()["json"] == SIMPLE + + +def test_duplicate_names_are_allowed_distinct_rows(client): + a = _post(client, "same name", SIMPLE).json() + b = _post(client, "same name", SIMPLE).json() + assert a["id"] != b["id"] + assert [p["name"] for p in client.get("/api/paths").json()] == ["same name", "same name"] + + +def test_names_preserved_verbatim(client): + # Unicode, emoji, quotes, leading/trailing whitespace — stored and returned as-is. + for name in ("🎥 drop №5 — “final”", " padded ", "a" * 300): + created = _post(client, name, SIMPLE).json() + assert created["name"] == name + assert client.get(f"/api/paths/{created['id']}").json()["name"] == name + + +def test_created_at_is_an_iso_timestamp(client): + from datetime import datetime + + created = _post(client, "stamped", SIMPLE).json() + # Parses as ISO-8601; the frontend shows it as a tooltip. + assert isinstance(datetime.fromisoformat(created["created_at"]), datetime) + + +# --------------------------------------------------------------------------- +# 404s +# --------------------------------------------------------------------------- +def test_get_and_delete_unknown_id_404(client): + assert client.get("/api/paths/424242").status_code == 404 + assert client.delete("/api/paths/424242").status_code == 404 + + +def test_deleted_path_stays_deleted(client): + pid = _post(client, "doomed", SIMPLE).json()["id"] + assert client.delete(f"/api/paths/{pid}").json() == {"deleted": pid} + assert client.get(f"/api/paths/{pid}").status_code == 404 + assert client.delete(f"/api/paths/{pid}").status_code == 404 + + +# --------------------------------------------------------------------------- +# Validation (422) — the frozen camPath JSON contract, edge by edge +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "body", + [ + "", # empty text + "not json at all {", + json.dumps(None), + json.dumps([_kf(0.0)]), # top-level array, not object + json.dumps("a string"), + json.dumps({"keyframes": []}), # version missing + json.dumps({"version": "1", "keyframes": []}), # version wrong type + json.dumps({"version": 2, "keyframes": []}), # wrong version + json.dumps({"version": 1}), # keyframes missing + json.dumps({"version": 1, "keyframes": {}}), # keyframes not a list + json.dumps(_campath(["not a dict"])), + json.dumps(_campath([{k: v for k, v in _kf(0.0).items() if k != "t_global"}])), + json.dumps(_campath([{k: v for k, v in _kf(0.0).items() if k != "fov"}])), + json.dumps(_campath([{**_kf(0.0), "t_global": "0.0"}])), # time as string + json.dumps(_campath([{**_kf(0.0), "pos": [0.0, 1.0]}])), # pos wrong arity + json.dumps(_campath([{**_kf(0.0), "quat": [0, 0, 0, 1, 0]}])), # quat wrong arity + json.dumps(_campath([{**_kf(0.0), "pos": 5}])), # pos not a list + json.dumps(_campath([_kf(0.0), {**_kf(1.0), "quat": None}])), # one bad among good + ], +) +def test_invalid_campath_json_422(client, body): + assert client.post("/api/paths", json={"name": "bad", "json": body}).status_code == 422 + + +def test_validation_failure_creates_no_row(client): + assert client.get("/api/paths").json() == [] + assert _post(client, "bad", "{").status_code == 422 + assert client.get("/api/paths").json() == [] + + +def test_missing_request_fields_422(client): + assert client.post("/api/paths", json={"json": json.dumps(SIMPLE)}).status_code == 422 + assert client.post("/api/paths", json={"name": "x"}).status_code == 422 + # "json" is the wire key (contract #3) — the internal alias must not be accepted. + assert client.post( + "/api/paths", json={"name": "x", "path_json": json.dumps(SIMPLE)} + ).status_code == 422 From feb5588a1b03b94a2f6df2cea8b24f4ae2234485 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:48:22 +1000 Subject: [PATCH 4/9] =?UTF-8?q?lane=20G=20M17:=20memory=20capsule=20?= =?UTF-8?q?=E2=80=94=20zero-backend=20shareable=20bundle=20+=20Range-capab?= =?UTF-8?q?le=20serve.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_capsule copies frontend/dist (clear error if missing), copies referenced media, bakes every read-only API response extension-less at the live API's relative paths (manifest with capsule:true + has_capture:false, events, anchors, poses, beats, pointcloud, splat, audio via the same lazy extract_audio_hq cache, plus M16 saved paths — listing baked as api/paths/index.html so per-id files can share the prefix), injects window.__F4D_API_BASE__="" ahead of the app script, and ships a stdlib serve.py (206 Range slicing incl. suffix ranges + 416, JSON content types for the extension-less api/* files). Optional artifacts degrade by omission. test_capsule.py: bundle structure, baked-JSON deep-equal vs the live route functions, injection position, degrade/overwrite-guard/no-dist errors, CLI dispatch (CR-4), and serve.py exercised over real HTTP in a subprocess (200/206/416/404, content types, mp4+m4a magic bytes). Suite: 169 passed. Co-Authored-By: Claude Fable 5 --- backend/festival4d/capsule.py | 295 ++++++++++++++++++++++++++++- backend/tests/test_capsule.py | 337 ++++++++++++++++++++++++++++++++++ 2 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_capsule.py diff --git a/backend/festival4d/capsule.py b/backend/festival4d/capsule.py index bc94fd1..d8e4f0e 100644 --- a/backend/festival4d/capsule.py +++ b/backend/festival4d/capsule.py @@ -1,4 +1,4 @@ -"""Memory capsule: zero-backend shareable export (spec M17, lane G). STUB — lane G fills. +"""Memory capsule: zero-backend shareable export (spec M17, lane G). Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (default ``config.CAPSULE_DIR``): @@ -16,13 +16,300 @@ Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (d Skip what doesn't exist (no splat -> no ``api/splat``) — degrade, don't block. Returns a summary dict (bundle path, file count, bytes) for the CLI log. + +Layout mirror (why paths look the way they do): the frontend resolves every URL as +``API_BASE + path`` with API_BASE == "" in a capsule, so requests hit the bundle root: +``fetch("/api/manifest")`` (no extension — bake the file extension-less; serve.py maps +extension-less ``api/*`` files to a JSON content type), ``video.src = "/media/"`` +(manifest ``url`` fields are ``/media/{filename}`` — media files are copied under +``media/``), PLY loader hits ``/api/pointcloud``, the WebAudio clock fetches +``/api/audio/{id}``. Saved camera paths (M16) are read-only in a capsule, so +``api/paths`` + ``api/paths/{id}`` are baked too — the load dropdown keeps working. """ from __future__ import annotations +import json +import logging +import shutil from pathlib import Path +from festival4d import config, db -def build_capsule(out_dir: str | Path | None = None) -> dict: - """Build the static shareable bundle (see module doc).""" - raise NotImplementedError("memory capsule (lane G / M17)") +log = logging.getLogger("festival4d.capsule") + +# Injected verbatim (phase-5 contract #5): a runtime override that beats the build-time +# VITE_API_BASE, turning the same dist bundle zero-backend. Must land in index.html before +# the app module reads it (modules are deferred, but we inject ahead of the first ' + + +# --------------------------------------------------------------------------- +# serve.py shipped in the bundle root (stdlib-only, Range-capable — pitfall #4) +# --------------------------------------------------------------------------- +SERVE_PY = r'''#!/usr/bin/env python3 +"""Serve this Festival 4D memory capsule locally. + +Plain `python -m http.server` cannot answer HTTP Range requests, and