Implement labeling backend: FastAPI + SQLAlchemy data model and CRUD API

Add the core labeling data model (label sets, ad-hoc Main->Sub class
hierarchy, frames, bounding-box labels, per-frame/per-set label
status) behind a FastAPI app, with SQLite as the default swappable
DATABASE_URL. Multi-user support is attribution-only for now
(get-or-create by username, no login flow yet). Dataset
versioning/promotion is intentionally deferred - it needs its own
design pass around snapshot semantics.

Each test gets a fully isolated app+DB via create_app(database_url=...)
rather than relying on process-global state. 13/13 tests pass; also
verified live end-to-end against a running uvicorn instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jonas 2026-07-16 11:24:57 +02:00
parent 2989a0814c
commit 4c9deda66a
18 changed files with 795 additions and 1 deletions

2
.gitignore vendored
View File

@ -21,6 +21,8 @@ checkpoints/
*.pt *.pt
*.pth *.pth
*.onnx *.onnx
*.db
*.db-journal
# OS # OS
.DS_Store .DS_Store

View File

@ -0,0 +1,58 @@
# Labeling Tool — Backend
API + data model for the bounding-box labeling tool. See CLAUDE.md §3.2 for the
full requirements this is built against.
**Status:** core data model + CRUD API implemented (sets, hierarchical classes,
frames, labels, per-frame/per-set status). **Not yet implemented:** dataset
versioning/promotion (`enemy-v1`, `enemy-v2`, ...) — deferred since it needs its
own design pass (snapshot semantics: labels are editable at any time, but a
promoted dataset version must stay reproducible). Also not yet implemented: any
frontend, auth/login (see below), or the active-learning auto-label workflow.
## Stack
- **FastAPI** + **SQLAlchemy** (2.0), **SQLite** by default (`./labeling.db`),
swappable via the `DATABASE_URL` env var (e.g. to Postgres later without code
changes — one Postgres-compatible ORM).
- **Multi-user, no auth yet:** labels/status changes take a plain `created_by` /
`updated_by` username string, resolved via get-or-create (`users.py`). There's no
login flow — attribution only, since there's no UI yet that would need real auth.
## Data model
- `LabelSet` — a label set (`Enemy`, `Items`, `Traps`, ...), one per detector model.
- `MainClass` / `SubClass` — the per-set Main → Sub class hierarchy (e.g. `Enemy`
`Bat`, `Snake`), created ad hoc via the API, no migration needed to add classes.
- `Frame` — one labelable image, identified by `(session_name, frame_index)`
matches the recording tool's frame-extraction output 1:1.
- `Label` — one bounding box (`x, y, width, height` in pixel space), scoped to a
frame + set + sub-class.
- `FrameSetStatus` — per-frame, per-set label state (`unlabeled` / `auto_labeled` /
`reviewed`).
## Setup
```
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## Run
```
spelunkai-labeling-backend
# or: uvicorn spelunkai_labeling_backend.main:create_app --factory --reload
```
Interactive API docs at `http://127.0.0.1:8000/docs` once running.
## Testing
```
pytest
```
Each test gets a fully isolated app + SQLite file via `create_app(database_url=...)`
(see `tests/conftest.py`) — no shared state between tests, no real server needed.

View File

@ -3,7 +3,17 @@ name = "spelunkai-labeling-backend"
version = "0.0.0" version = "0.0.0"
description = "SpelunkAI Labeling Tool backend: API + data model for bounding-box labels" description = "SpelunkAI Labeling Tool backend: API + data model for bounding-box labels"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy>=2.0",
]
[project.optional-dependencies]
dev = ["pytest>=8", "httpx>=0.27"]
[project.scripts]
spelunkai-labeling-backend = "spelunkai_labeling_backend.cli:main"
[build-system] [build-system]
requires = ["setuptools>=68"] requires = ["setuptools>=68"]

View File

@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
main()

View File

