- DB: paths table (contract #3) + helpers; patch_anchor (label/color) - API: GET /api/beats, POST /api/director (valid-empty degradation), /api/paths CRUD (validated POST -> 422), PATCH /api/anchors/{id}; manifest gains capsule:false (test_api.py key-set lock updated consciously) - CLI: features | direct | capsule subcommands -> lane stubs (exit-2 degradation) - Backend stubs with frozen contracts in docstrings: audio_features, director, capsule - Frontend: six lane stub modules imported from main.js; all phase-5 buttons pre-wired hidden in index.html; write-ui/body.capsule read-only mode; __F4D_API_BASE__ runtime override in state.js - Hooks (contract #6): transport.captureAudioStream/releaseAudioStream, scene3d.focusOn, scene3d.setHelpersVisible (+registerHelper; camPath gizmos) - Suite 121 -> 127 passed; npm build clean; live-browser verified (status file) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
4.0 KiB
Python
108 lines
4.0 KiB
Python
"""Phase-5 foundation2 contract tests: the new route shapes lanes E/F/G depend on
|
|
(plan/20-phase5.md "Contracts"). Lane G deepens paths coverage in test_paths.py;
|
|
lane E owns the real beats/director behavior tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
|
|
reason="ffmpeg/ffprobe required to build the API test fixture",
|
|
)
|
|
|
|
CAMPATH = {
|
|
"version": 1,
|
|
"keyframes": [
|
|
{"t_global": 1.0, "pos": [0, 1, 5], "quat": [0, 0, 0, 1], "fov": 50},
|
|
{"t_global": 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 synthetic
|
|
|
|
synthetic.build(duration_s=1.0, run_ffmpeg=True) # into the temp data dir (conftest)
|
|
|
|
from festival4d import api
|
|
|
|
with TestClient(api.app) as c:
|
|
yield c
|
|
|
|
|
|
def test_beats_404_then_serves_file(client):
|
|
from festival4d import config
|
|
|
|
config.BEATS_JSON.unlink(missing_ok=True)
|
|
assert client.get("/api/beats").status_code == 404
|
|
body = {"tempo_bpm": 120.0, "beats_s": [0.5, 1.0], "onsets": [], "generated_by": "festival4d.audio_features"}
|
|
config.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
config.BEATS_JSON.write_text(json.dumps(body))
|
|
try:
|
|
assert client.get("/api/beats").json() == body
|
|
finally:
|
|
config.BEATS_JSON.unlink()
|
|
|
|
|
|
def test_director_degrades_to_valid_empty_campath(client):
|
|
# While lane E is stubbed the response must still round-trip through camPath.fromJSON:
|
|
# a valid version-1 path with zero keyframes, plus a "note" marking the degradation.
|
|
data = client.post("/api/director", json={}).json()
|
|
assert data["version"] == 1
|
|
assert data["keyframes"] == []
|
|
assert "note" in data
|
|
assert client.post("/api/director", json={"top_n": 3, "lead_s": 1.0}).status_code == 200
|
|
|
|
|
|
def test_paths_crud_roundtrip(client):
|
|
assert client.get("/api/paths").json() == []
|
|
created = client.post(
|
|
"/api/paths", json={"name": "sweep", "json": json.dumps(CAMPATH)}
|
|
).json()
|
|
assert set(created) == {"id", "name", "created_at", "json"}
|
|
assert created["json"] == CAMPATH # deep-equal roundtrip
|
|
pid = created["id"]
|
|
|
|
listing = client.get("/api/paths").json()
|
|
assert [set(p) for p in listing] == [{"id", "name", "created_at"}]
|
|
assert client.get(f"/api/paths/{pid}").json()["json"] == CAMPATH
|
|
|
|
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
|
|
|
|
|
|
def test_paths_post_validates_campath(client):
|
|
post = lambda body: client.post("/api/paths", json={"name": "bad", "json": body}).status_code
|
|
assert post("not json at all {") == 422
|
|
assert post(json.dumps({"version": 2, "keyframes": []})) == 422
|
|
assert post(json.dumps({"version": 1})) == 422
|
|
assert post(json.dumps({"version": 1, "keyframes": [{"t_global": 0}]})) == 422
|
|
|
|
|
|
def test_anchor_patch_label_and_color(client):
|
|
a = client.post("/api/anchors", json={"label": "F", "x": 0, "y": 0, "z": 0}).json()
|
|
patched = client.patch(f"/api/anchors/{a['id']}", json={"label": "Fred", "color": "#4dd0ff"}).json()
|
|
assert patched["label"] == "Fred" and patched["color"] == "#4dd0ff"
|
|
assert (patched["x"], patched["y"], patched["z"]) == (0, 0, 0) # position untouched
|
|
# partial patch: color-only leaves the label alone
|
|
assert client.patch(f"/api/anchors/{a['id']}", json={"color": "#fff"}).json()["label"] == "Fred"
|
|
assert client.patch("/api/anchors/99999", json={"label": "x"}).status_code == 404
|
|
|
|
|
|
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.
|
|
from festival4d import cli
|
|
|
|
for cmd in (["features"], ["direct"], ["capsule"]):
|
|
assert cli.main(cmd) == 2
|