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