Files
ComfyAssets-ComfyUI_PromptM…/tests/test_saved_instructions.py
T
Vito 7e11d9cae2 Protected rows (#586), review-bot fixes (#593) and LoRA cancel tests (#575) (#597)
* test(lora): hold the import job to test cancel before and after the commit (#575)

Squashed from PR #594. Closes #575.

* fix: address review-bot findings on merged PRs (#593)

Squashed from PR #595. Closes #593.

* fix(maintenance): list every protected item with its reason (#586)

Squashed from PR #596. Closes #586.
2026-11-01 06:42:12 +00:00

384 lines
14 KiB
Python

"""Saved instructions with a default per language-model engine (#431, PR 2).
The entry and default validation, the in-memory migration of the old single
``autotag.defaults.template`` at load (first upgrade and re-upgrade), the
load fallbacks, and ``AutotagConfig.default_instructions_text``.
"""
import hashlib
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock
# config.py imports ComfyUI's server module; stand it in (as test_config does).
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
from py.config import AutotagConfig, PromptManagerConfig # noqa: E402
from py.tagging_defaults import ( # noqa: E402
BUILTIN_INSTRUCTIONS_ID,
DEFAULT_TAG_TEMPLATE,
MAX_SAVED_INSTRUCTIONS,
validate_default_instructions,
validate_instructions,
)
ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE")
def entry(id_, name, text="Tags only."):
return {"id": id_, "name": name, "text": text}
def sha12(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
class TestValidation(unittest.TestCase):
def test_entries_are_kept_with_trimmed_names(self):
self.assertEqual(
validate_instructions([entry("0123456789ab", " Photo ", "Tags.")]),
[entry("0123456789ab", "Photo", "Tags.")],
)
self.assertEqual(validate_instructions([]), [])
def test_every_refusal_sentence(self):
ok = entry("0123456789ab", "One")
cases = (
("x", "Saved instructions must be a list."),
(
[
entry(f"{i:012x}", f"n{i}")
for i in range(MAX_SAVED_INSTRUCTIONS + 1)
],
"Saved instructions: at most 20 entries besides Built-in.",
),
([ok, "x"], "Saved instructions: entry 2 has an invalid id."),
(
[entry("ABCDEF012345", "A")],
"Saved instructions: entry 1 has an invalid id.",
),
([entry("builtin", "A")], "Saved instructions: entry 1 has an invalid id."),
(
[{"id": "0123456789ab", "name": "A"}],
"Saved instructions: entry 1 has an invalid id.",
),
(
[ok, entry("0123456789ab", "Two")],
"Saved instructions: entry 2 repeats the id of entry 1.",
),
(
[entry("0123456789ab", " ")],
"Saved instructions: entry 1 needs a name.",
),
(
[entry("0123456789ab", "x" * 61)],
"Saved instructions: entry 1 name must be at most 60 characters.",
),
(
[entry("0123456789ab", "a\nb")],
"Saved instructions: entry 1 name must not contain a line break.",
),
(
[ok, entry("0123456789ac", "one")],
"Saved instructions: entry 2 name “one” is already used.",
),
(
[entry("0123456789ab", "BUILT-IN")],
"Saved instructions: the name “Built-in” is reserved.",
),
(
[entry("0123456789ab", "A", " ")],
"Saved instructions: entry 1 text must be non-empty text.",
),
(
[entry("0123456789ab", "A", "x" * 4001)],
"Saved instructions: entry 1 text must be at most 4000 characters.",
),
)
for value, message in cases:
with self.subTest(message=message):
with self.assertRaises(ValueError) as caught:
validate_instructions(value)
self.assertEqual(str(caught.exception), message)
def test_defaults_name_built_in_or_a_saved_entry(self):
saved = [entry("0123456789ab", "One")]
self.assertEqual(
validate_default_instructions(
{"gguf": "builtin", "hf": "0123456789ab"}, saved
),
{"gguf": "builtin", "hf": "0123456789ab"},
)
for value, message in (
(
{"gguf": "ffffffffffff", "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
(
{"gguf": "builtin", "hf": "nope"},
"Default for HuggingFace JoyCaption must be one of the saved "
"instructions.",
),
(
{"gguf": "builtin"},
"Default instructions must name GGUF and HuggingFace.",
),
(
{"gguf": [], "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
("builtin", "Default instructions must name GGUF and HuggingFace."),
):
with self.subTest(value=value):
with self.assertRaises(ValueError) as caught:
validate_default_instructions(value, saved)
self.assertEqual(str(caught.exception), message)
class ConfigTestCase(unittest.TestCase):
def setUp(self):
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
AutotagConfig.update_config({"defaults": {}})
def load(self, defaults, warnings=0):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w", encoding="utf-8") as handle:
json.dump({"autotag": {"defaults": defaults}}, handle)
before = os.stat(path).st_mtime_ns, self.read_bytes(path)
if warnings:
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.load_from_file(path)
self.assertEqual(len(logs.records), warnings, logs.output)
self.logs = [record.getMessage() for record in logs.records]
else:
PromptManagerConfig.load_from_file(path)
# Nothing is written at load.
self.assertEqual((os.stat(path).st_mtime_ns, self.read_bytes(path)), before)
@staticmethod
def read_bytes(path):
# Closed at once: Windows cannot delete the temp dir with it open.
with open(path, "rb") as handle:
return handle.read()
def state(self):
return [dict(e) for e in AutotagConfig.INSTRUCTIONS], dict(
AutotagConfig.DEFAULT_INSTRUCTIONS
)
class TestLoad(ConfigTestCase):
def test_a_new_install_has_built_in_only_for_both_engines(self):
self.load({})
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE
)
self.assertEqual(
AutotagConfig.all_instructions()[0],
{
"id": BUILTIN_INSTRUCTIONS_ID,
"name": "Built-in",
"text": DEFAULT_TAG_TEMPLATE,
},
)
def test_saved_entries_and_per_engine_defaults_load(self):
saved = [
entry("0123456789ab", "Photo", "Photo tags."),
entry("0123456789ac", "Anime", "Anime tags."),
]
self.load(
{
"instructions": saved,
"default_instructions": {"gguf": "0123456789ac", "hf": "0123456789ab"},
}
)
self.assertEqual(
self.state(), (saved, {"gguf": "0123456789ac", "hf": "0123456789ab"})
)
self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Anime tags.")
self.assertEqual(AutotagConfig.default_instructions_text("hf"), "Photo tags.")
with self.assertRaises(ValueError):
AutotagConfig.default_instructions_text("wd14-vit")
def test_a_dangling_default_falls_back_to_built_in_with_a_warning(self):
saved = [entry("0123456789ab", "Photo")]
self.load(
{
"instructions": saved,
"default_instructions": {"gguf": "ffffffffffff", "hf": "0123456789ab"},
},
warnings=1,
)
self.assertEqual(
self.state(), (saved, {"gguf": "builtin", "hf": "0123456789ab"})
)
self.assertEqual(
self.logs,
[
"autotag.defaults.default_instructions.gguf names no saved "
"instructions; using Built-in"
],
)
def test_a_damaged_list_falls_back_to_built_in_only_and_the_defaults_follow(self):
self.load(
{
"instructions": [
entry("0123456789ab", "A"),
entry("0123456789ab", "B"),
],
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1, # one warning; the defaults follow the list silently
)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
self.logs[0],
"autotag.defaults.instructions entry 2 repeats the id of entry 1; "
"using Built-in only",
)
def test_damaged_defaults_fall_back_with_a_warning(self):
self.load({"default_instructions": "builtin"}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
class TestMigration(ConfigTestCase):
CUSTOM = "My own tags, please."
def test_first_upgrade_makes_custom_template_the_default_for_both(self):
self.load({"template": self.CUSTOM})
self.assertEqual(
self.state(),
(
[entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)],
{"gguf": sha12(self.CUSTOM), "hf": sha12(self.CUSTOM)},
),
)
# Deterministic: a second load gives the same ids.
first = self.state()
self.load({"template": self.CUSTOM})
self.assertEqual(self.state(), first)
def test_a_template_equal_to_built_in_creates_no_entry(self):
self.load({"template": DEFAULT_TAG_TEMPLATE})
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_an_invalid_template_is_ignored_with_todays_warning(self):
self.load({"template": " "}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertIn("autotag.defaults.template", self.logs[0])
def test_a_stored_default_is_never_overwritten_by_the_first_upgrade(self):
self.load(
{
"template": self.CUSTOM,
"default_instructions": {"gguf": "builtin", "hf": "builtin"},
},
warnings=0,
)
self.assertEqual(
self.state(),
(
[entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)],
{"gguf": "builtin", "hf": "builtin"},
),
)
def test_the_mirror_of_a_saved_default_imports_nothing(self):
saved = [entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)]
both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"}
self.load(
{
"template": self.CUSTOM,
"instructions": saved,
"default_instructions": both,
}
)
self.assertEqual(self.state(), (saved, both))
def test_re_upgrade_imports_an_edit_made_on_an_older_build(self):
saved = [entry(sha12(self.CUSTOM), "Custom template", "Edited since.")]
both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"}
with self.assertLogs("prompt_manager.config", level="INFO") as logs:
self.load(
{
"template": self.CUSTOM,
"instructions": saved,
"default_instructions": both,
}
)
instructions, defaults = self.state()
# The text's own hash is taken by the edited entry: the next free one.
imported = hashlib.sha256((self.CUSTOM + "\n2").encode()).hexdigest()[:12]
self.assertEqual(
instructions, saved + [entry(imported, "Custom template (2)", self.CUSTOM)]
)
self.assertEqual(defaults, both)
self.assertIn(
"Imported the tagging template edited on an older version as "
"'Custom template (2)'",
"\n".join(logs.output),
)
def test_re_upgrade_into_a_full_list_imports_nothing_and_warns(self):
saved = [entry(f"{i:012x}", f"n{i}") for i in range(MAX_SAVED_INSTRUCTIONS)]
self.load({"template": self.CUSTOM, "instructions": saved}, warnings=1)
self.assertEqual(self.state()[0], saved)
self.assertIn("full", self.logs[0])
class TestGetConfig(ConfigTestCase):
def test_get_config_writes_the_lists_and_the_gguf_mirror(self):
AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"}
defaults = AutotagConfig.get_config()["defaults"]
self.assertEqual(
defaults["instructions"], [entry("0123456789ab", "Photo", "Photo tags.")]
)
self.assertEqual(
defaults["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"}
)
self.assertEqual(defaults["template"], "Photo tags.")
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
self.assertNotIn("template", AutotagConfig.get_config()["defaults"])
class TestDamagedValues(ConfigTestCase):
def test_a_non_text_default_falls_back_with_a_warning(self):
self.load({"default_instructions": {"gguf": [], "hf": "builtin"}}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_a_damaged_list_warns_once_and_migrates_the_template_afresh(self):
custom = "My own tags, please."
self.load(
{
"template": custom,
"instructions": "broken",
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1,
)
self.assertEqual(
self.state(),
(
[entry(sha12(custom), "Custom template", custom)],
{"gguf": sha12(custom), "hf": sha12(custom)},
),
)
if __name__ == "__main__":
unittest.main()