Merge branch 'feat/331-collections-pr1' into feat/331-collections-pr2
This commit is contained in:
@@ -262,6 +262,8 @@ def create_app(root: Path) -> web.Application:
|
||||
with target.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
|
||||
@@ -63,9 +63,9 @@ test('status line shows only server-reported backups (D103)', async () => {
|
||||
|
||||
const PREVIEW = Object.freeze({
|
||||
success: true, token: 'r'.repeat(43), expires_at: '2026-10-25T12:15:00+00:00',
|
||||
summary: { prompts: 1234, positives: 900, pairs: 3, archived_pairs: 0, image_links: 56, runs: 1, saved_views: 2 },
|
||||
summary: { prompts: 1234, positives: 900, pairs: 3, archived_pairs: 0, image_links: 56, runs: 1, saved_views: 2, collections: 1 },
|
||||
schema: { migrated: true, library_state: true }, integrity: 'ok', documents: { repaired: 0, cleared: 0, images: 0 },
|
||||
live: { prompts: 1, positives: 1, pairs: 0, archived_pairs: 0, image_links: 0, runs: 0, saved_views: 0 }, size: 4096,
|
||||
live: { prompts: 1, positives: 1, pairs: 0, archived_pairs: 0, image_links: 0, runs: 0, saved_views: 0, collections: 0 }, size: 4096,
|
||||
safety_backup: { destination_label: 'prompts.db.backup_<date and time>' },
|
||||
});
|
||||
|
||||
|
||||
+13
-21
@@ -846,6 +846,14 @@ class TestSettingsReadMonitorThroughModule(AdminAPITestCase):
|
||||
self.assertEqual(data["settings"]["gallery_root_paths"], ["output"])
|
||||
|
||||
|
||||
def _wired_prompt(text):
|
||||
"""A sampler-less graph whose one encoder feeds a guider (#252)."""
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
|
||||
"2": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@@ -2186,11 +2194,7 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
|
||||
def test_ingest_outcomes(self):
|
||||
f = Path("gen.png")
|
||||
meta = {
|
||||
"prompt": json.dumps(
|
||||
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}}}
|
||||
)
|
||||
}
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a cat"))}
|
||||
self.assertIsNone(self.api._ingest_scanned_file(f, {}))
|
||||
self.assertIsNone(self.api._ingest_scanned_file(f, {"prompt": "{}"}))
|
||||
self.assertEqual(
|
||||
@@ -2202,31 +2206,19 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
|
||||
def test_ingest_link_failures_are_tolerated(self):
|
||||
f = Path("gen.png")
|
||||
meta = {
|
||||
"prompt": json.dumps(
|
||||
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a dog"}}}
|
||||
)
|
||||
}
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
|
||||
self.api.db.link_image_to_prompt = _raise
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
|
||||
def test_ingest_save_failure_counts_as_found(self):
|
||||
f = Path("gen.png")
|
||||
meta = {
|
||||
"prompt": json.dumps(
|
||||
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a bird"}}}
|
||||
)
|
||||
}
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
|
||||
self.api.db.save_prompt = lambda *a, **k: None
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
|
||||
def test_batch_counts_and_per_file_errors(self):
|
||||
meta = {
|
||||
"prompt": json.dumps(
|
||||
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a fish"}}}
|
||||
)
|
||||
}
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
|
||||
self.api.db.get_prompt_by_hash = _raise
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
@@ -2915,7 +2907,7 @@ class TestScanReadsVideos(AdminAPITestCase):
|
||||
shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed"
|
||||
)
|
||||
async def test_scan_adds_prompts_found_in_videos(self):
|
||||
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}}}
|
||||
graph = _wired_prompt("a cat")
|
||||
_write_mp4(
|
||||
self.output_dir / "clip.mp4", json.dumps({"prompt": json.dumps(graph)})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Collections (#331): the routes under /prompt_manager/collections."""
|
||||
|
||||
import json
|
||||
import ntpath
|
||||
import sqlite3
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
BASE = "/prompt_manager/collections"
|
||||
ITEMS = BASE + "/items"
|
||||
MEMBERSHIP = BASE + "/membership"
|
||||
SOURCES_CHANGED = "The sources changed. Refresh the images and try again."
|
||||
|
||||
|
||||
class CollectionRoutesTestCase(ImageAPITestCase):
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
def make(self, *names, root=None):
|
||||
for name in names:
|
||||
make_png((root or self.output_dir) / name)
|
||||
|
||||
def scope(self, index=0):
|
||||
roots = list(self.api._get_all_output_dirs())
|
||||
return self.api._gallery_root_scope(roots[index], index)
|
||||
|
||||
def item(self, path, index=0, scope=None):
|
||||
return {
|
||||
"root_index": index,
|
||||
"root_scope": scope or self.scope(index),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
def two_sources(self):
|
||||
second = self.comfy_dir / "second"
|
||||
second.mkdir()
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
return second
|
||||
|
||||
async def call(self, method, url, body=None, status=200, **params):
|
||||
response = await self.client.request(method, url, json=body, params=params)
|
||||
text = await response.text()
|
||||
self.assertEqual(response.status, status, text)
|
||||
data = json.loads(text)
|
||||
self.assertNotIn(self.comfy_dir.as_posix(), text)
|
||||
self.assertNotIn(str(self.comfy_dir).replace("\\", "\\\\"), text)
|
||||
return data
|
||||
|
||||
async def add(self, status=200, **body):
|
||||
return await self.call("POST", ITEMS, body, status)
|
||||
|
||||
async def listed(self):
|
||||
return (await self.call("GET", BASE))["collections"]
|
||||
|
||||
|
||||
class TestCollectionRoutes(CollectionRoutesTestCase):
|
||||
async def test_each_route_succeeds(self):
|
||||
self.make("a.png", "b.png", "c.png")
|
||||
self.assertEqual(
|
||||
await self.call("GET", BASE), {"success": True, "collections": []}
|
||||
)
|
||||
made = await self.add(
|
||||
name=" LoRa testing ", items=[self.item("a.png"), self.item("b.png")]
|
||||
)
|
||||
grids = made["collection"]
|
||||
self.assertEqual(grids["name"], "LoRa testing")
|
||||
self.assertEqual(
|
||||
made,
|
||||
{
|
||||
"success": True,
|
||||
"collection": grids,
|
||||
"created": True,
|
||||
"added": 2,
|
||||
"already": 0,
|
||||
"not_found": 0,
|
||||
},
|
||||
)
|
||||
again = await self.add(
|
||||
collection_id=grids["id"], items=[self.item("b.png"), self.item("c.png")]
|
||||
)
|
||||
self.assertEqual(
|
||||
(again["created"], again["added"], again["already"]), (False, 1, 1)
|
||||
)
|
||||
self.assertEqual(
|
||||
await self.listed(),
|
||||
[{"id": grids["id"], "name": "LoRa testing", "count": 3, "missing": 0}],
|
||||
)
|
||||
membership = await self.call(
|
||||
"GET", MEMBERSHIP, root_index="0", root_scope=self.scope(), path="a.png"
|
||||
)
|
||||
self.assertEqual(membership, {"success": True, "collection_ids": [grids["id"]]})
|
||||
removed = await self.call(
|
||||
"DELETE",
|
||||
ITEMS,
|
||||
{"collection_id": grids["id"], "items": [self.item("a.png")]},
|
||||
)
|
||||
self.assertEqual(removed, {"success": True, "collection": grids, "removed": 1})
|
||||
renamed = await self.call(
|
||||
"PATCH", f"{BASE}/{grids['id']}", {"name": "LoRA grids"}
|
||||
)
|
||||
self.assertEqual(
|
||||
renamed,
|
||||
{"success": True, "collection": {"id": grids["id"], "name": "LoRA grids"}},
|
||||
)
|
||||
deleted = await self.call("DELETE", f"{BASE}/{grids['id']}")
|
||||
self.assertEqual(deleted, {"success": True, "deleted": True})
|
||||
self.assertEqual(await self.listed(), [])
|
||||
# Files are never touched.
|
||||
self.assertTrue((self.output_dir / "a.png").is_file())
|
||||
|
||||
async def test_names_are_unique_ignoring_case(self):
|
||||
self.make("a.png", "b.png")
|
||||
first = (await self.add(name="LoRa testing", items=[self.item("a.png")]))[
|
||||
"collection"
|
||||
]
|
||||
second = await self.add(name="lora TESTING", items=[self.item("b.png")])
|
||||
self.assertEqual(second["collection"], first)
|
||||
self.assertFalse(second["created"])
|
||||
self.assertEqual(len(await self.listed()), 1)
|
||||
|
||||
async def test_bad_requests_are_400(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
good = [self.item("a.png")]
|
||||
for body in (
|
||||
{"items": good},
|
||||
{"name": "x", "collection_id": cid, "items": good},
|
||||
{"name": "x"},
|
||||
{"name": "x", "items": good, "extra": 1},
|
||||
{"collection_id": "1", "items": good},
|
||||
{"collection_id": True, "items": good},
|
||||
{"collection_id": 0, "items": good},
|
||||
{"collection_id": 10**10, "items": good},
|
||||
{"name": "", "items": good},
|
||||
{"name": "x" * 101, "items": good},
|
||||
{"name": "bell\x07", "items": good},
|
||||
{"name": 5, "items": good},
|
||||
{"name": "x", "items": []},
|
||||
{"name": "x", "items": "a.png"},
|
||||
{"name": "x", "items": [self.item("a.png")] * 501},
|
||||
{"name": "x", "items": [{"root_index": 0, "path": "a.png"}]},
|
||||
{"name": "x", "items": [{**self.item("a.png"), "root_index": "0"}]},
|
||||
{"name": "x", "items": [{**self.item("a.png"), "root_scope": "xyz"}]},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
data = await self.add(400, **body)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ({"items": good}, {"collection_id": cid}):
|
||||
with self.subTest(remove=body):
|
||||
await self.call("DELETE", ITEMS, body, 400)
|
||||
for raw in ("abc", "0", "1234567890", "-1", "1.0"):
|
||||
with self.subTest(path_id=raw):
|
||||
await self.call("PATCH", f"{BASE}/{raw}", {"name": "x"}, 400)
|
||||
await self.call("DELETE", f"{BASE}/{raw}", None, 400)
|
||||
await self.call("PATCH", f"{BASE}/{cid}", {"name": ""}, 400)
|
||||
await self.call("PATCH", f"{BASE}/{cid}", {"label": "x"}, 400)
|
||||
for params in (
|
||||
{"root_index": "x", "root_scope": self.scope(), "path": "a.png"},
|
||||
{"root_index": "0", "path": "a.png"},
|
||||
{"root_index": "0", "root_scope": "nope", "path": "a.png"},
|
||||
):
|
||||
with self.subTest(membership=params):
|
||||
await self.call("GET", MEMBERSHIP, status=400, **params)
|
||||
self.assertEqual((await self.listed())[0]["count"], 1)
|
||||
|
||||
async def test_unsafe_paths_are_403(self):
|
||||
self.make("a.png")
|
||||
for path in (
|
||||
"../a.png",
|
||||
"sub/../../a.png",
|
||||
"C:/a.png",
|
||||
"a\\b.png",
|
||||
"/a.png",
|
||||
"notes.txt",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
await self.add(403, name="Set", items=[self.item(path)])
|
||||
await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
status=403,
|
||||
root_index="0",
|
||||
root_scope=self.scope(),
|
||||
path=path,
|
||||
)
|
||||
self.assertEqual(await self.listed(), [])
|
||||
|
||||
async def test_a_body_over_the_cap_is_413(self):
|
||||
response = await self.client.post(
|
||||
ITEMS, data=b'{"name":"x","items":[' + b" " * (1024 * 1024 + 1) + b"]}"
|
||||
)
|
||||
self.assertEqual(response.status, 413)
|
||||
|
||||
async def test_missing_collection_and_files_are_404(self):
|
||||
self.make("a.png")
|
||||
data = await self.add(404, collection_id=999, items=[self.item("a.png")])
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.call(
|
||||
"DELETE", ITEMS, {"collection_id": 999, "items": [self.item("a.png")]}, 404
|
||||
)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.call("PATCH", f"{BASE}/999", {"name": "x"}, 404)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.call("DELETE", f"{BASE}/999", None, 404)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.add(404, name="New", items=[self.item("never.png")])
|
||||
self.assertEqual(data["code"], "files-not-found")
|
||||
self.assertEqual(await self.listed(), [])
|
||||
|
||||
async def test_unlisted_files_count_as_not_found(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("a.png"), self.item("later.png")]
|
||||
)
|
||||
self.assertEqual((made["added"], made["not_found"]), (1, 1))
|
||||
again = await self.add(
|
||||
collection_id=made["collection"]["id"],
|
||||
items=[self.item("a.png"), self.item("later.png")],
|
||||
)
|
||||
self.assertEqual(
|
||||
(again["added"], again["already"], again["not_found"]), (0, 1, 1)
|
||||
)
|
||||
|
||||
async def test_name_taken_is_409(self):
|
||||
self.make("a.png")
|
||||
first = (await self.add(name="First", items=[self.item("a.png")]))["collection"]
|
||||
second = (await self.add(name="Second", items=[self.item("a.png")]))[
|
||||
"collection"
|
||||
]
|
||||
data = await self.call(
|
||||
"PATCH", f"{BASE}/{second['id']}", {"name": "FIRST"}, 409
|
||||
)
|
||||
self.assertEqual(
|
||||
(data["code"], data["collection_id"]), ("name-taken", first["id"])
|
||||
)
|
||||
renamed = await self.call("PATCH", f"{BASE}/{second['id']}", {"name": "SECOND"})
|
||||
self.assertEqual(renamed["collection"]["name"], "SECOND")
|
||||
|
||||
async def test_stale_scope_is_409_after_reordering_sources(self):
|
||||
second = self.two_sources()
|
||||
self.make("a.png")
|
||||
self.make("b.png", root=second)
|
||||
stale = self.item("b.png", 1)
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
for method, body in (
|
||||
("POST", {"collection_id": cid, "items": [self.item("b.png", 0), stale]}),
|
||||
("DELETE", {"collection_id": cid, "items": [stale]}),
|
||||
):
|
||||
with self.subTest(method=method):
|
||||
data = await self.call(method, ITEMS, body, 409)
|
||||
self.assertEqual(data["code"], "source-changed")
|
||||
self.assertEqual(data["error"], SOURCES_CHANGED)
|
||||
data = await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
status=409,
|
||||
root_index="1",
|
||||
root_scope=stale["root_scope"],
|
||||
path="b.png",
|
||||
)
|
||||
self.assertEqual(data["code"], "source-changed")
|
||||
# Nothing was written by the refused add; the member follows its folder.
|
||||
listed = await self.listed()
|
||||
self.assertEqual((listed[0]["count"], listed[0]["missing"]), (1, 0))
|
||||
membership = await self.call(
|
||||
"GET", MEMBERSHIP, root_index="1", root_scope=self.scope(1), path="a.png"
|
||||
)
|
||||
self.assertEqual(membership["collection_ids"], [cid])
|
||||
|
||||
async def test_counts_exclude_a_missing_member(self):
|
||||
second = self.two_sources()
|
||||
self.make("a.png", "b.png")
|
||||
self.make("c.png", root=second)
|
||||
made = await self.add(
|
||||
name="Grids",
|
||||
items=[self.item("a.png"), self.item("b.png"), self.item("c.png", 1)],
|
||||
)
|
||||
other = await self.add(name="Other", items=[self.item("c.png", 1)])
|
||||
(self.output_dir / "b.png").unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
listed = await self.listed()
|
||||
self.assertEqual(
|
||||
listed,
|
||||
[
|
||||
{
|
||||
"id": made["collection"]["id"],
|
||||
"name": "Grids",
|
||||
"count": 2,
|
||||
"missing": 1,
|
||||
},
|
||||
{
|
||||
"id": other["collection"]["id"],
|
||||
"name": "Other",
|
||||
"count": 1,
|
||||
"missing": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
# The member is removable although it is no longer listed.
|
||||
removed = await self.call(
|
||||
"DELETE",
|
||||
ITEMS,
|
||||
{"collection_id": made["collection"]["id"], "items": [self.item("b.png")]},
|
||||
)
|
||||
self.assertEqual(removed["removed"], 1)
|
||||
|
||||
async def test_counts_are_null_when_a_source_scan_fails(self):
|
||||
self.make("a.png")
|
||||
await self.add(name="Set", items=[self.item("a.png")])
|
||||
|
||||
async def broken(_root):
|
||||
raise OSError("scan failed at /secret/place")
|
||||
|
||||
with patch.object(self.api, "_get_gallery_files", side_effect=broken):
|
||||
data = await self.call("GET", BASE)
|
||||
self.assertEqual(
|
||||
data["collections"][0] | {"id": 0},
|
||||
{"id": 0, "name": "Set", "count": None, "missing": None},
|
||||
)
|
||||
self.assertNotIn("secret", json.dumps(data))
|
||||
|
||||
async def test_windows_case_spellings_are_one_member(self):
|
||||
self.make("Sub/A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("Sub/A.png"), self.item("SUB/a.PNG")]
|
||||
)
|
||||
self.assertEqual((made["added"], made["already"]), (1, 0))
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
listed = await self.listed()
|
||||
self.assertEqual((listed[0]["count"], listed[0]["missing"]), (1, 0))
|
||||
membership = await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
root_index="0",
|
||||
root_scope=self.scope(),
|
||||
path="sub/a.png",
|
||||
)
|
||||
self.assertEqual(membership["collection_ids"], [made["collection"]["id"]])
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output",
|
||||
params={
|
||||
"mode": "workspace",
|
||||
"collection": str(made["collection"]["id"]),
|
||||
},
|
||||
)
|
||||
page = await response.json()
|
||||
self.assertEqual([i["path"] for i in page["images"]], ["sub/a.png"])
|
||||
|
||||
async def test_posix_case_distinct_files_stay_two_members(self):
|
||||
self.make("A.png", "a.png")
|
||||
if len(list(self.output_dir.iterdir())) != 2:
|
||||
self.skipTest("case-insensitive filesystem")
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("A.png"), self.item("a.png")]
|
||||
)
|
||||
self.assertEqual(made["added"], 2)
|
||||
|
||||
async def test_a_500_never_carries_the_exception_text(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("disk I/O error at /secret/prompts.db")
|
||||
|
||||
cases = (
|
||||
(
|
||||
"list_collections",
|
||||
"GET",
|
||||
BASE,
|
||||
None,
|
||||
{},
|
||||
"Collections could not be read.",
|
||||
),
|
||||
(
|
||||
"file_collections",
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
None,
|
||||
{"root_index": "0", "root_scope": self.scope(), "path": "a.png"},
|
||||
"Collections could not be read.",
|
||||
),
|
||||
(
|
||||
"add_to_collection",
|
||||
"POST",
|
||||
ITEMS,
|
||||
{"collection_id": cid, "items": [self.item("a.png")]},
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
(
|
||||
"remove_from_collection",
|
||||
"DELETE",
|
||||
ITEMS,
|
||||
{"collection_id": cid, "items": [self.item("a.png")]},
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
(
|
||||
"rename_collection",
|
||||
"PATCH",
|
||||
f"{BASE}/{cid}",
|
||||
{"name": "x"},
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
(
|
||||
"delete_collection",
|
||||
"DELETE",
|
||||
f"{BASE}/{cid}",
|
||||
None,
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
)
|
||||
for method_name, method, url, body, params, message in cases:
|
||||
with self.subTest(method=method_name):
|
||||
with patch.object(self.api.db, method_name, side_effect=broken):
|
||||
data = await self.call(method, url, body, 500, **params)
|
||||
self.assertEqual(data, {"success": False, "error": message})
|
||||
|
||||
|
||||
class TestCollectionListingFilter(CollectionRoutesTestCase):
|
||||
"""#392's "all N matching" filter carries ``collection`` like the listing."""
|
||||
|
||||
async def test_a_filter_with_a_collection_resolves_only_members(self):
|
||||
second = self.two_sources()
|
||||
self.make("a.png", "b.png")
|
||||
self.make("c.png", root=second)
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("a.png"), self.item("c.png", 1)]
|
||||
)
|
||||
cid = str(made["collection"]["id"])
|
||||
selection = await self.api.resolve_listing_filter({"collection": cid})
|
||||
self.assertEqual(
|
||||
sorted((index, path) for index, _scope, path in selection.targets),
|
||||
[(0, "a.png"), (1, "c.png")],
|
||||
)
|
||||
selection = await self.api.resolve_listing_filter(
|
||||
{"collection": cid, "root": "1", "root_scope": self.scope(1)}
|
||||
)
|
||||
self.assertEqual([t[2] for t in selection.targets], ["c.png"])
|
||||
for bad in ("0", "x", "1234567890"):
|
||||
with self.subTest(bad=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
from py.api.images import parse_listing_filter
|
||||
|
||||
parse_listing_filter({"collection": bad})
|
||||
|
||||
async def test_bulk_category_acts_on_collection_members_only(self):
|
||||
self.make("a.png", "b.png")
|
||||
inside = self._save_prompt("Inside", category="Old")
|
||||
outside = self._save_prompt("Outside", category="Old")
|
||||
self.api.db.link_image_to_prompt(inside, str(self.output_dir / "a.png"))
|
||||
self.api.db.link_image_to_prompt(outside, str(self.output_dir / "b.png"))
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = str(made["collection"]["id"])
|
||||
data = await self.call(
|
||||
"POST",
|
||||
"/prompt_manager/bulk/category",
|
||||
{"images_filter": {"collection": cid}, "category": "New"},
|
||||
)
|
||||
self.assertEqual((data["updated_count"], data["prompt_count"]), (1, 1))
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(inside)["category"], "New")
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(outside)["category"], "Old")
|
||||
|
||||
async def test_a_deleted_collection_in_a_bulk_filter_is_a_404(self):
|
||||
self.make("a.png")
|
||||
data = await self.call(
|
||||
"POST",
|
||||
"/prompt_manager/bulk/category",
|
||||
{"images_filter": {"collection": "77"}, "category": "New"},
|
||||
status=404,
|
||||
)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
with self.assertRaises(Exception) as caught:
|
||||
await self.api.resolve_listing_filter({"collection": "77"})
|
||||
self.assertEqual(type(caught.exception).__name__, "CollectionMissing")
|
||||
@@ -835,7 +835,6 @@ class TestMetadataParsing(unittest.TestCase):
|
||||
self.assertEqual(parsed["positive_prompt"], "a cat")
|
||||
self.assertEqual(parsed["negative_prompt"], "dog")
|
||||
self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"])
|
||||
self.assertEqual(self.api._extract_readable_prompt(parsed), "a cat")
|
||||
|
||||
def test_parse_comfyui_fields(self):
|
||||
meta = {
|
||||
@@ -856,97 +855,6 @@ class TestMetadataParsing(unittest.TestCase):
|
||||
self.assertEqual(parsed["prompt"], "plain text")
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
|
||||
def test_extract_readable_prompt_variants(self):
|
||||
extract = self.api._extract_readable_prompt
|
||||
self.assertEqual(extract({"prompt": "plain"}), "plain")
|
||||
self.assertEqual(extract({"prompt": ["a", "", "b"]}), "a b")
|
||||
self.assertEqual(extract({"prompt": 42}), "42")
|
||||
self.assertEqual(
|
||||
extract({"prompt": None, "parameters": {"positive": ["x", "y"]}}), "x y"
|
||||
)
|
||||
self.assertIsNone(extract({"prompt": {}, "parameters": {}}))
|
||||
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
self.assertEqual(extract({"prompt": graph}), "pos")
|
||||
self.assertEqual(extract({"prompt": {"x": 1}, "workflow": graph}), "pos")
|
||||
|
||||
def test_node_inputs_and_text_lookup(self):
|
||||
self.assertEqual(self.api._get_node_inputs("nope"), {})
|
||||
self.assertEqual(self.api._get_node_inputs({"inputs": 5}), {})
|
||||
self.assertEqual(self.api._get_node_inputs({"inputs": {"a": 1}}), {"a": 1})
|
||||
listed = self.api._get_node_inputs(
|
||||
{"inputs": [{"name": "text", "link": None}, {"nope": 1}, "junk"]}
|
||||
)
|
||||
self.assertEqual(list(listed), ["text"])
|
||||
|
||||
self.assertIsNone(self.api._find_text_in_node(None))
|
||||
self.assertEqual(self.api._find_text_in_node({"inputs": {"text": "hi"}}), "hi")
|
||||
self.assertEqual(
|
||||
self.api._find_text_in_node(
|
||||
{"type": "CLIPTextEncode", "widgets_values": ["w"]}
|
||||
),
|
||||
"w",
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.api._find_text_in_node(
|
||||
{"type": "CLIPTextEncode", "widgets_values": [" "]}
|
||||
)
|
||||
)
|
||||
self.assertIsNone(self.api._find_text_in_node({"type": "KSampler"}))
|
||||
|
||||
def test_positive_prompt_from_nodes_array_and_fallbacks(self):
|
||||
extract = self.api._extract_positive_prompt_from_comfyui_data
|
||||
self.assertIsNone(extract("x"))
|
||||
self.assertIsNone(extract({}))
|
||||
self.assertIsNone(extract({"nodes": []}))
|
||||
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{"id": 1, "type": "CLIPTextEncode", "widgets_values": ["pos"]},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"widgets_values": ["neg"],
|
||||
"title": "Negative",
|
||||
},
|
||||
{"id": 3, "type": "KSampler", "inputs": [{"name": "seed", "link": 4}]},
|
||||
"junk",
|
||||
]
|
||||
}
|
||||
self.assertEqual(extract(workflow), "pos")
|
||||
|
||||
only_negative = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"widgets_values": ["neg"],
|
||||
"title": "neg",
|
||||
},
|
||||
]
|
||||
}
|
||||
self.assertEqual(extract(only_negative), "neg")
|
||||
|
||||
bad_link = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": "oops", "negative": []},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["zz", 0], "negative": []},
|
||||
},
|
||||
}
|
||||
self.assertEqual(extract(bad_link), "pos")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -687,6 +687,39 @@ class TestCleanupJob(DuplicateFilesTestCase):
|
||||
"Moved 2 database links to the kept copies", "\n".join(cleanup.log)
|
||||
)
|
||||
|
||||
async def test_duplicate_removal_moves_collection_memberships(self):
|
||||
self.trio()
|
||||
db = self.api.db
|
||||
root_key = duplicate_files.favorite_root_key(self.output_dir)
|
||||
grids = db.add_to_collection(name="LoRA grids", items=[(root_key, "b.png")])
|
||||
grids = grids["collection"]["id"]
|
||||
other = db.add_to_collection(
|
||||
name="Other", items=[(root_key, "sub/c.png"), (root_key, "d.png")]
|
||||
)["collection"]["id"]
|
||||
db.add_to_collection(collection_id=grids, items=[(root_key, "sub/c.png")])
|
||||
_plan, cleanup = await self.remove_all_but("a.png")
|
||||
# The union: a.png joins both; c.png's second grids row was a repeat.
|
||||
self.assertEqual(db.collection_members(grids, root_key), {"a.png"})
|
||||
self.assertEqual(db.collection_members(other, root_key), {"a.png", "d.png"})
|
||||
self.assertEqual(sorted(db.file_collections(root_key, "a.png")), [grids, other])
|
||||
self.assertEqual(db.file_collections(root_key, "b.png"), [])
|
||||
self.assertEqual(db.file_collections(root_key, "sub/c.png"), [])
|
||||
self.assertIn(
|
||||
"Moved 2 collection memberships to the kept copies", "\n".join(cleanup.log)
|
||||
)
|
||||
|
||||
def test_one_collection_membership_is_singular(self):
|
||||
job = type(
|
||||
"Job", (), {"log": [], "log_line": lambda s, line: s.log.append(line)}
|
||||
)()
|
||||
totals = dict.fromkeys(
|
||||
("moved", "merged", "favorites", "tags", "review", "collections"), 0
|
||||
)
|
||||
duplicate_files.DuplicateFilesMixin._log_moved_records(
|
||||
job, {**totals, "collections": 1}
|
||||
)
|
||||
self.assertEqual(job.log, ["Moved 1 collection membership to the kept copies"])
|
||||
|
||||
async def test_a_failed_removal_leaves_its_links_on_the_kept_copy(self):
|
||||
self.trio()
|
||||
prompt = self.save("Stays on c")
|
||||
@@ -843,8 +876,11 @@ class TestLinkQueries(DuplicateFilesTestCase):
|
||||
with self.subTest(sql=sql):
|
||||
self.assertNotIn("SCAN generated_images", plan)
|
||||
self.assertNotIn("SCAN image_favorites", plan)
|
||||
self.assertNotIn("SCAN image_collection_items", plan)
|
||||
finally:
|
||||
conn.close()
|
||||
statements = duplicate_files.query_plan_sql()
|
||||
self.assertTrue(any("image_collection_items" in sql for sql in statements))
|
||||
|
||||
|
||||
class TestReviewHelpers(DuplicateFilesTestCase):
|
||||
|
||||
@@ -2494,3 +2494,171 @@ class TestListingFilterSelection(ImageAPITestCase):
|
||||
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertEqual((await response.json())["code"], "source-changed")
|
||||
|
||||
|
||||
class TestWorkspaceCollectionFilter(ImageAPITestCase):
|
||||
"""#331: ``collection=<id>`` keeps only that collection's listed members."""
|
||||
|
||||
media = TestWorkspaceImages.media
|
||||
get = TestWorkspaceImages.get
|
||||
_names = TestWorkspaceImages._names
|
||||
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
def keys(self, root, relative):
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
return (_favorite_root_key(root), _favorite_relative_key(relative))
|
||||
|
||||
def collect(self, name, *members):
|
||||
"""A collection holding ``(root, relative)`` members; its id."""
|
||||
result = self.api.db.add_to_collection(
|
||||
name=name, items=[self.keys(root, rel) for root, rel in members]
|
||||
)
|
||||
return result["collection"]["id"]
|
||||
|
||||
def two_sources(self):
|
||||
second = self.comfy_dir / "second"
|
||||
second.mkdir()
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
return second
|
||||
|
||||
async def test_listing_keeps_only_collection_members(self):
|
||||
second = self.two_sources()
|
||||
self.media("a.png", 300)
|
||||
self.media("b.png", 200)
|
||||
self.media("sub/c.png", 100, root=second)
|
||||
self.media("d.png", 50, root=second)
|
||||
grids = self.collect(
|
||||
"Grids",
|
||||
(self.output_dir, "a.png"),
|
||||
(second, "sub/c.png"),
|
||||
(self.output_dir, "gone.png"), # stored, never listed
|
||||
)
|
||||
self.collect("Other", (self.output_dir, "b.png"))
|
||||
response, data = await self.get(collection=str(grids))
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(self._names(data), ["a.png", "c.png"])
|
||||
self.assertEqual(data["total"], 2)
|
||||
_, data = await self.get(collection=str(grids), root="1")
|
||||
self.assertEqual(self._names(data), ["c.png"])
|
||||
# Without the filter the listing is unchanged.
|
||||
_, data = await self.get()
|
||||
self.assertEqual(data["total"], 4)
|
||||
|
||||
async def test_collection_filter_combines_with_the_other_filters(self):
|
||||
night = self._save_prompt("Neon zebra", category="Night", tags=["a"])
|
||||
day = self._save_prompt("Morning field", category="Day", tags=["a"])
|
||||
files = {
|
||||
name: self.media(name, 100 + index)
|
||||
for index, name in enumerate(
|
||||
("night.png", "day.png", "zebra_plain.png", "clip.mp4", "out.png")
|
||||
)
|
||||
}
|
||||
self.api.db.link_image_to_prompt(night, str(files["night.png"]))
|
||||
self.api.db.link_image_to_prompt(day, str(files["day.png"]))
|
||||
members = [(self.output_dir, name) for name in files if name != "out.png"]
|
||||
grids = str(self.collect("Grids", *members))
|
||||
cases = (
|
||||
({}, ["clip.mp4", "day.png", "night.png", "zebra_plain.png"]),
|
||||
({"q": "zebra"}, ["night.png", "zebra_plain.png"]),
|
||||
({"category": "Night"}, ["night.png"]),
|
||||
({"tags": "a"}, ["day.png", "night.png"]),
|
||||
({"media": "video"}, ["clip.mp4"]),
|
||||
({"media": "image", "q": "day"}, ["day.png"]),
|
||||
)
|
||||
for params, expected in cases:
|
||||
with self.subTest(**params):
|
||||
response, data = await self.get(collection=grids, sort="name", **params)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(self._names(data), expected)
|
||||
self.assertEqual(data["total"], len(expected))
|
||||
|
||||
async def test_invalid_collection_is_a_400(self):
|
||||
for value in ("x", "0", "-1", "1.5", "1234567890", " 1"):
|
||||
with self.subTest(value=value):
|
||||
response, data = await self.get(collection=value)
|
||||
self.assertEqual(response.status, 400, data)
|
||||
|
||||
async def test_unknown_collection_is_a_404(self):
|
||||
self.media("a.png")
|
||||
response, data = await self.get(collection="77")
|
||||
self.assertEqual(response.status, 404)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
self.assertNotIn("images", data)
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
response, data = await self.get(collection="77")
|
||||
self.assertEqual((response.status, data["code"]), (404, "collection-not-found"))
|
||||
|
||||
async def test_a_collection_deleted_during_the_read_is_a_404(self):
|
||||
self.media("a.png")
|
||||
grids = self.collect("Grids", (self.output_dir, "a.png"))
|
||||
real = self.api.db.collection_members
|
||||
|
||||
def deleted_meanwhile(collection_id, root_key):
|
||||
self.api.db.delete_collection(collection_id)
|
||||
return real(collection_id, root_key)
|
||||
|
||||
with patch.object(
|
||||
self.api.db, "collection_members", side_effect=deleted_meanwhile
|
||||
):
|
||||
response, data = await self.get(collection=str(grids))
|
||||
self.assertEqual((response.status, data["code"]), (404, "collection-not-found"))
|
||||
|
||||
async def test_a_failed_members_read_is_a_500_never_an_unfiltered_page(self):
|
||||
self.media("a.png")
|
||||
self.media("b.png")
|
||||
grids = self.collect("Grids", (self.output_dir, "a.png"))
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
with patch.object(self.api.db, "collection_members", side_effect=broken):
|
||||
response, data = await self.get(collection=str(grids))
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertNotIn("images", data)
|
||||
self.assertNotIn("locked", json.dumps(data))
|
||||
|
||||
async def test_reordering_sources_keeps_members(self):
|
||||
second = self.two_sources()
|
||||
self.media("a.png", root=second)
|
||||
self.media("b.png")
|
||||
grids = str(self.collect("Grids", (second, "a.png")))
|
||||
_, data = await self.get(collection=grids)
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
|
||||
)
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
_, data = await self.get(collection=grids)
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(0, "a.png")]
|
||||
)
|
||||
|
||||
async def test_cursor_from_another_collection_is_refused(self):
|
||||
for index, name in enumerate(("a.png", "b.png", "c.png")):
|
||||
self.media(name, 100 + index)
|
||||
everything = [(self.output_dir, name) for name in ("a.png", "b.png", "c.png")]
|
||||
first = str(self.collect("First", *everything))
|
||||
second = str(self.collect("Second", *everything))
|
||||
_, page = await self.get(collection=first, limit=1)
|
||||
token = page["next_cursor"]
|
||||
self.assertIsInstance(token, str)
|
||||
response, _ = await self.get(collection=first, limit=1, cursor=token)
|
||||
self.assertEqual(response.status, 200)
|
||||
for params in ({"collection": second}, {}, {"favorites": "1"}):
|
||||
with self.subTest(**params):
|
||||
response, _ = await self.get(cursor=token, limit=1, **params)
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_collection_wins_over_favorites(self):
|
||||
self.media("a.png")
|
||||
self.media("b.png")
|
||||
grids = str(self.collect("Grids", (self.output_dir, "a.png")))
|
||||
from py.api.images import _favorite_root_key
|
||||
|
||||
self.api.db.set_image_favorite(_favorite_root_key(self.output_dir), "b.png")
|
||||
_, data = await self.get(collection=grids, favorites="1")
|
||||
self.assertEqual(self._names(data), ["a.png"])
|
||||
|
||||
@@ -614,6 +614,50 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
)
|
||||
assert_closed(self, str(backups[0]))
|
||||
|
||||
def test_backup_and_restore_keep_collections(self):
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO image_collections (id, name, name_key, created_at) "
|
||||
"VALUES (4, 'LoRa testing', 'lora testing', '2026-10-27T00:00:00Z')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO image_collection_items VALUES "
|
||||
"(4, 'root', 'grid.png', '2026-10-27T00:00:00Z')"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
# The rows still sit in the WAL; a backup must carry them.
|
||||
backup = os.path.join(directory, "backup.db")
|
||||
self.assertTrue(self.db.model.backup_database(backup))
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(backup, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM image_collections")
|
||||
|
||||
self.db.model.restore_from_file(backup)
|
||||
|
||||
conn = self.db.model.get_connection()
|
||||
self.assertEqual(
|
||||
[
|
||||
tuple(r)
|
||||
for r in conn.execute("SELECT id, name FROM image_collections")
|
||||
],
|
||||
[(4, "LoRa testing")],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT collection_id, root_key, relative_path "
|
||||
"FROM image_collection_items"
|
||||
)
|
||||
],
|
||||
[(4, "root", "grid.png")],
|
||||
)
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, directory)
|
||||
|
||||
def test_restore_without_a_live_database_returns_empty_backup_path(self):
|
||||
src = self._make_source_db(["fresh"])
|
||||
self.db.close_all()
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Collections (#331): the database operations."""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database.image_collections import (
|
||||
CollectionFilesMissing,
|
||||
CollectionInvalid,
|
||||
CollectionMissing,
|
||||
CollectionNameTaken,
|
||||
READ_SQL,
|
||||
)
|
||||
except ImportError:
|
||||
CollectionFilesMissing = CollectionMissing = CollectionNameTaken = None
|
||||
CollectionInvalid = ValueError
|
||||
READ_SQL = None
|
||||
|
||||
ROOT = "/real/output"
|
||||
OTHER = "/real/other"
|
||||
|
||||
|
||||
class ImageCollectionOperationsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "prompts.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def require(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.db, "add_to_collection"),
|
||||
"ImageCollectionOperations is not implemented",
|
||||
)
|
||||
|
||||
def add(self, items, **target):
|
||||
return self.db.add_to_collection(items=items, **target)
|
||||
|
||||
def test_add_creates_a_collection_by_name(self):
|
||||
self.require()
|
||||
result = self.add(
|
||||
[(ROOT, "a.png"), (ROOT, "b.png"), (OTHER, "a.png")], name="LoRa testing"
|
||||
)
|
||||
collection = result["collection"]
|
||||
self.assertEqual(collection["name"], "LoRa testing")
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"collection": collection, "created": True, "added": 3, "already": 0},
|
||||
)
|
||||
self.assertEqual(self.db.list_collections(), [collection])
|
||||
self.assertTrue(self.db.collection_exists(collection["id"]))
|
||||
self.assertEqual(self.db.get_collection(collection["id"]), collection)
|
||||
self.assertEqual(
|
||||
self.db.collection_members(collection["id"], ROOT), {"a.png", "b.png"}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.root_collection_members(OTHER), {collection["id"]: {"a.png"}}
|
||||
)
|
||||
self.assertEqual(self.db.collection_counts(), {collection["id"]: 3})
|
||||
self.assertEqual(self.db.file_collections(ROOT, "a.png"), [collection["id"]])
|
||||
self.assertEqual(self.db.file_collections(ROOT, "zzz.png"), [])
|
||||
|
||||
def test_add_by_name_ignores_case_and_unicode_form(self):
|
||||
self.require()
|
||||
first = self.add([(ROOT, "a.png")], name="Café grids")["collection"]
|
||||
# NFD spelling (macOS) in another case joins the same collection.
|
||||
second = self.add([(ROOT, "b.png")], name="CAFÉ GRIDS")
|
||||
self.assertEqual(second["collection"], first)
|
||||
self.assertFalse(second["created"])
|
||||
self.assertEqual(len(self.db.list_collections()), 1)
|
||||
self.assertEqual(
|
||||
self.db.collection_members(first["id"], ROOT), {"a.png", "b.png"}
|
||||
)
|
||||
|
||||
def test_add_counts_already_present(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png"), (ROOT, "b.png")], name="Set")
|
||||
result = self.add(
|
||||
[(ROOT, "b.png"), (ROOT, "c.png"), (ROOT, "c.png")],
|
||||
collection_id=made["collection"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
(result["created"], result["added"], result["already"]), (False, 1, 1)
|
||||
)
|
||||
self.assertEqual(self.db.collection_counts(), {made["collection"]["id"]: 3})
|
||||
|
||||
def test_add_with_a_new_name_and_nothing_inserted_creates_nothing(self):
|
||||
self.require()
|
||||
with self.assertRaises(CollectionFilesMissing):
|
||||
self.add([], name="Empty")
|
||||
self.assertEqual(self.db.list_collections(), [])
|
||||
# An existing name with nothing to add is not an error.
|
||||
made = self.add([(ROOT, "a.png")], name="Kept")["collection"]
|
||||
self.assertEqual(
|
||||
self.add([], name="kept"),
|
||||
{"collection": made, "created": False, "added": 0, "already": 0},
|
||||
)
|
||||
|
||||
def test_add_to_a_deleted_collection_raises_missing(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="Gone")["collection"]
|
||||
self.db.delete_collection(made["id"])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.add([(ROOT, "b.png")], collection_id=made["id"])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.remove_from_collection(made["id"], [(ROOT, "a.png")])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.rename_collection(made["id"], "Back")
|
||||
self.assertFalse(self.db.collection_exists(made["id"]))
|
||||
self.assertIsNone(self.db.get_collection(made["id"]))
|
||||
|
||||
def test_remove_returns_the_count(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png"), (ROOT, "b.png")], name="Set")["collection"]
|
||||
result = self.db.remove_from_collection(
|
||||
made["id"], [(ROOT, "a.png"), (ROOT, "a.png"), (ROOT, "never.png")]
|
||||
)
|
||||
self.assertEqual(result, {"collection": made, "removed": 1})
|
||||
self.assertEqual(self.db.collection_members(made["id"], ROOT), {"b.png"})
|
||||
# Removing the last member keeps the (now empty) collection.
|
||||
self.db.remove_from_collection(made["id"], [(ROOT, "b.png")])
|
||||
self.assertEqual(self.db.list_collections(), [made])
|
||||
self.assertEqual(self.db.collection_counts(), {made["id"]: 0})
|
||||
|
||||
def test_rename_refuses_a_taken_name(self):
|
||||
self.require()
|
||||
first = self.add([(ROOT, "a.png")], name="First")["collection"]
|
||||
second = self.add([(ROOT, "a.png")], name="Second")["collection"]
|
||||
with self.assertRaises(CollectionNameTaken) as caught:
|
||||
self.db.rename_collection(second["id"], " first ")
|
||||
self.assertEqual(caught.exception.collection_id, first["id"])
|
||||
self.assertEqual(self.db.get_collection(second["id"]), second)
|
||||
|
||||
def test_rename_accepts_a_case_variant_of_its_own_name(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="lora grids")["collection"]
|
||||
renamed = self.db.rename_collection(made["id"], "LoRA grids ")
|
||||
self.assertEqual(renamed, {"id": made["id"], "name": "LoRA grids"})
|
||||
self.assertEqual(self.db.list_collections(), [renamed])
|
||||
other = self.db.rename_collection(made["id"], "Portraits")
|
||||
self.assertEqual(other["name"], "Portraits")
|
||||
|
||||
def test_delete_unknown_raises_missing(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="Doomed")["collection"]
|
||||
self.assertTrue(self.db.delete_collection(made["id"]))
|
||||
self.assertEqual(self.db.root_collection_members(ROOT), {})
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.delete_collection(made["id"])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.delete_collection(999)
|
||||
|
||||
def test_list_is_in_name_key_order(self):
|
||||
self.require()
|
||||
for name in ("beta", "Alpha", "été", "Zeta", "alpha two"):
|
||||
self.add([(ROOT, "a.png")], name=name)
|
||||
self.assertEqual(
|
||||
[row["name"] for row in self.db.list_collections()],
|
||||
["Alpha", "alpha two", "beta", "Zeta", "été"],
|
||||
)
|
||||
|
||||
def test_names_are_trimmed_and_collapsed(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name=" LoRa \t testing\n ")["collection"]
|
||||
self.assertEqual(made["name"], "LoRa testing")
|
||||
for bad in ("", " ", "x" * 101, "bell\x07", 5, None):
|
||||
with self.subTest(name=bad):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add([(ROOT, "a.png")], name=bad)
|
||||
self.assertEqual(len(self.db.list_collections()), 1)
|
||||
|
||||
def test_targets_and_items_are_validated(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="Set")["collection"]
|
||||
for target in (
|
||||
{},
|
||||
{"name": "x", "collection_id": made["id"]},
|
||||
{"collection_id": True},
|
||||
{"collection_id": 0},
|
||||
{"collection_id": "1"},
|
||||
):
|
||||
with self.subTest(target=target):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add([(ROOT, "a.png")], **target)
|
||||
for items in ([("root",)], [(ROOT, 5)], "a.png", [(ROOT, "x" * 4097)]):
|
||||
with self.subTest(items=items):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add(items, collection_id=made["id"])
|
||||
|
||||
def test_reads_use_an_index(self):
|
||||
self.require()
|
||||
self.add([(ROOT, "a.png")], name="Set")
|
||||
conn = sqlite3.connect(self.db.model.db_path)
|
||||
try:
|
||||
for sql in READ_SQL:
|
||||
plan = " ".join(
|
||||
row[3]
|
||||
for row in conn.execute(
|
||||
"EXPLAIN QUERY PLAN " + sql, [None] * sql.count("?")
|
||||
)
|
||||
)
|
||||
with self.subTest(sql=sql):
|
||||
self.assertNotIn("SCAN image_collection_items", plan)
|
||||
if "?" in sql: # a lookup, not the whole list
|
||||
self.assertNotIn("SCAN image_collections", plan)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Collections schema (#331): install, validation and restore safety."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import unicodedata
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
from tests.test_annotation_schema import LEGACY_TABLES, table_rows
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
from test_annotation_schema import LEGACY_TABLES, table_rows
|
||||
|
||||
from database.models import PromptModel
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database import image_collections
|
||||
from database.image_collections import (
|
||||
image_collections_schema_error,
|
||||
migrate_image_collections,
|
||||
)
|
||||
except ImportError:
|
||||
image_collections = None
|
||||
image_collections_schema_error = None
|
||||
migrate_image_collections = None
|
||||
|
||||
COLLECTIONS = "image_collections"
|
||||
ITEMS = "image_collection_items"
|
||||
INDEX = "idx_image_collection_items_file"
|
||||
TABLES = (COLLECTIONS, ITEMS)
|
||||
DDL = {
|
||||
COLLECTIONS: (
|
||||
f"CREATE TABLE {COLLECTIONS} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"name TEXT NOT NULL, name_key TEXT NOT NULL UNIQUE, "
|
||||
"created_at TEXT NOT NULL)"
|
||||
),
|
||||
ITEMS: (
|
||||
f"CREATE TABLE {ITEMS} (collection_id INTEGER NOT NULL REFERENCES "
|
||||
f"{COLLECTIONS}(id) ON DELETE CASCADE, root_key TEXT NOT NULL, "
|
||||
"relative_path TEXT NOT NULL, added_at TEXT NOT NULL, "
|
||||
"PRIMARY KEY (collection_id, root_key, relative_path))"
|
||||
),
|
||||
}
|
||||
INDEX_DDL = f"CREATE INDEX {INDEX} ON {ITEMS}(root_key, relative_path)"
|
||||
NOW = "2026-10-27T00:00:00.000000+00:00"
|
||||
IMAGES = 23_000
|
||||
|
||||
|
||||
def _compact(sql):
|
||||
return re.sub(r"\s+", "", sql or "").lower()
|
||||
|
||||
|
||||
def expected_catalog():
|
||||
return {
|
||||
("table", COLLECTIONS, _compact(DDL[COLLECTIONS])),
|
||||
("table", ITEMS, _compact(DDL[ITEMS])),
|
||||
("index", INDEX, _compact(INDEX_DDL)),
|
||||
("index", f"sqlite_autoindex_{COLLECTIONS}_1", ""),
|
||||
("index", f"sqlite_autoindex_{ITEMS}_1", ""),
|
||||
}
|
||||
|
||||
|
||||
def catalog(conn):
|
||||
return {
|
||||
(kind, name, _compact(sql))
|
||||
for kind, name, sql in conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master "
|
||||
"WHERE name IN (?,?,?) OR tbl_name IN (?,?)",
|
||||
(*TABLES, INDEX, *TABLES),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def key(name):
|
||||
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())
|
||||
|
||||
|
||||
def schema(conn, exclude=()):
|
||||
return sorted(
|
||||
tuple(row)
|
||||
for row in conn.execute(
|
||||
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY name"
|
||||
)
|
||||
if row[1] not in exclude and row[2] not in exclude
|
||||
)
|
||||
|
||||
|
||||
def row_counts(conn):
|
||||
tables = [
|
||||
name
|
||||
for (name,) in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
]
|
||||
return {
|
||||
table: conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # nosec
|
||||
for table in tables
|
||||
}
|
||||
|
||||
|
||||
class ImageCollectionsSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.models = []
|
||||
self.addCleanup(self.close_models)
|
||||
self.path = str(Path(self.temp.name) / "live.db")
|
||||
|
||||
def close_models(self):
|
||||
for model in self.models:
|
||||
model.close_all()
|
||||
|
||||
def model(self, path=None):
|
||||
model = PromptModel(path or self.path)
|
||||
self.models.append(model)
|
||||
return model
|
||||
|
||||
def require_module(self):
|
||||
self.assertIsNotNone(
|
||||
image_collections_schema_error,
|
||||
"database.image_collections is not implemented",
|
||||
)
|
||||
|
||||
def closed_current(self, name="current.db", *statements):
|
||||
"""A closed current database, altered afterwards by *statements*."""
|
||||
path = str(Path(self.temp.name) / name)
|
||||
model = self.model(path)
|
||||
model.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute("PRAGMA foreign_keys=OFF")
|
||||
for statement in statements:
|
||||
if isinstance(statement, tuple):
|
||||
raw.execute(*statement)
|
||||
else:
|
||||
raw.execute(statement)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def drop_group(path):
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute(f"DROP TABLE {ITEMS}")
|
||||
raw.execute(f"DROP TABLE {COLLECTIONS}")
|
||||
|
||||
def test_migration_creates_tables_and_index(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
self.assertIsNone(image_collections_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {COLLECTIONS}").fetchone()[0], 0
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {ITEMS}").fetchone()[0], 0
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_migration_is_idempotent(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {COLLECTIONS} (name, name_key, created_at) "
|
||||
"VALUES ('Kept', 'kept', ?)",
|
||||
(NOW,),
|
||||
)
|
||||
conn.execute(f"INSERT INTO {ITEMS} VALUES (1, 'root', 'a.png', ?)", (NOW,))
|
||||
before = schema(conn)
|
||||
model.close_all()
|
||||
for _ in range(2):
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
migrate_image_collections(conn)
|
||||
self.assertEqual(schema(conn), before)
|
||||
self.assertEqual(
|
||||
[tuple(r) for r in conn.execute(f"SELECT * FROM {ITEMS}")],
|
||||
[(1, "root", "a.png", NOW)],
|
||||
)
|
||||
model.close_all()
|
||||
|
||||
def test_migration_reads_only_its_own_tables(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(1,'positive','p')")
|
||||
conn.executemany(
|
||||
"INSERT INTO generated_images(prompt_id,image_path,filename,"
|
||||
"file_path) VALUES(1,?,?,?)",
|
||||
((f"/out/{n}.png", f"{n}.png", f"/out/{n}.png") for n in range(IMAGES)),
|
||||
)
|
||||
conn.execute("INSERT INTO image_favorites VALUES('root','a.png',?)", (NOW,))
|
||||
model.close_all()
|
||||
self.drop_group(self.path)
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
raw.execute("PRAGMA foreign_keys=ON")
|
||||
counts_before = row_counts(raw)
|
||||
schema_before = schema(raw)
|
||||
legacy_before = table_rows(raw, LEGACY_TABLES)
|
||||
self.assertEqual(counts_before["generated_images"], IMAGES)
|
||||
touched = []
|
||||
|
||||
def record(action, arg1, arg2, _db, _trigger):
|
||||
touched.append((action, arg1, arg2))
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
raw.set_authorizer(record)
|
||||
try:
|
||||
migrate_image_collections(raw)
|
||||
finally:
|
||||
raw.set_authorizer(None)
|
||||
raw.commit()
|
||||
reads = {
|
||||
arg1 for action, arg1, _arg2 in touched if action == sqlite3.SQLITE_READ
|
||||
}
|
||||
self.assertTrue(reads)
|
||||
self.assertLessEqual(reads, {"sqlite_master", COLLECTIONS, ITEMS})
|
||||
writes = {
|
||||
arg1
|
||||
for action, arg1, _arg2 in touched
|
||||
if action
|
||||
in (sqlite3.SQLITE_INSERT, sqlite3.SQLITE_UPDATE, sqlite3.SQLITE_DELETE)
|
||||
}
|
||||
self.assertLessEqual(writes, {"sqlite_master"})
|
||||
self.assertEqual(catalog(raw), expected_catalog())
|
||||
counts_after = row_counts(raw)
|
||||
self.assertEqual(counts_after.pop(COLLECTIONS), 0)
|
||||
self.assertEqual(counts_after.pop(ITEMS), 0)
|
||||
self.assertEqual(counts_after, counts_before)
|
||||
self.assertEqual(schema(raw, exclude=TABLES), schema_before)
|
||||
self.assertEqual(table_rows(raw, LEGACY_TABLES), legacy_before)
|
||||
# A second run is a no-op.
|
||||
after_first = schema(raw)
|
||||
changes = raw.total_changes
|
||||
migrate_image_collections(raw)
|
||||
raw.commit()
|
||||
self.assertEqual(schema(raw), after_first)
|
||||
self.assertEqual(raw.total_changes, changes)
|
||||
|
||||
def test_migration_refuses_a_foreign_object_of_the_same_name(self):
|
||||
self.require_module()
|
||||
for statements in (
|
||||
(f"CREATE TABLE {COLLECTIONS} (x)",),
|
||||
(f"CREATE VIEW {COLLECTIONS} AS SELECT 1",),
|
||||
(f"CREATE INDEX {INDEX} ON prompts(text)",),
|
||||
):
|
||||
with self.subTest(statements=statements):
|
||||
path = str(Path(self.temp.name) / f"foreign{len(self.models)}.db")
|
||||
self.model(path).close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
self.drop_group(path)
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
for statement in statements:
|
||||
raw.execute(statement)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
before = schema(raw)
|
||||
self.assertIsNotNone(image_collections_schema_error(raw))
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.model(path).get_connection()
|
||||
PromptModel.close_all_instances()
|
||||
PromptModel.reset_schema_cache()
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(schema(raw), before)
|
||||
self.assertIsNone(
|
||||
raw.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name=?", (ITEMS,)
|
||||
).fetchone()
|
||||
)
|
||||
self.assertFalse(PromptModel.verify_database_file(path)[0])
|
||||
|
||||
def test_migration_rolls_back_on_error(self):
|
||||
self.require_module()
|
||||
self.model().close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
self.drop_group(self.path)
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
with patch.object(
|
||||
image_collections,
|
||||
"image_collections_schema_error",
|
||||
side_effect=[None, "Image collections went wrong"],
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.DatabaseError, "went wrong"):
|
||||
migrate_image_collections(raw)
|
||||
raw.commit()
|
||||
self.assertEqual(catalog(raw), set())
|
||||
|
||||
def test_startup_accepts_a_value_saved_under_older_rules(self):
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
"older.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (name, name_key, created_at) "
|
||||
"VALUES (?, 'not the key', ?)",
|
||||
(" " + "x" * 150 + " ", NOW),
|
||||
),
|
||||
)
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertIsNone(image_collections_schema_error(conn, check_values=False))
|
||||
self.assertIsNotNone(image_collections_schema_error(conn))
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {COLLECTIONS}").fetchone()[0], 1
|
||||
)
|
||||
model.close_all()
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(path, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_restore_accepts_an_old_backup_without_collections(self):
|
||||
self.require_module()
|
||||
path = self.closed_current("old.db")
|
||||
self.drop_group(path)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNone(image_collections_schema_error(raw))
|
||||
self.assertEqual(
|
||||
image_collections_schema_error(raw, allow_missing=False),
|
||||
"Missing image collections schema",
|
||||
)
|
||||
self.assertEqual(PromptModel.verify_database_file(path), (True, "ok"))
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
model.close_all()
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_restore_requires_collections_on_a_current_stage(self):
|
||||
self.require_module()
|
||||
path = self.closed_current("stage.db")
|
||||
self.drop_group(path)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(False, "Missing image collections schema"),
|
||||
)
|
||||
# One table without the other is never acceptable.
|
||||
partial = self.closed_current("partial.db", f"DROP TABLE {ITEMS}")
|
||||
self.assertFalse(PromptModel.verify_database_file(partial)[0])
|
||||
|
||||
def assert_refused(self, name, *statements):
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
f"{name}.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, 'Good', 'good', ?)",
|
||||
(NOW,),
|
||||
),
|
||||
(f"INSERT INTO {ITEMS} VALUES (1, 'root', 'a.png', ?)", (NOW,)),
|
||||
*statements,
|
||||
)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNone(image_collections_schema_error(raw, check_values=False))
|
||||
error = image_collections_schema_error(raw)
|
||||
self.assertIsNotNone(error)
|
||||
self.assertIn("collections", error.lower())
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(reason, error)
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(path, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_a_valid_current_database_is_accepted(self):
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
"valid.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, ?, ?, ?)",
|
||||
("LoRa testing", key("LoRa testing"), NOW),
|
||||
),
|
||||
(f"INSERT INTO {ITEMS} VALUES (1, 'root', ?, ?)", ("é" * 4096, NOW)),
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_non_text_name(self):
|
||||
self.assert_refused("int-name", f"UPDATE {COLLECTIONS} SET name = 5")
|
||||
|
||||
def test_restore_refuses_an_empty_name(self):
|
||||
self.assert_refused("empty", f"UPDATE {COLLECTIONS} SET name = '', name_key=''")
|
||||
|
||||
def test_restore_refuses_a_name_over_100_characters(self):
|
||||
name = "é" * 101
|
||||
self.assert_refused(
|
||||
"long",
|
||||
(f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?", (name, key(name))),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_control_character(self):
|
||||
name = "a\x07b"
|
||||
self.assert_refused(
|
||||
"control",
|
||||
(f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?", (name, key(name))),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_leading_or_trailing_space(self):
|
||||
for name in (" Good", "Good "):
|
||||
with self.subTest(name=name):
|
||||
self.assert_refused(
|
||||
f"padded{len(name)}{name[0] == ' '}",
|
||||
(
|
||||
f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?",
|
||||
(name, key(name)),
|
||||
),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_wrong_name_key(self):
|
||||
self.assert_refused("key", f"UPDATE {COLLECTIONS} SET name_key = 'Good'")
|
||||
|
||||
def test_restore_refuses_a_non_text_key(self):
|
||||
# Column affinity turns a number into text; a blob stays a blob.
|
||||
self.assert_refused("blob-key", f"UPDATE {ITEMS} SET relative_path = x'61'")
|
||||
|
||||
def test_restore_refuses_a_key_over_4096_characters(self):
|
||||
self.assert_refused(
|
||||
"long-key", (f"UPDATE {ITEMS} SET root_key = ?", ("é" * 4097,))
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_timestamp_over_64_bytes(self):
|
||||
self.assert_refused(
|
||||
"stamp", (f"UPDATE {COLLECTIONS} SET created_at = ?", ("t" * 65,))
|
||||
)
|
||||
self.assert_refused(
|
||||
"item-stamp", (f"UPDATE {ITEMS} SET added_at = ?", ("t" * 65,))
|
||||
)
|
||||
|
||||
def test_restore_refuses_an_item_of_a_missing_collection(self):
|
||||
self.assert_refused("orphan", f"INSERT INTO {ITEMS} VALUES (9, 'r', 'b', 'n')")
|
||||
|
||||
def test_restore_refuses_a_non_integer_collection_id(self):
|
||||
self.assert_refused("text-id", f"UPDATE {ITEMS} SET collection_id = 'one'")
|
||||
|
||||
def test_restore_refuses_a_trigger_or_extra_index(self):
|
||||
self.require_module()
|
||||
for name, statement in (
|
||||
(
|
||||
"trigger",
|
||||
f"CREATE TRIGGER t AFTER INSERT ON {ITEMS} BEGIN SELECT 1; END",
|
||||
),
|
||||
("extra", f"CREATE INDEX extra ON {COLLECTIONS}(name)"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
path = self.closed_current(f"{name}.db", statement)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNotNone(image_collections_schema_error(raw))
|
||||
self.assertFalse(PromptModel.verify_database_file(path)[0])
|
||||
|
||||
def test_deleting_a_collection_deletes_its_members(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, 'A', 'a', ?), (2, 'B', 'b', ?)",
|
||||
(NOW, NOW),
|
||||
)
|
||||
conn.executemany(
|
||||
f"INSERT INTO {ITEMS} VALUES (?, 'root', ?, ?)",
|
||||
((1, "a.png", NOW), (1, "b.png", NOW), (2, "a.png", NOW)),
|
||||
)
|
||||
conn.execute(f"DELETE FROM {COLLECTIONS} WHERE id = 1")
|
||||
self.assertEqual(
|
||||
[tuple(r) for r in conn.execute(f"SELECT collection_id FROM {ITEMS}")],
|
||||
[(2,)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -884,6 +884,16 @@ class TestFilterStart(ImageTagJobTestCase):
|
||||
self.assertEqual((status, data["code"]), (400, "no-images"))
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_filter_with_a_deleted_collection_is_a_404_without_a_job(self):
|
||||
self.png("a.png")
|
||||
|
||||
status, data = await self.start(
|
||||
{"mode": "add", "filter": {"collection": "77"}, "tags": ["x"]}
|
||||
)
|
||||
|
||||
self.assertEqual((status, data["code"]), (404, "collection-not-found"))
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_filter_over_the_cap_is_refused_without_a_job(self):
|
||||
from py.api import image_tag_jobs
|
||||
|
||||
|
||||
+158
-1
@@ -115,7 +115,12 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.assertEqual(second["pairs_added"], 0)
|
||||
|
||||
def test_legacy_single_prompt_second_pass_not_reported_as_new_link(self):
|
||||
meta = {"prompt": json.dumps({"1": graph()["1"]})}
|
||||
# A sampler-less graph: one encoder wired into a guider (#252).
|
||||
single = {
|
||||
"1": graph()["1"],
|
||||
"2": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
|
||||
}
|
||||
meta = {"prompt": json.dumps(single)}
|
||||
path = self.output_dir / "old.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
|
||||
@@ -292,3 +297,155 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.api.db.get_image_prompt_info(str(path))["workflow_data"], workflow
|
||||
)
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, metadata), "unchanged")
|
||||
|
||||
|
||||
def flux_graph(text="a red fox", encoder="CLIPTextEncode"):
|
||||
"""A sampler-less Flux graph: the prompt reaches a guider's conditioning (#252)."""
|
||||
return {
|
||||
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux.safetensors"}},
|
||||
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": "t5"}},
|
||||
"6": {"class_type": encoder, "inputs": {"text": text, "clip": ["2", 0]}},
|
||||
"22": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["1", 0], "conditioning": ["6", 0]},
|
||||
},
|
||||
"13": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["22", 0], "sigmas": ["17", 0]},
|
||||
},
|
||||
"17": {"class_type": "BasicScheduler", "inputs": {"steps": 20}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"images": ["13", 0]}},
|
||||
}
|
||||
|
||||
|
||||
class TestSamplerLessScan(AdminAPITestCase):
|
||||
"""Graphs where no node takes a positive or negative input (#252)."""
|
||||
|
||||
def _scanned(self, name, prompt_graph, workflow=None):
|
||||
meta = {"prompt": json.dumps(prompt_graph)}
|
||||
if workflow is not None:
|
||||
meta["workflow"] = json.dumps(workflow)
|
||||
return self.api._ingest_scanned_file(self.output_dir / name, meta)
|
||||
|
||||
def _texts(self):
|
||||
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
|
||||
|
||||
def test_the_one_wired_prompt_is_linked(self):
|
||||
self.assertEqual(self._scanned("flux.png", flux_graph()), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
def test_a_text_node_feeding_the_encoder_is_one_prompt(self):
|
||||
g = flux_graph(text=["30", 0])
|
||||
g["30"] = {"class_type": "TextInput", "inputs": {"text": "a red fox"}}
|
||||
self.assertEqual(self._scanned("textinput.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
def test_a_display_node_that_feeds_nothing_is_never_the_prompt(self):
|
||||
# A real scan stored a "=== GIT CLONE REPORT ===" display text this way.
|
||||
g = flux_graph()
|
||||
g["40"] = {
|
||||
"class_type": "easy showAnything",
|
||||
"inputs": {"text": "=== GIT CLONE REPORT ===\nTotal repositories: 1"},
|
||||
}
|
||||
self.assertEqual(self._scanned("report.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
def test_only_unconsumed_texts_store_nothing(self):
|
||||
g = {
|
||||
"40": {"class_type": "easy showAnything", "inputs": {"text": "report"}},
|
||||
"41": {"class_type": "Textbox", "inputs": {"text": "install note"}},
|
||||
}
|
||||
self.assertEqual(self._scanned("notes.png", g), "found")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_two_wired_texts_are_ambiguous_and_stay_unlinked(self):
|
||||
# A LoRA loader's text input is wired too: the scan stored "<lora:...>".
|
||||
g = flux_graph()
|
||||
g["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"clip": ["2", 0],
|
||||
"text": "<lora:style:1.00>",
|
||||
},
|
||||
}
|
||||
g["22"]["inputs"]["model"] = ["50", 0]
|
||||
g["6"]["inputs"]["clip"] = ["50", 1]
|
||||
self.assertEqual(self._scanned("lora.png", g), "unresolved")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_an_untraceable_wired_text_makes_the_graph_ambiguous(self):
|
||||
# The encoder's text comes from a node no rule reads: never guess the other.
|
||||
g = flux_graph(text=["60", 0])
|
||||
g["60"] = {"class_type": "SomeLLMNode", "inputs": {"seed": 1}}
|
||||
g["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
|
||||
}
|
||||
g["22"]["inputs"]["model"] = ["50", 0]
|
||||
self.assertEqual(self._scanned("llm.png", g), "unresolved")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_this_repositorys_prompt_node_is_authoritative(self):
|
||||
g = flux_graph(text="a red fox", encoder="PromptManager")
|
||||
g["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
|
||||
}
|
||||
g["22"]["inputs"]["model"] = ["50", 0]
|
||||
self.assertEqual(self._scanned("own.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
def test_a_numeric_text_is_never_stored_as_a_prompt(self):
|
||||
self.assertEqual(self._scanned("number.png", flux_graph(text="2048")), "found")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_workflow_widget_values_are_never_read(self):
|
||||
# The API text is linked from a node no rule reads; the UI workflow
|
||||
# still holds a stale widget value, which the old scan stored.
|
||||
g = flux_graph(text=["70", 0])
|
||||
g["70"] = {"class_type": "PromptBuilder", "inputs": {"prompt": "x", "n": 2}}
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{"id": 6, "type": "CLIPTextEncode", "widgets_values": ["stale text"]}
|
||||
]
|
||||
}
|
||||
self.assertEqual(self._scanned("stale.png", g, workflow), "unresolved")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_a_lora_loaders_trigger_word_output_is_not_its_text(self):
|
||||
# The encoder reads the loader's trigger-word output (slot 2), not its
|
||||
# text input: the prompt is unknown, never the "<lora:...>" syntax.
|
||||
g = flux_graph(text=["50", 2])
|
||||
g["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
|
||||
}
|
||||
g["22"]["inputs"]["model"] = ["50", 0]
|
||||
self.assertEqual(self._scanned("trigger.png", g), "unresolved")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_a_seeded_text_node_holds_a_template_not_the_prompt(self):
|
||||
g = flux_graph(text=["80", 0])
|
||||
g["80"] = {
|
||||
"class_type": "RandomPrompt",
|
||||
"inputs": {"text": "a {red|blue} fox", "seed": 5},
|
||||
}
|
||||
self.assertEqual(self._scanned("random.png", g), "unresolved")
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_a1111_parameters_beside_an_undecided_graph_are_read(self):
|
||||
# Metadata savers write both; the old scan read the A1111 positive.
|
||||
g = flux_graph()
|
||||
g["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
|
||||
}
|
||||
g["22"]["inputs"]["model"] = ["50", 0]
|
||||
meta = {
|
||||
"prompt": json.dumps(g),
|
||||
"parameters": "a red fox\nNegative prompt: blur\nSteps: 20",
|
||||
}
|
||||
path = self.output_dir / "saver.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
@@ -49,6 +49,7 @@ SUMMARY_KEYS = {
|
||||
"image_links",
|
||||
"runs",
|
||||
"saved_views",
|
||||
"collections",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ class TestStageAndPublish(StageTestCase):
|
||||
for key in ("pairs", "archived_pairs", "image_links", "runs"):
|
||||
self.assertEqual(staged.summary[key], 0, key)
|
||||
self.assertEqual(staged.summary["saved_views"], 0)
|
||||
self.assertEqual(staged.summary["collections"], 0)
|
||||
self.assertEqual(staged.schema, {"migrated": False, "library_state": True})
|
||||
self.assertEqual(
|
||||
staged.digest,
|
||||
@@ -239,6 +240,48 @@ class TestStageAndPublish(StageTestCase):
|
||||
staged.discard()
|
||||
|
||||
|
||||
class TestCollectionsSummary(StageTestCase):
|
||||
"""#331: collections join the review's summary and the live fingerprint."""
|
||||
|
||||
def add_collection(self, db, name):
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO image_collections (name, name_key, created_at) "
|
||||
"VALUES (?, ?, '2026-10-27T00:00:00+00:00')",
|
||||
(name, name.casefold()),
|
||||
)
|
||||
|
||||
def test_summary_counts_collections(self):
|
||||
src_path = os.path.join(self.uploads, "with-collections.db")
|
||||
src = PromptDatabase(src_path)
|
||||
try:
|
||||
self.save(src, "kept")
|
||||
self.add_collection(src, "First")
|
||||
self.add_collection(src, "Second")
|
||||
finally:
|
||||
src.close_all()
|
||||
staged = self.db.model.stage_restore(src_path)
|
||||
try:
|
||||
self.assertEqual(staged.summary["collections"], 2)
|
||||
finally:
|
||||
staged.discard()
|
||||
self.assertIn("collections", restore_stage.SUMMARY_KEYS)
|
||||
|
||||
def test_adding_a_collection_changes_the_fingerprint(self):
|
||||
conn = self.db.model.get_connection()
|
||||
summary, before = restore_stage.live_fingerprint(conn)
|
||||
self.assertEqual(summary["collections"], 0)
|
||||
self.add_collection(self.db, "After the review")
|
||||
summary, after = restore_stage.live_fingerprint(conn)
|
||||
self.assertEqual(summary["collections"], 1)
|
||||
self.assertNotEqual(after, before)
|
||||
# A delete and a re-add with the same count still changes it (rowid).
|
||||
with self.db.model.get_connection() as write:
|
||||
write.execute("DELETE FROM image_collections")
|
||||
self.add_collection(self.db, "Replacement")
|
||||
self.assertNotEqual(restore_stage.live_fingerprint(conn)[1], after)
|
||||
|
||||
|
||||
class TestOlderBackups(StageTestCase):
|
||||
"""Rollback-journal and older-schema backups restore exactly as before."""
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""``sampler_less_prompt``: the one wired text of a graph with no sampler (#252)."""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from utils.prompt_pair_resolver import sampler_less_prompt
|
||||
|
||||
|
||||
def _guided(**nodes):
|
||||
"""``nodes`` plus a guider consuming node ``"1"``."""
|
||||
graph = {"9": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}}}
|
||||
graph.update(nodes)
|
||||
return graph
|
||||
|
||||
|
||||
class TestSamplerLessPrompt(unittest.TestCase):
|
||||
def test_not_a_graph_or_too_large_is_absent(self):
|
||||
for graph in (None, [], {}, "text"):
|
||||
self.assertEqual(sampler_less_prompt(graph)["state"], "absent")
|
||||
big = {str(i): {"class_type": "X", "inputs": {}} for i in range(3)}
|
||||
self.assertEqual(sampler_less_prompt(big, max_visits=2)["state"], "absent")
|
||||
|
||||
def test_one_literal_text(self):
|
||||
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": " a fox "}}})
|
||||
self.assertEqual(
|
||||
sampler_less_prompt(graph),
|
||||
{"state": "resolved", "text": "a fox", "texts": ["a fox"]},
|
||||
)
|
||||
|
||||
def test_a_number_literal_is_never_a_prompt(self):
|
||||
for value in (2048, "2048", "1.5", " 7 "):
|
||||
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": value}}})
|
||||
self.assertEqual(sampler_less_prompt(graph)["state"], "absent", value)
|
||||
|
||||
def test_a_long_digit_run_with_a_letter_is_text_and_fast(self):
|
||||
text = "9" * 50_000 + "x"
|
||||
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": text}}})
|
||||
started = time.monotonic()
|
||||
self.assertEqual(sampler_less_prompt(graph)["text"], text)
|
||||
self.assertLess(time.monotonic() - started, 1.0)
|
||||
|
||||
def test_number_like_words_are_prompts(self):
|
||||
for value in ("nan", "inf", "Infinity", "1_000"):
|
||||
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": value}}})
|
||||
self.assertEqual(sampler_less_prompt(graph)["text"], value)
|
||||
|
||||
def test_an_own_node_with_unreadable_text_beside_another_text_is_ambiguous(self):
|
||||
graph = _guided(
|
||||
**{
|
||||
"1": {"class_type": "PromptManager", "inputs": {"text": ["5", 0]}},
|
||||
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
|
||||
"2": {"class_type": "Enc", "inputs": {"text": "<lora:x:1>"}},
|
||||
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(sampler_less_prompt(graph)["state"], "ambiguous")
|
||||
|
||||
def test_unhashable_class_type_is_an_unknown_node_not_a_crash(self):
|
||||
graph = _guided(**{"1": {"class_type": ["x"], "inputs": {"text": "a fox"}}})
|
||||
self.assertEqual(sampler_less_prompt(graph)["text"], "a fox")
|
||||
|
||||
def test_own_negative_node_is_not_authoritative(self):
|
||||
graph = _guided(
|
||||
**{
|
||||
"1": {"class_type": "Enc", "inputs": {"text": "a fox"}},
|
||||
"2": {
|
||||
"class_type": "PromptManagerNegative",
|
||||
"inputs": {"text": "blur"},
|
||||
},
|
||||
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
|
||||
}
|
||||
)
|
||||
found = sampler_less_prompt(graph)
|
||||
self.assertEqual(found["state"], "ambiguous")
|
||||
self.assertEqual(found["texts"], ["a fox", "blur"])
|
||||
|
||||
def test_own_node_text_wins_over_an_untraceable_text(self):
|
||||
graph = _guided(
|
||||
**{
|
||||
"1": {"class_type": "PromptManagerText", "inputs": {"text": "a fox"}},
|
||||
"2": {"class_type": "Enc", "inputs": {"text": ["5", 0]}},
|
||||
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
|
||||
"6": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(sampler_less_prompt(graph)["text"], "a fox")
|
||||
|
||||
def test_two_own_texts_are_ambiguous(self):
|
||||
graph = _guided(
|
||||
**{
|
||||
"1": {"class_type": "PromptManager", "inputs": {"text": "a fox"}},
|
||||
"2": {"class_type": "PromptManager", "inputs": {"text": "a cat"}},
|
||||
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(sampler_less_prompt(graph)["state"], "ambiguous")
|
||||
|
||||
def test_only_an_untraceable_text_is_ambiguous_with_nothing_to_show(self):
|
||||
graph = _guided(
|
||||
**{
|
||||
"1": {"class_type": "Enc", "inputs": {"text": ["5", 0]}},
|
||||
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
sampler_less_prompt(graph),
|
||||
{"state": "ambiguous", "text": None, "texts": []},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user