"""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