diff --git a/backend/tests/test_paths.py b/backend/tests/test_paths.py new file mode 100644 index 0000000..6b1b7a5 --- /dev/null +++ b/backend/tests/test_paths.py @@ -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