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