Files
ComfyAssets-ComfyUI_PromptM…/tests/test_library_folders.py
T
Vito 0126705045 feat(library): filter prompts by output folder and browse tags with Any (#444, PR 2) (#456)
* feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444

* feat(library): filter prompts by output folder and browse tags with Any (#444)

Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444

* fix(library): let saved-view defaults fill the missing #444 filters

libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444

* test(saved-views): give the D153 Library view the tag_mode key (#444)

The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444

* test(saved-views): give the D153 Library view the folder key (#444)

Refs #444

* fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)

Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444
2026-10-29 14:10:29 +00:00

244 lines
10 KiB
Python

"""#444: the Library's output-folder filter and multi-tag OR."""
import ntpath
import os
import posixpath
import tempfile
import unittest
from pathlib import Path
from database.library_folders import (
folder_prefixes,
normalize_folder,
relative_folder,
)
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
class TestFolderNames(unittest.TestCase):
def test_normalize_strips_outer_slashes_on_both_flavors(self):
for flavor in (posixpath, ntpath):
with self.subTest(flavor=flavor.__name__):
self.assertEqual(normalize_folder("", flavor), "")
self.assertEqual(normalize_folder("/", flavor), "")
self.assertEqual(normalize_folder("/a/b/", flavor), "a/b")
self.assertEqual(normalize_folder("..foo/b", flavor), "..foo/b")
def test_a_backslash_separates_only_on_windows(self):
# Live B1: on POSIX a backslash is part of a folder name, as listed.
name = "Metadata\\Klein_4b Wildcard Run"
self.assertEqual(normalize_folder(name, posixpath), name)
self.assertEqual(
normalize_folder("2026\\08-Aug\\", posixpath), "2026\\08-Aug\\"
)
self.assertEqual(
normalize_folder(name, ntpath), "Metadata/Klein_4b Wildcard Run"
)
self.assertEqual(normalize_folder("2026\\08-Aug\\", ntpath), "2026/08-Aug")
# A colon names a drive only on Windows.
self.assertEqual(normalize_folder("a:b/c", posixpath), "a:b/c")
def test_normalize_refuses_anything_but_a_folder_below_a_root(self):
for flavor, value in (
*(
(flavor, value)
for flavor in (posixpath, ntpath)
for value in ("a/../b", "./a", "a//b", "a\x00b", 3, "x" * 1025)
),
(ntpath, "C:/x"),
(ntpath, "c:"),
(ntpath, "a\\..\\b"),
):
with self.subTest(flavor=flavor.__name__, value=value):
with self.assertRaises(ValueError):
normalize_folder(value, flavor)
def test_the_default_flavor_is_the_platform_path_module(self):
self.assertEqual(normalize_folder("a\\b"), normalize_folder("a\\b", os.path))
def test_posix_prefixes_are_exact_and_end_in_a_separator(self):
self.assertEqual(
folder_prefixes(["/out", "/data/out/"], "a/b", posixpath),
["/out/a/b/", "/data/out/a/b/"],
)
self.assertEqual(folder_prefixes(["/"], "a", posixpath), ["/a/"])
self.assertEqual(
folder_prefixes(["/out", "/out/"], "a", posixpath), ["/out/a/"]
)
with self.assertRaises(ValueError):
folder_prefixes([], "a", posixpath)
def test_windows_prefixes_fold_case_including_non_ascii(self):
self.assertEqual(
folder_prefixes(["C:\\ComfyUI\\Output"], "Ä/Sub", ntpath),
["c:\\comfyui\\output\\ä\\sub\\"],
)
self.assertEqual(folder_prefixes(["D:\\"], "x", ntpath), ["d:\\x\\"])
self.assertEqual(
folder_prefixes(["\\\\Server\\Share\\Out"], "a", ntpath),
["\\\\server\\share\\out\\a\\"],
)
# The stored file_path is normcase(normpath(abspath)), the same fold.
stored = ntpath.normcase(ntpath.normpath("C:/ComfyUI/output/Ä/sub/x.png"))
self.assertTrue(
stored.startswith(
folder_prefixes(["C:/ComfyUI/Output"], "ä/SUB", ntpath)[0]
)
)
def test_relative_folder_keeps_the_stored_spelling(self):
self.assertEqual(
relative_folder("/out/2026/Aug/x.png", ["/out"], posixpath), "2026/Aug"
)
self.assertIsNone(relative_folder("/out/x.png", ["/out"], posixpath))
self.assertIsNone(relative_folder("/outside/x.png", ["/out"], posixpath))
self.assertIsNone(relative_folder("/output2/a/x.png", ["/out"], posixpath))
self.assertEqual(
relative_folder("/out/..foo/x.png", ["/out"], posixpath), "..foo"
)
self.assertEqual(
relative_folder("C:\\Out\\Ä\\Sub\\x.png", ["c:\\out"], ntpath), "Ä/Sub"
)
self.assertEqual(relative_folder("D:\\a\\x.png", ["D:\\"], ntpath), "a")
class TestLibraryFolderAndAnyTag(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
base = Path(os.path.realpath(self.temp.name))
self.root = base / "output"
self.root.mkdir()
self.db = PromptDatabase(str(base / "folders.db"))
def tearDown(self):
try:
self.db.close_all()
finally:
self.temp.cleanup()
def save(self, text, **metadata):
return self.db.save_prompt(
text, prompt_hash=generate_prompt_hash(text), **metadata
)
def link(self, prompt_id, *parts):
path = self.root.joinpath(*parts)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x")
self.db.link_image_to_prompt(prompt_id, str(path))
def texts(self, **options):
return sorted(item["text"] for item in self.db.list_library(**options)["items"])
def in_folder(self, folder, roots=None):
return self.texts(
folder_prefixes=folder_prefixes(roots or [str(self.root)], folder)
)
def test_folder_matches_its_descendants_only(self):
self.link(self.save("deep"), "a", "b", "x.png")
self.link(self.save("top"), "a", "y.png")
self.link(self.save("sibling"), "ab", "z.png")
self.link(self.save("literal"), "100%_off", "w.png")
self.save("no images")
self.assertEqual(self.in_folder("a"), ["deep", "top"])
self.assertEqual(self.in_folder("a/b"), ["deep"])
self.assertEqual(self.in_folder("ab"), ["sibling"])
self.assertEqual(self.in_folder("100%_off"), ["literal"])
self.assertEqual(self.in_folder("1"), [])
cut = self.db.list_library(
folder_prefixes=folder_prefixes([str(self.root)], "a"), limit=1
)
self.assertEqual(cut["total"], 2)
def test_folder_combines_with_text_category_and_tags(self):
self.link(self.save("red cat", category="Art", tags=["cat"]), "a", "1.png")
self.link(self.save("red dog", category="Art", tags=["dog"]), "a", "2.png")
self.link(self.save("red bird", category="Art", tags=["cat"]), "b", "3.png")
prefixes = folder_prefixes([str(self.root)], "a")
self.assertEqual(
self.texts(folder_prefixes=prefixes, tags=["cat"]), ["red cat"]
)
self.assertEqual(
self.texts(folder_prefixes=prefixes, text="dog", category="Art"),
["red dog"],
)
@unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows")
def test_folder_listing_and_match_through_a_symlinked_root(self):
link = self.root.parent / "linked"
os.symlink(self.root, link)
prompt = self.save("via link")
(self.root / "a").mkdir()
(self.root / "a" / "x.png").write_bytes(b"x")
self.db.link_image_to_prompt(prompt, str(link / "a" / "x.png"))
spellings = [str(self.root), str(link)]
self.assertEqual(self.in_folder("a", spellings), ["via link"])
self.assertEqual(self.in_folder("a", [str(self.root)]), [])
self.assertEqual(self.db.get_library_options(spellings)["folders"], ["a"])
@unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows")
def test_a_posix_folder_with_a_backslash_lists_and_filters_alike(self):
name = "Metadata\\Klein_4b Wildcard Run"
self.link(self.save("klein"), name, "x.png")
self.link(self.save("other"), "Metadata", "y.png")
listed = self.db.get_library_options([str(self.root)])["folders"]
self.assertEqual(listed, ["Metadata", name])
self.assertEqual(self.in_folder(normalize_folder(name)), ["klein"])
self.assertEqual(self.in_folder(normalize_folder("Metadata")), ["other"])
def test_folder_listing_offers_ancestors_and_skips_outside_images(self):
self.link(self.save("deep"), "2026", "08-Aug", "x.png")
self.link(self.save("root image"), "y.png")
outside = Path(self.temp.name) / "elsewhere" / "z.png"
outside.parent.mkdir()
outside.write_bytes(b"x")
self.db.link_image_to_prompt(self.save("outside"), str(outside))
options = self.db.get_library_options([str(self.root)])
self.assertEqual(options["folders"], ["2026", "2026/08-Aug"])
self.assertNotIn("folders", self.db.get_library_options())
def test_folder_combines_with_untagged(self):
self.link(self.save("plain in a"), "a", "1.png")
self.link(self.save("tagged in a", tags=["t"]), "a", "2.png")
self.link(self.save("plain in b"), "b", "3.png")
prefixes = folder_prefixes([str(self.root)], "a")
self.assertEqual(
self.texts(folder_prefixes=prefixes, tag_mode="untagged"), ["plain in a"]
)
def test_any_tag_mode_is_the_union_across_parent_and_pair(self):
self.save("parent a", tags=["a"])
self.save("parent b", tags=["b"])
self.save("both", tags=["a", "b"])
self.save("neither", tags=["c"])
self.db.save_prompt_pair("pair b", "noise", tags=["b"])
split = self.save("split", tags=["a"])
self.db.save_prompt_pair("split", "split noise", tags=["b"])
archived = self.db.save_prompt_pair("archived b", "old", tags=["b"])
self.db.archive_prompt_pair(archived)
self.assertIsInstance(split, int)
self.assertEqual(
self.texts(tags=["a", "b"], tag_mode="any"),
["both", "pair b", "parent a", "parent b", "split"],
)
self.assertEqual(self.texts(tags=["a", "b"], tag_mode="all"), ["both"])
self.assertEqual(self.texts(tags=["a", "b"]), ["both"])
for tags in (["a"], ["b"], ["c"]):
self.assertEqual(
self.texts(tags=tags, tag_mode="any"), self.texts(tags=tags)
)
self.assertEqual(
self.texts(tags=["a", "b"], tag_mode="any", text="parent"),
["parent a", "parent b"],
)
self.assertEqual(self.texts(tag_mode="any"), self.texts())
for mode in ("none", None, "ANY"):
with self.subTest(mode=mode), self.assertRaises(ValueError):
self.db.list_library(tag_mode=mode)
if __name__ == "__main__":
unittest.main()