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 <noreply@anthropic.com>
This commit is contained in:
parent
1c0422a025
commit
b37f1fab0a
198
backend/tests/test_paths.py
Normal file
198
backend/tests/test_paths.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user