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>
112 lines
4.3 KiB
Python
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
|