@ -0,0 +1,14 @@
"""Convenience entry point to run the labeling backend with uvicorn."""
from __future__ import annotations
import uvicorn
from .main import create_app
def main() -> None:
uvicorn.run(create_app(), host="127.0.0.1", port=8000)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,40 @@
"""Database engine/session setup.
Each `Database` instance owns its own engine and session factory, so a
production run and a test can each get a fully isolated database instead of
sharing process-global state (see `main.create_app`).
"""
from __future__ import annotations
import os
from typing import Iterator, Optional
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from .models import Base
DEFAULT_DATABASE_URL = "sqlite:///./labeling.db"
class Database:
def __init__(self, database_url: Optional[str] = None):
self.url = database_url or os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL)
connect_args = {"check_same_thread": False} if self.url.startswith("sqlite") else {}
self.engine = create_engine(self.url, connect_args=connect_args)
self.session_factory = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
def init_models(self) -> None:
Base.metadata.create_all(bind=self.engine)
def get_session(self) -> Iterator[Session]:
session = self.session_factory()
try:
yield session
finally:
session.close()
def get_db() -> Iterator[Session]:
"""Marker dependency; `create_app` overrides this with a real `Database.get_session`."""
raise NotImplementedError("get_db must be overridden via app.dependency_overrides")

View File

@ -0,0 +1,38 @@
"""FastAPI app factory for the labeling backend.
Run with `uvicorn spelunkai_labeling_backend.main:create_app --factory` (or
`spelunkai-labeling-backend`) so the app and its database is constructed
lazily instead of at import time; this also means importing this module has
no side effects, and tests can call `create_app(database_url=...)` to get a
fully isolated instance.
"""
from __future__ import annotations
from typing import Optional
from fastapi import FastAPI
from .db import Database, get_db
from .routers.frames import router as frames_router
from .routers.labels import router as labels_router
from .routers.sets import main_classes_router, sets_router
def create_app(database_url: Optional[str] = None) -> FastAPI:
database = Database(database_url)
database.init_models()
app = FastAPI(title="SpelunkAI Labeling Backend")
app.state.database = database
app.dependency_overrides[get_db] = database.get_session
app.include_router(sets_router)
app.include_router(main_classes_router)
app.include_router(frames_router)
app.include_router(labels_router)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
return app

View File

@ -0,0 +1,121 @@
"""SQLAlchemy ORM models for the labeling data model.
Main/sub classes are data rows (not enum columns), so users can add new
classes on the fly per CLAUDE.md §3.2 without a schema migration.
"""
from __future__ import annotations
import datetime
import enum
from sqlalchemy import Column, DateTime, Enum as SqlEnum, Float, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc)
class LabelStatus(str, enum.Enum):
UNLABELED = "unlabeled"
AUTO_LABELED = "auto_labeled"
REVIEWED = "reviewed"
class LabelSource(str, enum.Enum):
MANUAL = "manual"
AUTO = "auto"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False)
created_at = Column(DateTime, default=_utcnow)
class LabelSet(Base):
__tablename__ = "label_sets"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
description = Column(String, nullable=True)
created_at = Column(DateTime, default=_utcnow)
main_classes = relationship("MainClass", back_populates="label_set", cascade="all, delete-orphan")
class MainClass(Base):
__tablename__ = "main_classes"
__table_args__ = (UniqueConstraint("set_id", "name", name="uq_main_class_set_name"),)
id = Column(Integer, primary_key=True)
set_id = Column(Integer, ForeignKey("label_sets.id"), nullable=False)
name = Column(String, nullable=False)
label_set = relationship("LabelSet", back_populates="main_classes")
sub_classes = relationship("SubClass", back_populates="main_class", cascade="all, delete-orphan")
class SubClass(Base):
__tablename__ = "sub_classes"
__table_args__ = (UniqueConstraint("main_class_id", "name", name="uq_sub_class_main_name"),)
id = Column(Integer, primary_key=True)
main_class_id = Column(Integer, ForeignKey("main_classes.id"), nullable=False)
name = Column(String, nullable=False)
main_class = relationship("MainClass", back_populates="sub_classes")
class Frame(Base):
"""A single labelable image, produced by the recording tool's frame extraction."""
__tablename__ = "frames"
__table_args__ = (UniqueConstraint("session_name", "frame_index", name="uq_frame_session_index"),)
id = Column(Integer, primary_key=True)
session_name = Column(String, nullable=False)
frame_index = Column(Integer, nullable=False)
image_path = Column(String, nullable=False)
width = Column(Integer, nullable=False)
height = Column(Integer, nullable=False)
created_at = Column(DateTime, default=_utcnow)
class FrameSetStatus(Base):
"""Per-image, per-set label state (unlabeled / auto_labeled / reviewed)."""
__tablename__ = "frame_set_status"
__table_args__ = (UniqueConstraint("frame_id", "set_id", name="uq_frame_set_status"),)
id = Column(Integer, primary_key=True)
frame_id = Column(Integer, ForeignKey("frames.id"), nullable=False)
set_id = Column(Integer, ForeignKey("label_sets.id"), nullable=False)
status = Column(SqlEnum(LabelStatus), nullable=False, default=LabelStatus.UNLABELED)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
class Label(Base):
"""A single bounding-box annotation, scoped to one frame within one label set."""
__tablename__ = "labels"
id = Column(Integer, primary_key=True)
frame_id = Column(Integer, ForeignKey("frames.id"), nullable=False)
set_id = Column(Integer, ForeignKey("label_sets.id"), nullable=False)
sub_class_id = Column(Integer, ForeignKey("sub_classes.id"), nullable=False)
x = Column(Float, nullable=False)
y = Column(Float, nullable=False)
width = Column(Float, nullable=False)
height = Column(Float, nullable=False)
source = Column(SqlEnum(LabelSource), nullable=False, default=LabelSource.MANUAL)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
sub_class = relationship("SubClass")

