sprite / tests /test_api.py
Cnass's picture
Upload 4 files
bf442bf verified
Raw History Blame Contribute Delete
8.71 kB
"""
tests/test_api.py
------------------
HTTP-level tests for the endpoints that carry real logic rather than
being a one-line pass-through: bulk metadata assignment, duplicate
collapsing, and the animation frame layout.
The DB path is redirected to a throwaway file BEFORE `app` is imported,
so these never touch the Space's real data.
Run with: pytest tests/test_api.py -v
"""
import importlib
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
_TMP = tempfile.TemporaryDirectory()
import config # noqa: E402
config.DB_PATH = Path(_TMP.name) / "api-test.sqlite3"
import database as db # noqa: E402
importlib.reload(db)
import app as flask_app # noqa: E402
def reset_db():
if config.DB_PATH.exists():
config.DB_PATH.unlink()
for suffix in ("-wal", "-shm"):
extra = Path(str(config.DB_PATH) + suffix)
if extra.exists():
extra.unlink()
db.init_db()
def client():
flask_app.app.config["TESTING"] = True
return flask_app.app.test_client()
def make_sprite(sprite_id, sheet_id="sheet_a", **kwargs):
db.insert_sprite(
db.SpriteRecord(
id=sprite_id,
sheet_id=sheet_id,
source_file=f"{sheet_id}.png",
sprite_index=kwargs.pop("sprite_index", 1),
x=kwargs.pop("x", 0), y=kwargs.pop("y", 0),
width=kwargs.pop("width", 16), height=kwargs.pop("height", 16),
png_path=f"/tmp/{sprite_id}.png",
png_path_transparent=None,
sha256=sprite_id, phash="00",
quality={"overall_quality_score": kwargs.pop("quality", 80.0)},
confidence=kwargs.pop("confidence", 95.0),
confidence_reasons=[],
component_count=1,
category=kwargs.pop("category", "unknown"),
style="pixel_art",
tags=kwargs.pop("tags", []),
status=kwargs.pop("status", "pending"),
is_duplicate_of=kwargs.pop("is_duplicate_of", None),
duplicate_similarity=kwargs.pop("duplicate_similarity", None),
auto_tagged=kwargs.pop("auto_tagged", False),
)
)
# ---------------------------------------------------------------------------
# Bulk assignment
# ---------------------------------------------------------------------------
def test_bulk_assign_scoped_to_one_sheet_leaves_others_alone():
reset_db()
make_sprite("a", sheet_id="s1")
make_sprite("b", sheet_id="s1")
make_sprite("c", sheet_id="s2")
res = client().post("/api/batch/assign", json={
"scope": "sheet", "sheet_id": "s1", "category": "tree", "add_tags": ["forest"],
})
assert res.status_code == 200
assert res.get_json()["updated_count"] == 2
assert db.get_sprite("a")["category"] == "tree"
assert db.get_sprite("c")["category"] == "unknown"
def test_bulk_assign_registers_a_brand_new_category():
"""
Typing a category that doesn't exist yet must create it, otherwise the
dropdown can never gain anything the built-in list didn't ship with.
"""
reset_db()
make_sprite("a", sheet_id="s1")
client().post("/api/batch/assign", json={
"scope": "sheet", "sheet_id": "s1", "category": "Siege Weapon",
})
assert "siege_weapon" in db.list_categories("category")
assert db.get_sprite("a")["category"] == "siege_weapon"
def test_bulk_assign_rejects_an_unknown_scope():
reset_db()
res = client().post("/api/batch/assign", json={"scope": "everything", "category": "x"})
assert res.status_code == 400
def test_bulk_assign_with_nothing_to_change_is_a_no_op():
reset_db()
make_sprite("a", sheet_id="s1", tags=["keep"])
res = client().post("/api/batch/assign", json={"scope": "sheet", "sheet_id": "s1"})
assert res.status_code == 200
assert res.get_json()["updated_count"] == 0
# ---------------------------------------------------------------------------
# Duplicate collapsing
# ---------------------------------------------------------------------------
def test_dedupe_keeps_the_best_member_of_each_cluster():
reset_db()
make_sprite("low", quality=40, is_duplicate_of=None)
make_sprite("high", quality=95, is_duplicate_of="low")
make_sprite("mid", quality=70, is_duplicate_of="high")
res = client().post("/api/batch/dedupe-keep-one").get_json()
assert res["cluster_count"] == 1
assert res["kept_ids"] == ["high"]
assert set(res["rejected_ids"]) == {"low", "mid"}
assert db.get_sprite("high")["status"] == "pending"
assert db.get_sprite("low")["status"] == "rejected"
def test_dedupe_clears_the_flag_on_the_survivor():
"""
The kept copy is the only one now, so leaving it flagged would keep it
out of batch auto-accept for no reason.
"""
reset_db()
make_sprite("first", quality=90)
make_sprite("second", quality=50, is_duplicate_of="first")
client().post("/api/batch/dedupe-keep-one")
assert db.get_sprite("first")["is_duplicate_of"] is None
def test_dedupe_does_nothing_without_duplicates():
reset_db()
make_sprite("a")
make_sprite("b")
res = client().post("/api/batch/dedupe-keep-one").get_json()
assert res["cluster_count"] == 0
assert res["rejected_count"] == 0
# ---------------------------------------------------------------------------
# Animation frames
# ---------------------------------------------------------------------------
def test_animation_frames_are_aligned_to_a_shared_canvas():
"""
Every sprite is cropped tight to its own content, so playing the crops
back-to-back makes the animation wobble. Frames therefore come back
with offsets into one canvas covering all of them.
"""
reset_db()
make_sprite("f1", sheet_id="anim", sprite_index=1, x=10, y=20, width=8, height=8)
make_sprite("f2", sheet_id="anim", sprite_index=2, x=40, y=20, width=12, height=16)
data = client().get("/api/animation/frames?sheet_id=anim").get_json()
assert [f["id"] for f in data["frames"]] == ["f1", "f2"]
# Origin is the top-left of the union, so the first frame sits at 0,0.
assert (data["frames"][0]["offset_x"], data["frames"][0]["offset_y"]) == (0, 0)
assert (data["frames"][1]["offset_x"], data["frames"][1]["offset_y"]) == (30, 0)
assert data["canvas"] == {"width": 42, "height": 16}
def test_animation_frames_are_ordered_by_sprite_index():
reset_db()
make_sprite("c", sheet_id="anim", sprite_index=3)
make_sprite("a", sheet_id="anim", sprite_index=1)
make_sprite("b", sheet_id="anim", sprite_index=2)
data = client().get("/api/animation/frames?sheet_id=anim").get_json()
assert [f["sprite_index"] for f in data["frames"]] == [1, 2, 3]
def test_animation_skips_rejected_frames():
reset_db()
make_sprite("keep", sheet_id="anim", sprite_index=1)
make_sprite("gone", sheet_id="anim", sprite_index=2, status="rejected")
data = client().get("/api/animation/frames?sheet_id=anim").get_json()
assert [f["id"] for f in data["frames"]] == ["keep"]
def test_animation_requires_a_sheet_id():
reset_db()
assert client().get("/api/animation/frames").status_code == 400
def test_animation_on_an_empty_sheet_returns_no_frames():
reset_db()
data = client().get("/api/animation/frames?sheet_id=nothing").get_json()
assert data["frames"] == []
# ---------------------------------------------------------------------------
# Reports
# ---------------------------------------------------------------------------
def test_report_endpoints_respond():
reset_db()
make_sprite("a", status="accepted")
c = client()
for url in ("/api/dataset/balance", "/api/dataset/calibration", "/api/dataset/split-preview"):
assert c.get(url).status_code == 200, url
def test_split_preview_warns_when_a_partition_is_empty():
reset_db()
for i in range(5):
make_sprite(f"a{i}", sheet_id="one_sheet", sprite_index=i, status="accepted")
data = client().get("/api/dataset/split-preview").get_json()
assert data["total"] == 5
assert data["grouped_by_sheet"] is True
assert data["warnings"], "a single sheet cannot fill three partitions"
if __name__ == "__main__":
import traceback
tests = [(n, f) for n, f in list(globals().items()) if n.startswith("test_") and callable(f)]
passed, failed = 0, 0
for name, fn in tests:
try:
fn()
print(f"PASS {name}")
passed += 1
except Exception:
print(f"FAIL {name}")
traceback.print_exc()
failed += 1
print(f"\n{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)