616 lines
18 KiB
Python
616 lines
18 KiB
Python
import json
|
|
import importlib.util
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from smart_metadata_reader.models import LoraInfo, MetadataBundle, ParseResult
|
|
|
|
|
|
def test_format_setting_outputs_complete_human_readable_text():
|
|
from smart_metadata_reader.settings_formatter import format_setting
|
|
|
|
result = ParseResult(
|
|
filename="001.png",
|
|
model_name="model.safetensors",
|
|
vae_name="vae.vae.safetensors",
|
|
loras=[
|
|
LoraInfo(
|
|
node_id="10",
|
|
class_type="LoraLoader",
|
|
lora_name="lora_a.safetensors",
|
|
strength_model=0.8,
|
|
strength_clip=1.0,
|
|
path=["KSampler 9.model", "LoraLoader 10"],
|
|
),
|
|
LoraInfo(
|
|
node_id="11",
|
|
class_type="LoraLoader",
|
|
lora_name="lora_b.safetensors",
|
|
strength_model=0.6,
|
|
strength_clip=0.75,
|
|
path=["KSampler 9.model", "LoraLoader 11"],
|
|
),
|
|
],
|
|
seed=123456789,
|
|
steps=30,
|
|
cfg=7.0,
|
|
sampler_name="euler",
|
|
scheduler="normal",
|
|
width=1200,
|
|
height=1600,
|
|
status_message="OK",
|
|
confidence=0.94,
|
|
partial_result={"source_format": "ComfyUI prompt/workflow"},
|
|
)
|
|
|
|
setting = format_setting(result)
|
|
|
|
assert "Filename: 001.png" in setting
|
|
assert "Source: ComfyUI prompt/workflow" in setting
|
|
assert "Model: model.safetensors" in setting
|
|
assert "VAE: vae.vae.safetensors" in setting
|
|
assert "LoRA:" in setting
|
|
assert "* lora_a.safetensors (model 0.80, clip 1.00)" in setting
|
|
assert "* lora_b.safetensors (model 0.60, clip 0.75)" in setting
|
|
assert "Seed: 123456789" in setting
|
|
assert "Steps: 30" in setting
|
|
assert "CFG: 7.0" in setting
|
|
assert "Sampler: euler" in setting
|
|
assert "Scheduler: normal" in setting
|
|
assert "Size: 1200 x 1600" in setting
|
|
assert "Status: OK" in setting
|
|
assert "Confidence: 0.94" in setting
|
|
|
|
|
|
def test_format_setting_handles_missing_fields_without_error():
|
|
from smart_metadata_reader.settings_formatter import format_setting
|
|
|
|
setting = format_setting(ParseResult())
|
|
|
|
assert "Filename: unknown" in setting
|
|
assert "Source: unknown" in setting
|
|
assert "Model: unknown" in setting
|
|
assert "VAE: unknown" in setting
|
|
assert "LoRA: none" in setting
|
|
assert "Seed: -1" in setting
|
|
assert "Steps: 0" in setting
|
|
assert "CFG: 0.0" in setting
|
|
assert "Sampler: unknown" in setting
|
|
assert "Scheduler: unknown" in setting
|
|
assert "Size: unknown" in setting
|
|
|
|
|
|
def test_format_setting_displays_unresolved_partial_result_entries():
|
|
from smart_metadata_reader.settings_formatter import format_setting
|
|
|
|
result = ParseResult(
|
|
filename="partial.png",
|
|
status_message="PARTIAL",
|
|
confidence=0.52,
|
|
partial_result={
|
|
"source_format": "ComfyUI prompt/workflow",
|
|
"unresolved": [
|
|
{
|
|
"node_id": "88",
|
|
"class_type": "UnknownConditioningRouter",
|
|
"field": "positive_conditioning",
|
|
"role": "positive",
|
|
"reason": "no conditioning adapter matched",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
setting = format_setting(result)
|
|
|
|
assert "Status: PARTIAL" in setting
|
|
assert "Confidence: 0.52" in setting
|
|
assert "Unresolved:" in setting
|
|
assert (
|
|
"* Node 88 UnknownConditioningRouter input positive_conditioning "
|
|
"role=positive reason=no conditioning adapter matched"
|
|
) in setting
|
|
|
|
|
|
def test_format_setting_displays_a1111_source_and_status():
|
|
from smart_metadata_reader.settings_formatter import format_setting
|
|
|
|
result = ParseResult(
|
|
filename="",
|
|
positive="<lora:role:0.8>, nsfw",
|
|
negative="low quality",
|
|
seed=123,
|
|
steps=25,
|
|
cfg=7.5,
|
|
sampler_name="Euler a",
|
|
width=768,
|
|
height=1024,
|
|
model_name="demo_model",
|
|
status_message="A1111 parameters fallback",
|
|
confidence=0.75,
|
|
partial_result={"source_format": "A1111 parameters"},
|
|
)
|
|
|
|
setting = format_setting(result)
|
|
|
|
assert "Source: A1111 parameters" in setting
|
|
assert "Status: A1111 parameters fallback" in setting
|
|
assert "Model: demo_model" in setting
|
|
|
|
|
|
def test_format_setting_is_plain_text_not_json():
|
|
from smart_metadata_reader.settings_formatter import format_setting
|
|
|
|
setting = format_setting(
|
|
ParseResult(
|
|
filename="plain.png",
|
|
partial_result={"source_format": "ComfyUI prompt/workflow"},
|
|
)
|
|
)
|
|
|
|
assert not setting.strip().startswith("{")
|
|
with pytest.raises(json.JSONDecodeError):
|
|
json.loads(setting)
|
|
|
|
|
|
def test_node_mappings_expose_smart_metadata_reader():
|
|
import nodes
|
|
|
|
assert nodes.NODE_CLASS_MAPPINGS["SmartMetadataReader"] is nodes.SmartMetadataReader
|
|
assert nodes.NODE_DISPLAY_NAME_MAPPINGS["SmartMetadataReader"] == "Smart Metadata Reader"
|
|
|
|
|
|
def test_plugin_root_init_exports_comfyui_mappings():
|
|
root_dir = pytest.importorskip("pathlib").Path(__file__).resolve().parents[1]
|
|
spec = importlib.util.spec_from_file_location(
|
|
"comfyui_smart_metadata_reader_plugin",
|
|
root_dir / "__init__.py",
|
|
submodule_search_locations=[str(root_dir)],
|
|
)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
finally:
|
|
sys.modules.pop(spec.name, None)
|
|
sys.modules.pop(f"{spec.name}.nodes", None)
|
|
|
|
assert "SmartMetadataReader" in module.NODE_CLASS_MAPPINGS
|
|
assert module.NODE_DISPLAY_NAME_MAPPINGS["SmartMetadataReader"] == "Smart Metadata Reader"
|
|
|
|
|
|
def test_plugin_package_import_works_without_repo_root_on_sys_path(tmp_path):
|
|
root_dir = pytest.importorskip("pathlib").Path(__file__).resolve().parents[1]
|
|
script = textwrap.dedent(
|
|
f"""
|
|
import importlib.util
|
|
import pathlib
|
|
import sys
|
|
|
|
root = pathlib.Path({str(root_dir)!r})
|
|
sys.path = [
|
|
path for path in sys.path
|
|
if pathlib.Path(path or ".").resolve() != root
|
|
]
|
|
for name in list(sys.modules):
|
|
if name == "smart_metadata_reader" or name.startswith("smart_metadata_reader."):
|
|
sys.modules.pop(name, None)
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"comfyui_custom_node_under_test",
|
|
root / "__init__.py",
|
|
submodule_search_locations=[str(root)],
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
assert "SmartMetadataReader" in module.NODE_CLASS_MAPPINGS
|
|
assert module.NODE_DISPLAY_NAME_MAPPINGS["SmartMetadataReader"] == "Smart Metadata Reader"
|
|
print("package import ok")
|
|
"""
|
|
)
|
|
|
|
completed = subprocess.run(
|
|
[sys.executable, "-c", script],
|
|
cwd=tmp_path,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert "package import ok" in completed.stdout
|
|
|
|
|
|
def test_input_types_ignores_unusable_comfyui_filename_list_and_scans_input_dir(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
import nodes
|
|
|
|
(tmp_path / "root.png").write_bytes(b"fake")
|
|
subdir = tmp_path / "sub"
|
|
subdir.mkdir()
|
|
(subdir / "a.jpg").write_bytes(b"fake")
|
|
(subdir / "b.webp").write_bytes(b"fake")
|
|
(subdir / "note.txt").write_text("ignore me")
|
|
|
|
def broken_filename_list(category):
|
|
raise KeyError(category)
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(
|
|
get_filename_list=broken_filename_list,
|
|
get_input_directory=lambda: str(tmp_path),
|
|
),
|
|
)
|
|
|
|
input_types = nodes.SmartMetadataReader.INPUT_TYPES()
|
|
|
|
assert input_types["required"]["image"][0] == [
|
|
"root.png",
|
|
"sub/a.jpg",
|
|
"sub/b.webp",
|
|
]
|
|
|
|
|
|
def test_smart_metadata_reader_input_types_include_uploadable_image(tmp_path, monkeypatch):
|
|
import nodes
|
|
|
|
(tmp_path / "a.png").write_bytes(b"fake")
|
|
(tmp_path / "c.jpg").write_bytes(b"fake")
|
|
(tmp_path / "d.jpeg").write_bytes(b"fake")
|
|
(tmp_path / "b.webp").write_bytes(b"fake")
|
|
(tmp_path / "ignored.bmp").write_bytes(b"fake")
|
|
(tmp_path / "note.txt").write_text("ignore me")
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_input_directory=lambda: str(tmp_path)),
|
|
)
|
|
|
|
input_types = nodes.SmartMetadataReader.INPUT_TYPES()
|
|
required = input_types["required"]
|
|
|
|
assert required["image"][0] == ["a.png", "b.webp", "c.jpg", "d.jpeg"]
|
|
assert required["image"][1]["image_upload"] is True
|
|
assert required["parameter_index"][0] == "INT"
|
|
assert required["parameter_index"][1]["default"] == 0
|
|
assert required["parameter_index"][1]["min"] == 0
|
|
assert required["prefer_cached_text"] == ("BOOLEAN", {"default": True})
|
|
assert required["include_raw_json"] == ("BOOLEAN", {"default": True})
|
|
assert required["max_depth"][0] == "INT"
|
|
assert required["max_depth"][1]["default"] == 40
|
|
assert required["max_depth"][1]["min"] == 1
|
|
assert required["max_depth"][1]["max"] == 200
|
|
|
|
|
|
def test_smart_metadata_reader_input_types_empty_or_missing_input_dir_is_safe(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
import nodes
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_input_directory=lambda: str(tmp_path)),
|
|
)
|
|
assert nodes.SmartMetadataReader.INPUT_TYPES()["required"]["image"][0] == []
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_input_directory=lambda: str(tmp_path / "missing")),
|
|
)
|
|
assert nodes.SmartMetadataReader.INPUT_TYPES()["required"]["image"][0] == []
|
|
|
|
|
|
def test_smart_metadata_reader_input_types_handles_input_directory_errors(monkeypatch):
|
|
import nodes
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_input_directory=lambda: (_ for _ in ()).throw(KeyError("input"))),
|
|
)
|
|
|
|
assert nodes.SmartMetadataReader.INPUT_TYPES()["required"]["image"][0] == []
|
|
|
|
|
|
def test_smart_metadata_reader_return_contract():
|
|
import nodes
|
|
|
|
node_cls = nodes.SmartMetadataReader
|
|
|
|
assert node_cls.RETURN_TYPES == (
|
|
"IMAGE",
|
|
"MASK",
|
|
"STRING",
|
|
"STRING",
|
|
"INT",
|
|
"INT",
|
|
"FLOAT",
|
|
"INT",
|
|
"INT",
|
|
"STRING",
|
|
"STRING",
|
|
"STRING",
|
|
)
|
|
assert node_cls.RETURN_NAMES == (
|
|
"image",
|
|
"mask",
|
|
"positive",
|
|
"negative",
|
|
"seed",
|
|
"steps",
|
|
"cfg",
|
|
"width",
|
|
"height",
|
|
"model_name",
|
|
"filename",
|
|
"setting",
|
|
)
|
|
assert len(node_cls.RETURN_TYPES) == len(node_cls.RETURN_NAMES)
|
|
assert hasattr(node_cls, node_cls.FUNCTION)
|
|
assert node_cls.CATEGORY
|
|
|
|
|
|
def test_smart_metadata_reader_text_output_indices_match_parser_mapping():
|
|
import nodes
|
|
|
|
node_cls = nodes.SmartMetadataReader
|
|
|
|
assert node_cls.RETURN_NAMES[0] == "image"
|
|
assert node_cls.RETURN_NAMES[1] == "mask"
|
|
assert node_cls.RETURN_NAMES[2] == "positive"
|
|
assert node_cls.RETURN_NAMES[3] == "negative"
|
|
assert node_cls.RETURN_TYPES[2] == "STRING"
|
|
assert node_cls.RETURN_TYPES[3] == "STRING"
|
|
|
|
|
|
def test_smart_metadata_reader_method_returns_expected_tuple_order(tmp_path, monkeypatch):
|
|
import nodes
|
|
|
|
image_path = tmp_path / "sample.png"
|
|
image_path.write_bytes(b"fake image bytes")
|
|
|
|
calls = {}
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_annotated_filepath=lambda image: str(image_path)),
|
|
)
|
|
|
|
def fake_read_metadata(path):
|
|
calls["read_metadata"] = path
|
|
return MetadataBundle(
|
|
filename="sample.png",
|
|
width=1200,
|
|
height=1600,
|
|
prompt_raw="{}",
|
|
workflow_raw=None,
|
|
parameters_raw=None,
|
|
source_format="ComfyUI prompt/workflow",
|
|
)
|
|
|
|
def fake_parse_metadata_bundle(
|
|
bundle,
|
|
parameter_index,
|
|
prefer_cached_text,
|
|
include_raw_json,
|
|
max_depth,
|
|
):
|
|
calls["parse"] = {
|
|
"bundle": bundle,
|
|
"parameter_index": parameter_index,
|
|
"prefer_cached_text": prefer_cached_text,
|
|
"include_raw_json": include_raw_json,
|
|
"max_depth": max_depth,
|
|
}
|
|
return ParseResult(
|
|
positive="real positive",
|
|
negative="real negative",
|
|
seed=123,
|
|
steps=30,
|
|
cfg=7.5,
|
|
width=1200,
|
|
height=1600,
|
|
model_name="model.safetensors",
|
|
filename="sample.png",
|
|
setting="Filename: sample.png\nStatus: OK",
|
|
)
|
|
|
|
monkeypatch.setattr(nodes, "read_metadata", fake_read_metadata)
|
|
monkeypatch.setattr(nodes, "parse_metadata_bundle", fake_parse_metadata_bundle)
|
|
monkeypatch.setattr(nodes, "load_image_and_mask", lambda path: ("IMAGE_TENSOR", "MASK_TENSOR", 1200, 1600))
|
|
|
|
output = nodes.SmartMetadataReader().read_metadata(
|
|
"sample.png",
|
|
parameter_index=2,
|
|
prefer_cached_text=False,
|
|
include_raw_json=False,
|
|
max_depth=12,
|
|
)
|
|
|
|
assert output == (
|
|
"IMAGE_TENSOR",
|
|
"MASK_TENSOR",
|
|
"real positive",
|
|
"real negative",
|
|
123,
|
|
30,
|
|
7.5,
|
|
1200,
|
|
1600,
|
|
"model.safetensors",
|
|
"sample.png",
|
|
"Filename: sample.png\nStatus: OK",
|
|
)
|
|
assert calls["read_metadata"] == str(image_path)
|
|
assert calls["parse"]["parameter_index"] == 2
|
|
assert calls["parse"]["prefer_cached_text"] is False
|
|
assert calls["parse"]["include_raw_json"] is False
|
|
assert calls["parse"]["max_depth"] == 12
|
|
|
|
|
|
def test_smart_metadata_reader_parse_failure_returns_failed_setting(tmp_path, monkeypatch):
|
|
import nodes
|
|
|
|
image_path = tmp_path / "broken-metadata.png"
|
|
image_path.write_bytes(b"fake image bytes")
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_annotated_filepath=lambda image: str(image_path)),
|
|
)
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"read_metadata",
|
|
lambda path: MetadataBundle(
|
|
filename="broken-metadata.png",
|
|
width=640,
|
|
height=480,
|
|
prompt_raw=None,
|
|
workflow_raw=None,
|
|
parameters_raw=None,
|
|
source_format="none",
|
|
),
|
|
)
|
|
monkeypatch.setattr(nodes, "parse_metadata_bundle", lambda *args, **kwargs: (_ for _ in ()).throw(ValueError("boom")))
|
|
monkeypatch.setattr(nodes, "load_image_and_mask", lambda path: ("IMAGE_TENSOR", "MASK_TENSOR", 640, 480))
|
|
|
|
output = nodes.SmartMetadataReader().read_metadata("broken-metadata.png", 0, True, True, 40)
|
|
|
|
assert output[:2] == ("IMAGE_TENSOR", "MASK_TENSOR")
|
|
assert output[2] == ""
|
|
assert output[3] == ""
|
|
assert output[4] == -1
|
|
assert output[5] == 0
|
|
assert output[6] == 0.0
|
|
assert output[7] == 640
|
|
assert output[8] == 480
|
|
assert output[9] == ""
|
|
assert output[10] == "broken-metadata.png"
|
|
assert "Status: FAILED" in output[11]
|
|
assert "boom" in output[11]
|
|
|
|
|
|
def test_smart_metadata_reader_image_load_failure_raises_clear_error(monkeypatch):
|
|
import nodes
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_annotated_filepath=lambda image: "missing.png"),
|
|
)
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"load_image_and_mask",
|
|
lambda path: (_ for _ in ()).throw(OSError("cannot identify image file")),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="Failed to load image 'missing.png'"):
|
|
nodes.SmartMetadataReader().read_metadata("missing.png", 0, True, True, 40)
|
|
|
|
|
|
def test_smart_metadata_reader_is_changed_tracks_file_content(tmp_path, monkeypatch):
|
|
import nodes
|
|
|
|
image_path = tmp_path / "changed.png"
|
|
image_path.write_bytes(b"first")
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_annotated_filepath=lambda image: str(image_path)),
|
|
)
|
|
|
|
first = nodes.SmartMetadataReader.IS_CHANGED("changed.png")
|
|
image_path.write_bytes(b"second")
|
|
second = nodes.SmartMetadataReader.IS_CHANGED("changed.png")
|
|
|
|
assert first
|
|
assert second
|
|
assert first != second
|
|
|
|
|
|
def test_smart_metadata_reader_is_changed_handles_missing_or_bad_paths(monkeypatch):
|
|
import nodes
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_annotated_filepath=lambda image: (_ for _ in ()).throw(KeyError("input"))),
|
|
)
|
|
|
|
value = nodes.SmartMetadataReader.IS_CHANGED("missing.png")
|
|
|
|
assert isinstance(value, str)
|
|
assert value.startswith("missing:")
|
|
|
|
|
|
def test_smart_metadata_reader_validate_inputs_checks_image_exists(tmp_path, monkeypatch):
|
|
import nodes
|
|
|
|
existing = tmp_path / "exists.png"
|
|
existing.write_bytes(b"fake")
|
|
missing = tmp_path / "missing.png"
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(get_annotated_filepath=lambda image: str(tmp_path / image)),
|
|
)
|
|
|
|
assert nodes.SmartMetadataReader.VALIDATE_INPUTS("exists.png") is True
|
|
invalid = nodes.SmartMetadataReader.VALIDATE_INPUTS("missing.png")
|
|
assert isinstance(invalid, str)
|
|
assert "Invalid image file" in invalid
|
|
assert str(missing) in invalid
|
|
|
|
|
|
def test_smart_metadata_reader_validate_inputs_falls_back_when_exists_helper_errors(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
import nodes
|
|
|
|
existing = tmp_path / "exists.png"
|
|
existing.write_bytes(b"fake")
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(
|
|
exists_annotated_filepath=lambda image: (_ for _ in ()).throw(KeyError("input")),
|
|
get_input_directory=lambda: str(tmp_path),
|
|
),
|
|
)
|
|
|
|
assert nodes.SmartMetadataReader.VALIDATE_INPUTS("exists.png") is True
|
|
assert "Invalid image file" in nodes.SmartMetadataReader.VALIDATE_INPUTS("missing.png")
|
|
|
|
|
|
def test_smart_metadata_reader_validate_inputs_uses_comfyui_exists_helper(monkeypatch):
|
|
import nodes
|
|
|
|
monkeypatch.setattr(
|
|
nodes,
|
|
"folder_paths",
|
|
SimpleNamespace(exists_annotated_filepath=lambda image: image == "ok.png"),
|
|
)
|
|
|
|
assert nodes.SmartMetadataReader.VALIDATE_INPUTS("ok.png") is True
|
|
assert "Invalid image file" in nodes.SmartMetadataReader.VALIDATE_INPUTS("bad.png")
|