View File

@ -0,0 +1,50 @@
"""Frame ingestion and listing."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import models, schemas
from ..db import get_db
router = APIRouter(prefix="/frames", tags=["frames"])
@router.post("", response_model=schemas.FrameRead, status_code=201)
def create_frame(payload: schemas.FrameCreate, db: Session = Depends(get_db)):
existing = (
db.query(models.Frame)
.filter_by(session_name=payload.session_name, frame_index=payload.frame_index)
.first()
)
if existing is not None:
return existing
frame = models.Frame(**payload.model_dump())
db.add(frame)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, "frame already exists")
db.refresh(frame)
return frame
@router.get("", response_model=list[schemas.FrameRead])
def list_frames(session_name: Optional[str] = None, db: Session = Depends(get_db)):
query = db.query(models.Frame)
if session_name is not None:
query = query.filter_by(session_name=session_name)
return query.order_by(models.Frame.frame_index).all()
@router.get("/{frame_id}", response_model=schemas.FrameRead)
def get_frame(frame_id: int, db: Session = Depends(get_db)):
frame = db.get(models.Frame, frame_id)
if frame is None:
raise HTTPException(404, "frame not found")
return frame

View File

@ -0,0 +1,97 @@
"""Bounding-box labels and per-frame/per-set label status."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..db import get_db
from ..users import get_or_create_user
router = APIRouter(tags=["labels"])
def _require_frame_and_set(db: Session, frame_id: int, set_id: int) -> None:
if db.get(models.Frame, frame_id) is None:
raise HTTPException(404, "frame not found")
if db.get(models.LabelSet, set_id) is None:
raise HTTPException(404, "set not found")
@router.post("/frames/{frame_id}/sets/{set_id}/labels", response_model=schemas.LabelRead, status_code=201)
def create_label(frame_id: int, set_id: int, payload: schemas.LabelCreate, db: Session = Depends(get_db)):
_require_frame_and_set(db, frame_id, set_id)
sub_class = db.get(models.SubClass, payload.sub_class_id)
if sub_class is None or sub_class.main_class.set_id != set_id:
raise HTTPException(422, "sub_class_id does not belong to this set")
label = models.Label(
frame_id=frame_id,
set_id=set_id,
sub_class_id=payload.sub_class_id,
x=payload.x,
y=payload.y,
width=payload.width,
height=payload.height,
source=payload.source,
created_by_id=get_or_create_user(db, payload.created_by).id if payload.created_by else None,
)
db.add(label)
db.commit()
db.refresh(label)
return label
@router.get("/frames/{frame_id}/sets/{set_id}/labels", response_model=list[schemas.LabelRead])
def list_labels(frame_id: int, set_id: int, db: Session = Depends(get_db)):
_require_frame_and_set(db, frame_id, set_id)
return db.query(models.Label).filter_by(frame_id=frame_id, set_id=set_id).all()
@router.patch("/labels/{label_id}", response_model=schemas.LabelRead)
def update_label(label_id: int, payload: schemas.LabelUpdate, db: Session = Depends(get_db)):
label = db.get(models.Label, label_id)
if label is None:
raise HTTPException(404, "label not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(label, field, value)
db.commit()
db.refresh(label)
return label
@router.delete("/labels/{label_id}", status_code=204)
def delete_label(label_id: int, db: Session = Depends(get_db)):
label = db.get(models.Label, label_id)
if label is None:
raise HTTPException(404, "label not found")
db.delete(label)
db.commit()
@router.get("/frames/{frame_id}/sets/{set_id}/status", response_model=schemas.FrameSetStatusRead)
def get_status(frame_id: int, set_id: int, db: Session = Depends(get_db)):
_require_frame_and_set(db, frame_id, set_id)
status = db.query(models.FrameSetStatus).filter_by(frame_id=frame_id, set_id=set_id).first()
if status is None:
status = models.FrameSetStatus(frame_id=frame_id, set_id=set_id, status=models.LabelStatus.UNLABELED)
db.add(status)
db.commit()
db.refresh(status)
return status
@router.put("/frames/{frame_id}/sets/{set_id}/status", response_model=schemas.FrameSetStatusRead)
def set_status(frame_id: int, set_id: int, payload: schemas.FrameSetStatusUpdate, db: Session = Depends(get_db)):
_require_frame_and_set(db, frame_id, set_id)
status = db.query(models.FrameSetStatus).filter_by(frame_id=frame_id, set_id=set_id).first()
if status is None:
status = models.FrameSetStatus(frame_id=frame_id, set_id=set_id)
db.add(status)
status.status = payload.status
if payload.updated_by:
status.updated_by_id = get_or_create_user(db, payload.updated_by).id
db.commit()
db.refresh(status)
return status

View File

@ -0,0 +1,63 @@
"""Label sets and their Main -> Sub class hierarchy."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..db import get_db
sets_router = APIRouter(prefix="/sets", tags=["sets"])
main_classes_router = APIRouter(prefix="/main-classes", tags=["sets"])
@sets_router.post("", response_model=schemas.LabelSetRead, status_code=201)
def create_set(payload: schemas.LabelSetCreate, db: Session = Depends(get_db)):
if db.query(models.LabelSet).filter_by(name=payload.name).first():
raise HTTPException(409, f"set '{payload.name}' already exists")
label_set = models.LabelSet(name=payload.name, description=payload.description)
db.add(label_set)
db.commit()
db.refresh(label_set)
return label_set
@sets_router.get("", response_model=list[schemas.LabelSetRead])
def list_sets(db: Session = Depends(get_db)):
return db.query(models.LabelSet).all()
@sets_router.get("/{set_id}", response_model=schemas.LabelSetRead)
def get_set(set_id: int, db: Session = Depends(get_db)):
label_set = db.get(models.LabelSet, set_id)
if label_set is None:
raise HTTPException(404, "set not found")
return label_set
@sets_router.post("/{set_id}/main-classes", response_model=schemas.MainClassRead, status_code=201)
def create_main_class(set_id: int, payload: schemas.MainClassCreate, db: Session = Depends(get_db)):
label_set = db.get(models.LabelSet, set_id)
if label_set is None:
raise HTTPException(404, "set not found")
if db.query(models.MainClass).filter_by(set_id=set_id, name=payload.name).first():
raise HTTPException(409, f"main class '{payload.name}' already exists in this set")
main_class = models.MainClass(set_id=set_id, name=payload.name)
db.add(main_class)
db.commit()
db.refresh(main_class)
return main_class
@main_classes_router.post("/{main_class_id}/sub-classes", response_model=schemas.SubClassRead, status_code=201)
def create_sub_class(main_class_id: int, payload: schemas.SubClassCreate, db: Session = Depends(get_db)):
main_class = db.get(models.MainClass, main_class_id)
if main_class is None:
raise HTTPException(404, "main class not found")
if db.query(models.SubClass).filter_by(main_class_id=main_class_id, name=payload.name).first():
raise HTTPException(409, f"sub class '{payload.name}' already exists under this main class")
sub_class = models.SubClass(main_class_id=main_class_id, name=payload.name)
db.add(sub_class)
db.commit()
db.refresh(sub_class)
return sub_class

View File

@ -0,0 +1,109 @@
"""Pydantic request/response schemas."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, ConfigDict
from .models import LabelSource, LabelStatus
class SubClassCreate(BaseModel):
name: str
class SubClassRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
class MainClassCreate(BaseModel):
name: str
class MainClassRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
sub_classes: list[SubClassRead] = []
class LabelSetCreate(BaseModel):
name: str
description: Optional[str] = None
class LabelSetRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: Optional[str] = None
main_classes: list[MainClassRead] = []
class FrameCreate(BaseModel):
session_name: str
frame_index: int
image_path: str
width: int
height: int
class FrameRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
session_name: str
frame_index: int
image_path: str
width: int
height: int
class LabelCreate(BaseModel):
sub_class_id: int
x: float
y: float
width: float
height: float
source: LabelSource = LabelSource.MANUAL
created_by: Optional[str] = None
class LabelUpdate(BaseModel):
sub_class_id: Optional[int] = None
x: Optional[float] = None
y: Optional[float] = None
width: Optional[float] = None
height: Optional[float] = None
class LabelRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
frame_id: int
set_id: int
sub_class_id: int
x: float
y: float
width: float
height: float
source: LabelSource
class FrameSetStatusRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
frame_id: int
set_id: int
status: LabelStatus
class FrameSetStatusUpdate(BaseModel):
status: LabelStatus
updated_by: Optional[str] = None

View File

@ -0,0 +1,22 @@
"""Lightweight user resolution.
No login/auth flow yet just get-or-create by username, so labels and
status changes can be attributed to a person (CLAUDE.md §3.2 multi-user
support) without building a full auth system before there's a UI to need it.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from . import models
def get_or_create_user(db: Session, username: str) -> models.User:
user = db.query(models.User).filter_by(username=username).first()
if user is not None:
return user
user = models.User(username=username)
db.add(user)
db.commit()
db.refresh(user)
return user

View File

@ -0,0 +1,12 @@
import pytest
from fastapi.testclient import TestClient
from spelunkai_labeling_backend.main import create_app
@pytest.fixture()
def client(tmp_path):
db_path = tmp_path / "test.db"
app = create_app(database_url=f"sqlite:///{db_path}")
with TestClient(app) as test_client:
yield test_client

View File

@ -0,0 +1,32 @@
def test_create_and_list_frames(client):
payload = {
"session_name": "run01",
"frame_index": 0,
"image_path": "run01_frames/frame_000000.png",
"width": 1280,
"height": 720,
}
resp = client.post("/frames", json=payload)
assert resp.status_code == 201
frame_id = resp.json()["id"]
resp = client.post("/frames", json=payload)
assert resp.status_code == 201
assert resp.json()["id"] == frame_id
resp = client.get("/frames", params={"session_name": "run01"})
assert resp.status_code == 200
assert len(resp.json()) == 1
def test_list_frames_filters_by_session(client):
client.post("/frames", json={"session_name": "run01", "frame_index": 0, "image_path": "a.png", "width": 1, "height": 1})
client.post("/frames", json={"session_name": "run02", "frame_index": 0, "image_path": "b.png", "width": 1, "height": 1})
resp = client.get("/frames", params={"session_name": "run02"})
assert [f["image_path"] for f in resp.json()] == ["b.png"]
def test_get_missing_frame(client):
resp = client.get("/frames/999")
assert resp.status_code == 404

View File

@ -0,0 +1,75 @@
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"

View File

@ -0,0 +1,47 @@
def test_create_and_list_sets(client):
resp = client.post("/sets", json={"name": "Enemy", "description": "Enemy sprites"})
assert resp.status_code == 201
resp = client.get("/sets")
assert resp.status_code == 200
assert [s["name"] for s in resp.json()] == ["Enemy"]
def test_create_set_rejects_duplicate_name(client):
client.post("/sets", json={"name": "Enemy"})
resp = client.post("/sets", json={"name": "Enemy"})
assert resp.status_code == 409
def test_get_missing_set(client):
resp = client.get("/sets/999")
assert resp.status_code == 404
def test_create_main_and_sub_class(client):
set_id = client.post("/sets", json={"name": "Enemy"}).json()["id"]
resp = client.post(f"/sets/{set_id}/main-classes", json={"name": "Enemy"})
assert resp.status_code == 201
main_class_id = resp.json()["id"]
resp = client.post(f"/main-classes/{main_class_id}/sub-classes", json={"name": "Bat"})
assert resp.status_code == 201
assert resp.json()["name"] == "Bat"
resp = client.get(f"/sets/{set_id}")
assert resp.json()["main_classes"][0]["sub_classes"][0]["name"] == "Bat"
def test_main_class_requires_existing_set(client):
resp = client.post("/sets/999/main-classes", json={"name": "Enemy"})
assert resp.status_code == 404
def test_sub_class_rejects_duplicate_name_under_same_main_class(client):
set_id = client.post("/sets", json={"name": "Enemy"}).json()["id"]
main_class_id = client.post(f"/sets/{set_id}/main-classes", json={"name": "Enemy"}).json()["id"]
client.post(f"/main-classes/{main_class_id}/sub-classes", json={"name": "Bat"})
resp = client.post(f"/main-classes/{main_class_id}/sub-classes", json={"name": "Bat"})
assert resp.status_code == 409