spelunkai/labeling/backend/tests/test_labels.py
Jonas c5fd4de09b Implement labeling frontend and the backend support it needs
Add a framework-free HTML/CSS/JS labeling UI: set/session/status
filters, a frame browser, drag-to-draw/move/resize bounding boxes with
a per-set class picker, per-frame status control, and Left/Right frame
navigation. No login - a locally cached username is sent for
attribution only, matching the backend's get-or-create user model.

Backend additions the frontend needed: serve frame images from a
configurable FRAMES_ROOT via a /images static mount, permissive CORS
(internal tool, not publicly exposed), and a GET /sets/{id}/frames
endpoint returning frames joined with their per-set label status
(defaulting missing rows to unlabeled) for the frame browser.

Verified the full call chain end-to-end against a running backend +
static frontend server (set/class creation, frame ingest, image
serving, label CRUD, status updates, CORS preflight) - every field
name the JS reads matches the API responses. 18/18 backend tests
pass. Not yet verified: actual interactive browser use (no browser
tooling available here) - try drag-to-draw/resize locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:34:26 +02:00

107 lines
4.1 KiB
Python

def _make_set_with_subclass(client, name="Enemy", sub_name="Bat"):
set_id = client.post("/sets", json={"name": name}).json()["id"]
main_class_id = client.post(f"/sets/{set_id}/main-classes", json={"name": name}).json()["id"]
sub_class_id = client.post(f"/main-classes/{main_class_id}/sub-classes", json={"name": sub_name}).json()["id"]
return set_id, sub_class_id
def _make_frame(client, session_name="run01", frame_index=0):
payload = {"session_name": session_name, "frame_index": frame_index, "image_path": "x.png", "width": 1280, "height": 720}
return client.post("/frames", json=payload).json()["id"]
def test_create_list_update_delete_label(client):
set_id, sub_class_id = _make_set_with_subclass(client)
frame_id = _make_frame(client)
resp = client.post(
f"/frames/{frame_id}/sets/{set_id}/labels",
json={"sub_class_id": sub_class_id, "x": 10, "y": 20, "width": 30, "height": 40, "created_by": "jonas"},
)
assert resp.status_code == 201
label_id = resp.json()["id"]
resp = client.get(f"/frames/{frame_id}/sets/{set_id}/labels")
assert len(resp.json()) == 1
resp = client.patch(f"/labels/{label_id}", json={"x": 15})
assert resp.status_code == 200
assert resp.json()["x"] == 15
resp = client.delete(f"/labels/{label_id}")
assert resp.status_code == 204
resp = client.get(f"/frames/{frame_id}/sets/{set_id}/labels")
assert resp.json() == []
def test_label_rejects_sub_class_from_another_set(client):
_, sub_class_id = _make_set_with_subclass(client, name="Enemy", sub_name="Bat")
other_set_id, _ = _make_set_with_subclass(client, name="Items", sub_name="Gold")
frame_id = _make_frame(client)
resp = client.post(
f"/frames/{frame_id}/sets/{other_set_id}/labels",
json={"sub_class_id": sub_class_id, "x": 0, "y": 0, "width": 1, "height": 1},
)
assert resp.status_code == 422
def test_labels_require_existing_frame_and_set(client):
_, sub_class_id = _make_set_with_subclass(client)
resp = client.post(
"/frames/999/sets/999/labels",
json={"sub_class_id": sub_class_id, "x": 0, "y": 0, "width": 1, "height": 1},
)
assert resp.status_code == 404
def test_frame_set_status_defaults_and_updates(client):
set_id, _ = _make_set_with_subclass(client)
frame_id = _make_frame(client)
resp = client.get(f"/frames/{frame_id}/sets/{set_id}/status")
assert resp.status_code == 200
assert resp.json()["status"] == "unlabeled"
resp = client.put(
f"/frames/{frame_id}/sets/{set_id}/status",
json={"status": "reviewed", "updated_by": "jonas"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "reviewed"
resp = client.get(f"/frames/{frame_id}/sets/{set_id}/status")
assert resp.json()["status"] == "reviewed"
def test_list_frames_for_set_defaults_to_unlabeled_and_filters_by_status(client):
set_id, _ = _make_set_with_subclass(client)
frame1 = _make_frame(client, session_name="run01", frame_index=0)
frame2 = _make_frame(client, session_name="run01", frame_index=1)
client.put(f"/frames/{frame2}/sets/{set_id}/status", json={"status": "reviewed"})
resp = client.get(f"/sets/{set_id}/frames")
assert resp.status_code == 200
status_by_frame = {row["frame"]["id"]: row["status"] for row in resp.json()}
assert status_by_frame[frame1] == "unlabeled"
assert status_by_frame[frame2] == "reviewed"
resp = client.get(f"/sets/{set_id}/frames", params={"status": "reviewed"})
assert [row["frame"]["id"] for row in resp.json()] == [frame2]
def test_list_frames_for_set_filters_by_session(client):
set_id, _ = _make_set_with_subclass(client)
_make_frame(client, session_name="run01", frame_index=0)
_make_frame(client, session_name="run02", frame_index=0)
resp = client.get(f"/sets/{set_id}/frames", params={"session_name": "run02"})
assert len(resp.json()) == 1
assert resp.json()[0]["frame"]["session_name"] == "run02"
def test_list_frames_for_set_requires_existing_set(client):
resp = client.get("/sets/999/frames")
assert resp.status_code == 404