festifun/backend/tests/test_phase5_api.py
type-two 0f8685b49e M10: audio features — beats & onsets from the reference ingest WAV
run_features() fills the foundation2 stub: librosa beat/tempo tracking +
onset-strength detection at a 10 ms hop (librosa's default 32 ms grid at
16 kHz quantizes past the +/-50 ms acceptance bound), tempo from the median
inter-beat interval, times mapped to t_global via the frozen timebase
helpers. Degradation per house style: missing videos/WAV -> summary with
note, nothing written; silent/short/beatless audio -> valid-empty
beats.json, never a crash.

Acceptance (test_audio_features.py, 7 tests): synthetic 120 BPM grid
recovered — tempo 120.0 (+/-2 required), worst beat 30 ms off grid
(+/-50 required); the three ground-truth bangs appear as strong onsets;
silence/short/missing all degrade cleanly; CLI dispatch end-to-end.

CR-4: minimal stub-era assertion updates in test_phase5_api.py (CLI stub
loop shrinks to capsule) per the approved CR-1 precedent — see
plan/CHANGE_REQUESTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:48:31 +10:00

112 lines
4.3 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_returns_valid_campath(client):
# CR-4: lane E's M11 landed, so on the synthetic fixture (events + poses present) the
# response is a REAL path — assert the frozen camPath shape (holds stubbed or implemented).
# The empty+note degradation (no events / no poses) is covered in test_director.py.
data = client.post("/api/director", json={}).json()
assert data["version"] == 1
assert isinstance(data["keyframes"], list)
for kf in data["keyframes"]:
assert set(kf) == {"t_global", "pos", "quat", "fov"}
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 still-stubbed subcommands degrade per the house pattern: exit code 2, no traceback.
# CR-4: `features` and `direct` left this list when lane E landed M10/M11 (their real
# dispatch is covered in test_audio_features.py / test_director.py); lane G drops
# `capsule` when M17 lands.
from festival4d import cli
for cmd in (["capsule"],):
assert cli.main(cmd) == 2