spelunkai/labeling/backend/tests/test_dataset_versions.py
Jonas 4e993ccf52 Add dataset version promotion (enemy-v1, enemy-v2, ...)
Add DatasetVersion/DatasetVersionFrame/DatasetVersionLabel and a
promote endpoint (POST /sets/{id}/dataset-versions) that freezes a
set's currently-reviewed frames (or an explicit frame_ids selection)
into a named, immutable snapshot: it copies each label's data at
promotion time rather than referencing the live rows, so later edits
or deletes to those labels can't retroactively change an already
-promoted version. GET /dataset-versions/{id} returns the frozen
frames+labels - this is what the training pipeline will eventually
pull from.

This was the labeling backend's last deliberately-deferred piece from
the original data model (needed its own design pass for snapshot
semantics). 31/31 backend tests pass, including one that promotes a
version, edits and deletes the live label afterward, and asserts the
snapshot is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:08:17 +02:00

112 lines
4.8 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 _make_reviewed_frame_with_label(client, set_id, sub_class_id, frame_index):
frame_id = _make_frame(client, frame_index=frame_index)
client.post(
f"/frames/{frame_id}/sets/{set_id}/labels",
json={"sub_class_id": sub_class_id, "x": 1, "y": 2, "width": 3, "height": 4},
)
client.put(f"/frames/{frame_id}/sets/{set_id}/status", json={"status": "reviewed"})
return frame_id
def test_promote_defaults_to_reviewed_frames(client):
set_id, sub_class_id = _make_set_with_subclass(client)
reviewed_frame = _make_reviewed_frame_with_label(client, set_id, sub_class_id, frame_index=0)
_make_frame(client, frame_index=1) # stays unlabeled, should not be promoted
resp = client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1"})
assert resp.status_code == 201
assert resp.json()["frame_count"] == 1
detail = client.get(f"/dataset-versions/{resp.json()['id']}").json()
assert [f["frame"]["id"] for f in detail["frames"]] == [reviewed_frame]
assert detail["frames"][0]["labels"][0]["sub_class_id"] == sub_class_id
def test_promote_rejects_duplicate_name(client):
set_id, sub_class_id = _make_set_with_subclass(client)
_make_reviewed_frame_with_label(client, set_id, sub_class_id, frame_index=0)
client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1"})
resp = client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1"})
assert resp.status_code == 409
def test_promote_rejects_empty_selection(client):
set_id, _ = _make_set_with_subclass(client)
resp = client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1"})
assert resp.status_code == 422
def test_promote_with_explicit_frame_ids(client):
set_id, sub_class_id = _make_set_with_subclass(client)
frame_id = _make_frame(client, frame_index=0) # not reviewed, but explicitly selected
client.post(
f"/frames/{frame_id}/sets/{set_id}/labels",
json={"sub_class_id": sub_class_id, "x": 0, "y": 0, "width": 1, "height": 1},
)
resp = client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1", "frame_ids": [frame_id]})
assert resp.status_code == 201
assert resp.json()["frame_count"] == 1
def test_promote_rejects_unknown_frame_ids(client):
set_id, _ = _make_set_with_subclass(client)
resp = client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1", "frame_ids": [999]})
assert resp.status_code == 422
def test_promote_requires_existing_set(client):
resp = client.post("/sets/999/dataset-versions", json={"name": "v1"})
assert resp.status_code == 404
def test_dataset_version_is_frozen_after_label_edits(client):
set_id, sub_class_id = _make_set_with_subclass(client)
frame_id = _make_reviewed_frame_with_label(client, set_id, sub_class_id, frame_index=0)
version_id = client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1"}).json()["id"]
original_labels = client.get(f"/dataset-versions/{version_id}").json()["frames"][0]["labels"]
assert original_labels[0]["x"] == 1
# edit the live label after promotion
label_id = client.get(f"/frames/{frame_id}/sets/{set_id}/labels").json()[0]["id"]
client.patch(f"/labels/{label_id}", json={"x": 999})
# the promoted snapshot must be unaffected
frozen_labels = client.get(f"/dataset-versions/{version_id}").json()["frames"][0]["labels"]
assert frozen_labels[0]["x"] == 1
# deleting the live label must not touch the snapshot either
client.delete(f"/labels/{label_id}")
frozen_labels_after_delete = client.get(f"/dataset-versions/{version_id}").json()["frames"][0]["labels"]
assert len(frozen_labels_after_delete) == 1
def test_list_dataset_versions(client):
set_id, sub_class_id = _make_set_with_subclass(client)
_make_reviewed_frame_with_label(client, set_id, sub_class_id, frame_index=0)
client.post(f"/sets/{set_id}/dataset-versions", json={"name": "v1"})
resp = client.get(f"/sets/{set_id}/dataset-versions")
assert resp.status_code == 200
assert [v["name"] for v in resp.json()] == ["v1"]
assert resp.json()[0]["frame_count"] == 1
def test_get_missing_dataset_version(client):
resp = client.get("/dataset-versions/999")
assert resp.status_code == 404