# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Unit tests for the disk-backed image gallery: PNG-embedded recipe round-trips, listing order, safe id handling, and delete/clear.""" from __future__ import annotations import base64 import io import os import pytest import core.inference.image_gallery as gallery PIL = pytest.importorskip("PIL") from PIL import Image # noqa: E402 @pytest.fixture(autouse = True) def _tmp_gallery(monkeypatch, tmp_path): # Point the gallery at a throwaway root instead of ~/.unsloth/studio. monkeypatch.setattr(gallery, "studio_root", lambda: tmp_path) def _img(color = (10, 20, 30)): return Image.new("RGB", (16, 16), color) def _meta(**over): base = { "prompt": "a sloth", "negative_prompt": None, "width": 1024, "height": 1024, "steps": 9, "guidance": 0.0, "seed": 7, "model": "unsloth/Z-Image-Turbo-GGUF", "created_at": 100.0, } base.update(over) return base def test_save_embeds_recipe_and_round_trips(): record = gallery.save(_img(), _meta()) assert record["id"] and record["url"].endswith(f"{record['id']}/file") # The recipe is embedded in the PNG itself (portable), not just in a sidecar. raw = base64.b64decode(gallery.image_b64(record["id"])) with Image.open(io.BytesIO(raw)) as im: assert im.text["unsloth"] assert "Negative prompt" not in im.text["parameters"] # none given assert "Steps: 9" in im.text["parameters"] listed = gallery.list_images() assert len(listed) == 1 assert listed[0]["prompt"] == "a sloth" and listed[0]["seed"] == 7 def _save_with_mtime(prompt: str, t: float) -> dict: record = gallery.save(_img(), _meta(prompt = prompt, created_at = t)) # Listing orders by mtime; set it explicitly so a tight test loop can't tie it. os.utime(gallery.gallery_dir() / f"{record['id']}.png", (t, t)) return record def test_list_is_newest_first(): old = _save_with_mtime("old", 100.0) new = _save_with_mtime("new", 200.0) assert [r["id"] for r in gallery.list_images()] == [new["id"], old["id"]] def test_list_paginates_with_limit_offset(): # 5 images, newest (t=4) first. for i in range(5): _save_with_mtime(f"p{i}", float(i)) page1 = gallery.list_images(limit = 2, offset = 0) page2 = gallery.list_images(limit = 2, offset = 2) assert [r["prompt"] for r in page1] == ["p4", "p3"] assert [r["prompt"] for r in page2] == ["p2", "p1"] # limit=None still returns everything from the offset. assert len(gallery.list_images()) == 5 assert len(gallery.list_images(offset = 4)) == 1 def test_negative_prompt_recorded_in_parameters(): record = gallery.save(_img(), _meta(negative_prompt = "blurry")) raw = base64.b64decode(gallery.image_b64(record["id"])) with Image.open(io.BytesIO(raw)) as im: assert "Negative prompt: blurry" in im.text["parameters"] def test_delete_and_clear(): a = gallery.save(_img(), _meta(prompt = "a")) gallery.save(_img(), _meta(prompt = "b")) assert gallery.delete(a["id"]) is True assert gallery.delete(a["id"]) is False # already gone assert len(gallery.list_images()) == 1 assert gallery.clear() == 1 assert gallery.list_images() == [] def test_image_path_rejects_unsafe_ids(): # Traversal / bad chars never resolve to a path. assert gallery.image_path("../../etc/passwd") is None assert gallery.image_path("a/b") is None assert gallery.image_path("missing") is None def test_list_skips_foreign_pngs(tmp_path): # A PNG without our recipe chunk (user dropped a file) is ignored. foreign = gallery.gallery_dir() / "foreign.png" _img().save(foreign, format = "PNG") gallery.save(_img(), _meta(prompt = "ours")) listed = gallery.list_images() assert [r["prompt"] for r in listed] == ["ours"] def test_foreign_png_in_window_does_not_drop_valid_images(): # A foreign PNG sorting INTO the requested page must not consume a window slot and # drop a valid image that sorts after it: paging is over readable records, not files. _save_with_mtime("p2", 100.0) foreign = gallery.gallery_dir() / "zzz_foreign.png" _img().save(foreign, format = "PNG") # newest by mtime (set below), sorts first os.utime(foreign, (300.0, 300.0)) _save_with_mtime("p1", 200.0) # First page of 2 must still return both real images, not [p1] (foreign eating a slot). page1 = gallery.list_images(limit = 2, offset = 0) assert [r["prompt"] for r in page1] == ["p1", "p2"] def test_list_skips_recipe_missing_required_fields(tmp_path): # A PNG carrying our chunk but an incomplete/older-schema recipe (no seed etc.) # must be skipped, not crash the whole listing when the route builds GalleryImage. import json from PIL.PngImagePlugin import PngInfo info = PngInfo() info.add_text("unsloth", json.dumps({"prompt": "partial"})) # missing width/seed/... _img().save(gallery.gallery_dir() / "partial.png", format = "PNG", pnginfo = info) gallery.save(_img(), _meta(prompt = "ours")) listed = gallery.list_images() assert [r["prompt"] for r in listed] == ["ours"] def test_valid_callback_paginates_over_accepted_records(): # A record that passes _read_meta (every required key present) but fails the caller's stricter # schema check must be filtered BEFORE pagination, so offset/limit/has_more all count over the # accepted domain. Otherwise a leading bad record returns an empty/short page with more still # remaining, and the frontend (which advances by valid records) stalls at offset 0. _save_with_mtime("BAD", 300.0) # newest, sorts first _save_with_mtime("g1", 200.0) _save_with_mtime("g2", 100.0) def _valid(rec): return rec.get("prompt") != "BAD" # First page of 2 over VALID records returns both good ones -- not [g1] (bad eating a slot) # and not [] (bad filling the whole window). page = gallery.list_images(limit = 2, offset = 0, valid = _valid) assert [r["prompt"] for r in page] == ["g1", "g2"] # The has_more probe (limit + 1) sees no extra VALID record beyond the two returned. assert len(gallery.list_images(limit = 3, offset = 0, valid = _valid)) == 2 def test_valid_callback_leading_bad_record_does_not_stall_at_offset_zero(): # Reproduces the exact stall: every record in the first window is schema-invalid. Without # in-pager filtering the route returned images=[] with has_more=True at offset 0 forever. for i in range(3): _save_with_mtime(f"BAD{i}", 300.0 - i) # newest three are all invalid _save_with_mtime("good", 10.0) def _valid(rec): return not str(rec.get("prompt", "")).startswith("BAD") # limit+1 = 3: the pager must look PAST the invalid leaders and return the one good record, # so has_more (len > limit) is False and the client advances off offset 0. records = gallery.list_images(limit = 2, offset = 0, valid = _valid) assert [r["prompt"] for r in records] == ["good"] def test_save_is_atomic_no_partial_png_on_publish_failure(monkeypatch): # A crash between writing the bytes and publishing the file must leave neither a truncated # {id}.png nor a leftover temp: the listing only ever sees fully-written records. def _boom(*a, **k): raise OSError("simulated rename failure") monkeypatch.setattr(gallery.os, "replace", _boom) with pytest.raises(OSError, match = "simulated rename failure"): gallery.save(_img(), _meta()) # No final PNG surfaced, and the hidden temp was cleaned up. assert list(gallery.gallery_dir().glob("*.png")) == [] assert list(gallery.gallery_dir().iterdir()) == []