1451 lines
44 KiB
Python
1451 lines
44 KiB
Python
import pytest
|
|
|
|
from core.contracts import (
|
|
CustomNodeMetadata,
|
|
DownloadSpec,
|
|
HuggingFaceFileReference,
|
|
MissingModel,
|
|
ModelCatalogEntry,
|
|
ModelMatch,
|
|
ModelReference,
|
|
ProviderUrlReference,
|
|
Resolution,
|
|
ResolvedModel,
|
|
SearchResult,
|
|
WorkflowAnalysisResult,
|
|
WorkflowModelInventory,
|
|
)
|
|
from core.custom_nodes.base import CustomNodeModelAdapter
|
|
from core.custom_nodes.registry import (
|
|
get_custom_node_adapter_for_reference,
|
|
get_custom_node_resolution_metadata,
|
|
)
|
|
from core.resolver import apply_resolution
|
|
from core.services.search_orchestrator import SearchOrchestrator
|
|
|
|
|
|
def test_search_result_normalizes_provider_aliases_and_preserves_extra_fields():
|
|
result = SearchResult.from_mapping(
|
|
{
|
|
"source": "huggingface",
|
|
"name": "model.safetensors",
|
|
"downloadUrl": "https://example.test/model.safetensors",
|
|
"sizeKB": 2,
|
|
"hashes": {"SHA256": "abc123"},
|
|
"repository": "author/model",
|
|
}
|
|
)
|
|
|
|
serialized = result.to_dict()
|
|
|
|
assert result.source == "huggingface"
|
|
assert result.filename == "model.safetensors"
|
|
assert result.download_url == "https://example.test/model.safetensors"
|
|
assert result.size == 2048
|
|
assert result.sha256 == "abc123"
|
|
assert serialized["repository"] == "author/model"
|
|
assert serialized["type"] == ""
|
|
|
|
|
|
def test_provider_url_reference_validates_and_serializes_identity():
|
|
reference = ProviderUrlReference.from_mapping(
|
|
{"modelId": "123", "modelVersionId": 456}
|
|
)
|
|
|
|
assert reference.model_id == 123
|
|
assert reference.version_id == 456
|
|
assert reference.to_dict() == {"model_id": 123, "version_id": 456}
|
|
|
|
with pytest.raises(ValueError, match="model_id must be an integer"):
|
|
ProviderUrlReference(model_id="not-a-number")
|
|
|
|
|
|
def test_huggingface_file_reference_requires_typed_repository_path():
|
|
reference = HuggingFaceFileReference(
|
|
repo="org/repo",
|
|
filename="models/model.safetensors",
|
|
)
|
|
|
|
assert reference.to_dict() == {
|
|
"repo": "org/repo",
|
|
"branch": "main",
|
|
"filename": "models/model.safetensors",
|
|
}
|
|
with pytest.raises(TypeError, match="repo must be a string"):
|
|
HuggingFaceFileReference(repo=123, filename="model.safetensors")
|
|
|
|
|
|
def test_model_catalog_entry_validates_source_fields_and_preserves_extra():
|
|
entry = ModelCatalogEntry.from_mapping(
|
|
{
|
|
"url": "https://example.test/model",
|
|
"download_url": "https://example.test/download",
|
|
"type": "checkpoints",
|
|
"directory": "checkpoints",
|
|
"size": 2048,
|
|
"canonical_name": "model",
|
|
},
|
|
filename="model.safetensors",
|
|
)
|
|
|
|
assert entry.filename == "model.safetensors"
|
|
assert entry.model_type == "checkpoints"
|
|
assert entry.extra_value("canonical_name") == "model"
|
|
assert entry.to_dict()["download_url"] == "https://example.test/download"
|
|
|
|
with pytest.raises(TypeError, match="ModelCatalogEntry url"):
|
|
ModelCatalogEntry.from_mapping(
|
|
{"url": 123},
|
|
filename="model.safetensors",
|
|
)
|
|
|
|
|
|
def test_search_result_normalizes_legacy_identity_and_file_aliases():
|
|
result = SearchResult.from_mapping(
|
|
{
|
|
"source": "civitai",
|
|
"modelName": "Example model",
|
|
"modelVersionId": 456,
|
|
"fileName": "example.safetensors",
|
|
"versionUrl": "https://example.test/models/123?modelVersionId=456",
|
|
"SHA256": "abc123",
|
|
}
|
|
)
|
|
|
|
assert result.name == "Example model"
|
|
assert result.version_id == 456
|
|
assert result.filename == "example.safetensors"
|
|
assert result.version_url.endswith("modelVersionId=456")
|
|
assert result.sha256 == "abc123"
|
|
assert "modelName" not in result.to_dict()
|
|
|
|
|
|
def test_search_result_preserves_explicit_empty_provider_hash():
|
|
result = SearchResult.from_mapping(
|
|
{"source": "huggingface", "sha256": ""}
|
|
)
|
|
|
|
assert result.sha256 == ""
|
|
assert result.to_dict()["sha256"] == ""
|
|
|
|
|
|
def test_search_result_mapping_source_override_is_strict():
|
|
with pytest.raises(TypeError, match="SearchResult source"):
|
|
SearchResult.from_mapping({"name": "model"}, source=123)
|
|
|
|
|
|
def test_search_result_normalizes_human_readable_size_to_bytes():
|
|
result = SearchResult(source="model_list", size="12 MB")
|
|
|
|
assert result.size == 12 * 1024**2
|
|
assert result.to_dict()["size"] == 12 * 1024**2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field_name",
|
|
[
|
|
"name",
|
|
"version_name",
|
|
"model_type",
|
|
"filename",
|
|
"url",
|
|
"download_url",
|
|
"base_model",
|
|
"sha256",
|
|
"details_source",
|
|
"version_url",
|
|
"match_type",
|
|
],
|
|
)
|
|
def test_search_result_rejects_non_string_canonical_text_fields(field_name):
|
|
with pytest.raises(TypeError, match=f"SearchResult {field_name}"):
|
|
SearchResult(source="test", **{field_name: 123})
|
|
|
|
with pytest.raises(TypeError, match="SearchResult download_url"):
|
|
SearchResult.from_mapping(
|
|
{"source": "test", "download_url": 123}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field_name", "value"),
|
|
[
|
|
("tags", 0),
|
|
("trained_words", "trigger"),
|
|
("images", {}),
|
|
("hashes", []),
|
|
("extra", []),
|
|
],
|
|
)
|
|
def test_search_result_rejects_non_collection_canonical_fields(field_name, value):
|
|
with pytest.raises(TypeError, match=f"SearchResult {field_name}"):
|
|
SearchResult(source="test", **{field_name: value})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field_name", "value"),
|
|
[("tags", 0), ("trained_words", "trigger"), ("images", {}), ("hashes", [])],
|
|
)
|
|
def test_search_result_mapping_adapter_rejects_non_collection_fields(
|
|
field_name,
|
|
value,
|
|
):
|
|
with pytest.raises(TypeError, match=f"SearchResult {field_name}"):
|
|
SearchResult.from_mapping({"source": "test", field_name: value})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "field_name"),
|
|
[
|
|
({"source": "test", "name": 123}, "name"),
|
|
({"source": "test", "sha256": 123}, "sha256"),
|
|
({"source": "test", "sizeKB": "invalid"}, "size"),
|
|
],
|
|
)
|
|
def test_search_result_mapping_adapter_rejects_invalid_provider_scalars(
|
|
payload,
|
|
field_name,
|
|
):
|
|
with pytest.raises((TypeError, ValueError), match=field_name):
|
|
SearchResult.from_mapping(payload)
|
|
|
|
|
|
def test_search_result_mapping_adapter_rejects_invalid_provider_source():
|
|
with pytest.raises(TypeError, match="SearchResult source"):
|
|
SearchResult.from_mapping({"source": {"name": "civitai"}})
|
|
|
|
|
|
def test_search_result_rejects_malformed_numeric_fields():
|
|
with pytest.raises(ValueError, match="SearchResult confidence"):
|
|
SearchResult(source="test", confidence="not-a-number")
|
|
with pytest.raises(ValueError, match="SearchResult size"):
|
|
SearchResult(source="test", size="not-a-size")
|
|
|
|
|
|
def test_search_result_round_trip_preserves_custom_url_modes():
|
|
compact = SearchResult(
|
|
source="civitai",
|
|
name="Compact result",
|
|
filename="compact.safetensors",
|
|
url="https://example.test/models/1",
|
|
result_mode="compact_custom_url",
|
|
custom_url=True,
|
|
)
|
|
full = SearchResult(
|
|
source="civitai",
|
|
name="Full result",
|
|
filename="full.safetensors",
|
|
url="https://example.test/models/2",
|
|
result_mode="custom_url",
|
|
custom_url=True,
|
|
)
|
|
|
|
assert SearchResult.from_mapping(compact.to_dict()).result_mode == (
|
|
"compact_custom_url"
|
|
)
|
|
assert SearchResult.from_mapping(full.to_dict()).result_mode == "custom_url"
|
|
|
|
|
|
def test_search_result_rejects_unknown_result_modes():
|
|
with pytest.raises(ValueError, match="Unsupported model result mode"):
|
|
SearchResult(source="civitai", result_mode="unknown")
|
|
|
|
|
|
def test_contract_adapters_reject_malformed_payloads():
|
|
with pytest.raises(TypeError, match="SearchResult must be an object"):
|
|
SearchResult.from_mapping(None)
|
|
with pytest.raises(ValueError, match="model identity"):
|
|
ModelReference.from_mapping({})
|
|
with pytest.raises(ValueError, match="path or filename"):
|
|
ResolvedModel.from_mapping({})
|
|
with pytest.raises(TypeError, match="model object"):
|
|
ModelMatch.from_mapping({"model": None})
|
|
|
|
|
|
def test_extra_fields_cannot_override_canonical_contract_fields():
|
|
reference = ModelReference(
|
|
node_id=1,
|
|
original_path="canonical.safetensors",
|
|
category="checkpoints",
|
|
extra={"category": "wrong"},
|
|
)
|
|
model = ResolvedModel(
|
|
path="canonical.safetensors",
|
|
filename="canonical.safetensors",
|
|
extra={"path": "wrong.safetensors"},
|
|
)
|
|
result = SearchResult(
|
|
source="civitai",
|
|
filename="canonical.safetensors",
|
|
extra={"filename": "wrong.safetensors"},
|
|
)
|
|
|
|
assert reference.to_dict()["category"] == "checkpoints"
|
|
assert model.to_dict()["path"] == "canonical.safetensors"
|
|
assert result.to_dict()["filename"] == "canonical.safetensors"
|
|
|
|
|
|
def test_extra_fields_cannot_create_empty_canonical_values():
|
|
model = ResolvedModel(filename="model.safetensors").with_extra(
|
|
path="wrong.safetensors",
|
|
category="wrong",
|
|
provider="test",
|
|
)
|
|
reference = ModelReference(
|
|
node_id=1,
|
|
original_path="model.safetensors",
|
|
extra={"subgraph_id": "wrong", "provider": "test"},
|
|
)
|
|
|
|
serialized_model = model.to_dict()
|
|
serialized_reference = reference.to_dict()
|
|
|
|
assert "path" not in serialized_model
|
|
assert "category" not in serialized_model
|
|
assert serialized_model["provider"] == "test"
|
|
assert "subgraph_id" not in serialized_reference
|
|
assert serialized_reference["provider"] == "test"
|
|
|
|
|
|
def test_resolved_model_aliases_cannot_leak_through_extra_or_raw_payloads():
|
|
model = ResolvedModel(
|
|
path="canonical.safetensors",
|
|
filename="canonical.safetensors",
|
|
raw={
|
|
"resolved_path": "wrong.safetensors",
|
|
"name": "wrong.safetensors",
|
|
},
|
|
).with_extra(
|
|
resolved_path="another-wrong.safetensors",
|
|
name="another-wrong.safetensors",
|
|
provider="scanner",
|
|
)
|
|
|
|
serialized = model.to_dict()
|
|
|
|
assert serialized["path"] == "canonical.safetensors"
|
|
assert serialized["filename"] == "canonical.safetensors"
|
|
assert "resolved_path" not in serialized
|
|
assert "name" not in serialized
|
|
assert serialized["provider"] == "scanner"
|
|
|
|
|
|
def test_search_result_extra_cannot_restore_omitted_mode_fields():
|
|
custom = SearchResult(
|
|
source="civitai",
|
|
filename="custom.safetensors",
|
|
result_mode="custom_url",
|
|
custom_url=True,
|
|
).with_extra(
|
|
confidence=99,
|
|
result_mode="search",
|
|
filename="wrong.safetensors",
|
|
provider="civitai",
|
|
)
|
|
compact = SearchResult(
|
|
source="civitai",
|
|
filename="compact.safetensors",
|
|
result_mode="compact_custom_url",
|
|
custom_url=True,
|
|
).with_extra(
|
|
confidence=99,
|
|
size=123,
|
|
type="wrong",
|
|
provider="civitai",
|
|
)
|
|
|
|
custom_payload = custom.to_dict()
|
|
compact_payload = compact.to_dict()
|
|
|
|
assert "confidence" not in custom_payload
|
|
assert "result_mode" not in custom_payload
|
|
assert custom_payload["filename"] == "custom.safetensors"
|
|
assert custom_payload["provider"] == "civitai"
|
|
assert "confidence" not in compact_payload
|
|
assert "size" not in compact_payload
|
|
assert "type" not in compact_payload
|
|
assert compact_payload["filename"] == "compact.safetensors"
|
|
|
|
|
|
def test_missing_model_extra_cannot_inject_matches_when_unset():
|
|
missing = MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
}
|
|
).with_extra(
|
|
matches=[{"bad": True}],
|
|
reference_count=99,
|
|
provider="workflow",
|
|
)
|
|
|
|
serialized = missing.to_dict()
|
|
|
|
assert "matches" not in serialized
|
|
assert serialized["reference_count"] == 1
|
|
assert serialized["provider"] == "workflow"
|
|
|
|
|
|
def test_missing_model_requires_consistent_reference_collection():
|
|
with pytest.raises(TypeError, match="all_node_refs"):
|
|
MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"all_node_refs": [
|
|
{"node_id": 1, "original_path": "missing.safetensors"},
|
|
None,
|
|
],
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="reference_count"):
|
|
MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"all_node_refs": [
|
|
{"node_id": 1, "original_path": "missing.safetensors"}
|
|
],
|
|
"reference_count": 2,
|
|
}
|
|
)
|
|
|
|
|
|
def test_missing_model_rejects_malformed_matches_in_mapping():
|
|
with pytest.raises(TypeError, match="MissingModel matches"):
|
|
MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"matches": [None],
|
|
}
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="MissingModel matches"):
|
|
MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"matches": {"bad": True},
|
|
}
|
|
)
|
|
|
|
|
|
def test_missing_model_derives_direct_reference_count():
|
|
reference = ModelReference(node_id=1, original_path="missing.safetensors")
|
|
missing = MissingModel(
|
|
reference=reference,
|
|
all_node_refs=(reference, reference),
|
|
)
|
|
|
|
assert missing.reference_count == 2
|
|
assert len(missing.all_node_refs) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["1", 1.5, True])
|
|
def test_missing_model_rejects_non_integer_reference_count(value):
|
|
reference = ModelReference(node_id=1, original_path="missing.safetensors")
|
|
|
|
with pytest.raises(TypeError, match="reference_count"):
|
|
MissingModel(reference=reference, reference_count=value)
|
|
|
|
with pytest.raises(TypeError, match="reference_count"):
|
|
MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"reference_count": value,
|
|
}
|
|
)
|
|
|
|
|
|
def test_missing_model_rejects_non_positive_reference_count():
|
|
reference = ModelReference(node_id=1, original_path="missing.safetensors")
|
|
|
|
with pytest.raises(ValueError, match="reference_count"):
|
|
MissingModel(reference=reference, reference_count=0)
|
|
|
|
with pytest.raises(ValueError, match="reference_count"):
|
|
MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"reference_count": -1,
|
|
}
|
|
)
|
|
|
|
|
|
def test_contract_boolean_adapters_do_not_use_string_truthiness():
|
|
reference = ModelReference.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "missing.safetensors",
|
|
"exists": "false",
|
|
"is_top_level": "false",
|
|
}
|
|
)
|
|
result = SearchResult.from_mapping(
|
|
{
|
|
"source": "civitai",
|
|
"custom_url": "false",
|
|
}
|
|
)
|
|
|
|
assert reference.exists is False
|
|
assert reference.is_top_level is False
|
|
assert result.custom_url is False
|
|
assert result.result_mode == "search"
|
|
|
|
metadata = CustomNodeMetadata.from_mapping(
|
|
{"is_legacy_lora_manager": "false"}
|
|
)
|
|
assert metadata.is_legacy_lora_manager is False
|
|
|
|
legacy_reference = ModelReference.from_mapping(
|
|
{
|
|
"node_id": 2,
|
|
"original_path": "legacy.safetensors",
|
|
"is_lora_v2": "false",
|
|
}
|
|
)
|
|
assert get_custom_node_adapter_for_reference(legacy_reference) is None
|
|
assert (
|
|
get_custom_node_resolution_metadata(legacy_reference).is_legacy_lora_manager
|
|
is False
|
|
)
|
|
|
|
legacy_v2_reference = ModelReference.from_mapping(
|
|
{
|
|
"node_id": 4,
|
|
"original_path": "legacy-v2.safetensors",
|
|
"is_lora_v2": "true",
|
|
}
|
|
)
|
|
assert get_custom_node_adapter_for_reference(legacy_v2_reference) is not None
|
|
assert (
|
|
get_custom_node_resolution_metadata(legacy_v2_reference).is_legacy_lora_manager
|
|
is True
|
|
)
|
|
|
|
workflow_flags = ModelReference.from_mapping(
|
|
{
|
|
"node_id": 3,
|
|
"original_path": "workflow.safetensors",
|
|
"active": "false",
|
|
"auto_download_capable": "false",
|
|
"auto_download_candidate": "false",
|
|
"connected": "false",
|
|
"input_choice_matches_value": "false",
|
|
"is_legacy_lora_manager": "false",
|
|
"is_lora_v2": "false",
|
|
"is_urn": "false",
|
|
"locate_is_top_level": "false",
|
|
"locate_via_promoted_widget": "false",
|
|
}
|
|
)
|
|
for flag in (
|
|
"active",
|
|
"auto_download_capable",
|
|
"auto_download_candidate",
|
|
"connected",
|
|
"input_choice_matches_value",
|
|
"is_legacy_lora_manager",
|
|
"is_lora_v2",
|
|
"is_urn",
|
|
"locate_is_top_level",
|
|
"locate_via_promoted_widget",
|
|
):
|
|
assert workflow_flags.extra_value(flag) is False
|
|
|
|
|
|
def test_custom_node_metadata_preserves_canonical_legacy_flag():
|
|
reference = ModelReference.from_mapping(
|
|
{
|
|
"node_id": 2,
|
|
"original_path": "old-lora",
|
|
"is_legacy_lora_manager": "true",
|
|
}
|
|
)
|
|
|
|
metadata = get_custom_node_resolution_metadata(reference)
|
|
|
|
assert metadata.adapter_id == "lora-manager"
|
|
assert metadata.is_legacy_lora_manager is True
|
|
|
|
|
|
def test_direct_contract_constructors_normalize_nested_values():
|
|
reference = ModelReference(
|
|
node_id=1,
|
|
original_path="model.safetensors",
|
|
exists="false",
|
|
is_top_level="true",
|
|
)
|
|
assert reference.exists is False
|
|
assert reference.is_top_level is True
|
|
|
|
match = ModelMatch(
|
|
model={"path": "models/model.safetensors"},
|
|
similarity="0.25",
|
|
)
|
|
assert isinstance(match.model, ResolvedModel)
|
|
assert match.similarity == 0.25
|
|
|
|
missing = MissingModel(
|
|
reference={"node_id": 1, "original_path": "missing.safetensors"},
|
|
all_node_refs=[
|
|
{"node_id": 1, "original_path": "missing.safetensors"}
|
|
],
|
|
matches=[
|
|
{"model": {"path": "models/model.safetensors"}}
|
|
],
|
|
)
|
|
assert isinstance(missing.reference, ModelReference)
|
|
assert isinstance(missing.all_node_refs[0], ModelReference)
|
|
assert isinstance(missing.matches[0], ModelMatch)
|
|
|
|
analysis = WorkflowAnalysisResult(
|
|
missing_models=[
|
|
{"node_id": 1, "original_path": "missing.safetensors"}
|
|
],
|
|
resolved_models=[
|
|
{"node_id": 2, "original_path": "loaded.safetensors"}
|
|
],
|
|
total_missing=1,
|
|
total_resolved=1,
|
|
total_models_analyzed=2,
|
|
)
|
|
assert isinstance(analysis.missing_models[0], MissingModel)
|
|
assert isinstance(analysis.resolved_models[0], ModelReference)
|
|
|
|
resolution = Resolution(
|
|
node_id=1,
|
|
widget_index=0,
|
|
resolved_path="new.safetensors",
|
|
resolved_model={"path": "new.safetensors"},
|
|
reference={"node_id": 1, "original_path": "old.safetensors"},
|
|
is_top_level="false",
|
|
)
|
|
assert isinstance(resolution.resolved_model, ResolvedModel)
|
|
assert isinstance(resolution.reference, ModelReference)
|
|
assert resolution.is_top_level is False
|
|
|
|
with pytest.raises(TypeError, match="ModelMatch model"):
|
|
ModelMatch(model=None)
|
|
with pytest.raises(TypeError, match="MissingModel reference"):
|
|
MissingModel(reference=None)
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["maybe", 2, [], {}])
|
|
def test_boolean_contracts_reject_unknown_values(value):
|
|
with pytest.raises(ValueError, match="exists"):
|
|
ModelReference.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "model.safetensors",
|
|
"exists": value,
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("value", [123, [], {}])
|
|
def test_model_reference_known_text_extras_reject_non_text_values(value):
|
|
with pytest.raises(TypeError, match="ModelReference full_path"):
|
|
ModelReference.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "model.safetensors",
|
|
"full_path": value,
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="is_top_level"):
|
|
ModelReference.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "model.safetensors",
|
|
"is_top_level": value,
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="custom_url"):
|
|
SearchResult.from_mapping(
|
|
{"source": "test", "custom_url": value}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="is_legacy_lora_manager"):
|
|
CustomNodeMetadata.from_mapping(
|
|
{"is_legacy_lora_manager": value}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="is_top_level"):
|
|
Resolution.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"original_path": "missing.safetensors",
|
|
"resolved_path": "model.safetensors",
|
|
"is_top_level": value,
|
|
}
|
|
)
|
|
|
|
|
|
def test_canonical_boolean_contracts_reject_explicit_null():
|
|
assert (
|
|
ModelReference.from_mapping(
|
|
{"node_id": 1, "original_path": "model.safetensors"}
|
|
).exists
|
|
is False
|
|
)
|
|
assert SearchResult.from_mapping({"source": "test"}).custom_url is False
|
|
assert (
|
|
CustomNodeMetadata.from_mapping({}).is_legacy_lora_manager is False
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="exists"):
|
|
ModelReference.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "model.safetensors",
|
|
"exists": None,
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="custom_url"):
|
|
SearchResult.from_mapping({"source": "test", "custom_url": None})
|
|
|
|
with pytest.raises(ValueError, match="is_legacy_lora_manager"):
|
|
CustomNodeMetadata.from_mapping(
|
|
{"is_legacy_lora_manager": None}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("value", [None, "maybe", 2, [], {}])
|
|
def test_model_reference_boolean_extras_reject_invalid_values(value):
|
|
with pytest.raises(ValueError, match="ModelReference active"):
|
|
ModelReference.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"original_path": "model.safetensors",
|
|
"active": value,
|
|
}
|
|
)
|
|
|
|
|
|
def test_lora_manager_adapter_normalizes_external_boolean_values():
|
|
from core.custom_nodes.lora_manager import analyze_references
|
|
|
|
references = analyze_references(
|
|
{
|
|
"id": 1,
|
|
"type": "LoraLoaderV2",
|
|
"widgets_values": [
|
|
"",
|
|
"",
|
|
[{"name": "character", "active": None, "strength": 1.0}],
|
|
],
|
|
},
|
|
available_models=[],
|
|
is_active=False,
|
|
get_widget_name_hint=lambda _node, _index: "lora_name",
|
|
)
|
|
|
|
assert references[0].extra_value("active") is True
|
|
assert references[0].extra_value("connected") is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("factory", "payload", "message"),
|
|
[
|
|
(
|
|
ModelReference.from_mapping,
|
|
{"node_id": 1, "original_path": 123},
|
|
"ModelReference original_path",
|
|
),
|
|
(
|
|
ResolvedModel.from_mapping,
|
|
{"path": 123},
|
|
"ResolvedModel path",
|
|
),
|
|
(
|
|
ResolvedModel.from_mapping,
|
|
{"path": 0, "resolved_path": "valid.safetensors"},
|
|
"ResolvedModel path",
|
|
),
|
|
],
|
|
)
|
|
def test_model_contracts_reject_non_string_text_fields(factory, payload, message):
|
|
with pytest.raises(TypeError, match=message):
|
|
factory(payload)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("factory", "payload", "message"),
|
|
[
|
|
(
|
|
ModelReference.from_mapping,
|
|
{"node_id": True, "original_path": "model.safetensors"},
|
|
"ModelReference node_id",
|
|
),
|
|
(
|
|
ModelReference.from_mapping,
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": "0",
|
|
"original_path": "model.safetensors",
|
|
},
|
|
"ModelReference widget_index",
|
|
),
|
|
(
|
|
SearchResult.from_mapping,
|
|
{"source": "test", "model_id": False},
|
|
"SearchResult model_id",
|
|
),
|
|
(
|
|
Resolution.from_mapping,
|
|
{
|
|
"node_id": True,
|
|
"widget_index": 0,
|
|
"resolved_path": "model.safetensors",
|
|
},
|
|
"Resolution node_id",
|
|
),
|
|
],
|
|
)
|
|
def test_identifier_contracts_reject_invalid_types(factory, payload, message):
|
|
with pytest.raises(TypeError, match=message):
|
|
factory(payload)
|
|
|
|
|
|
def test_identifier_contracts_normalize_string_ids():
|
|
reference = ModelReference.from_mapping(
|
|
{
|
|
"node_id": " 12 ",
|
|
"widget_index": 0,
|
|
"original_path": "model.safetensors",
|
|
}
|
|
)
|
|
result = SearchResult.from_mapping(
|
|
{"source": "test", "model_id": " 12 ", "version_id": " 34 "}
|
|
)
|
|
|
|
assert reference.node_id == "12"
|
|
assert result.model_id == "12"
|
|
assert result.version_id == "34"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("factory", "payload", "message"),
|
|
[
|
|
(
|
|
ModelMatch.from_mapping,
|
|
{
|
|
"model": {"path": "models/model.safetensors"},
|
|
"filename": 123,
|
|
},
|
|
"ModelMatch filename",
|
|
),
|
|
(
|
|
CustomNodeMetadata.from_mapping,
|
|
{"adapter_id": 123},
|
|
"CustomNodeMetadata adapter_id",
|
|
),
|
|
(
|
|
CustomNodeMetadata.from_mapping,
|
|
{"original_identity": 123},
|
|
"CustomNodeMetadata original_identity",
|
|
),
|
|
(
|
|
Resolution.from_mapping,
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"resolved_path": 123,
|
|
},
|
|
"Resolution resolved_path",
|
|
),
|
|
(
|
|
Resolution.from_mapping,
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"resolved_path": "model.safetensors",
|
|
"category": 123,
|
|
},
|
|
"Resolution category",
|
|
),
|
|
],
|
|
)
|
|
def test_nested_contracts_reject_non_string_canonical_fields(
|
|
factory,
|
|
payload,
|
|
message,
|
|
):
|
|
with pytest.raises((TypeError, ValueError), match=message):
|
|
factory(payload)
|
|
|
|
|
|
def test_contract_collections_reject_falsey_non_arrays():
|
|
reference = ModelReference(node_id=1, original_path="model.safetensors")
|
|
|
|
with pytest.raises(TypeError, match="all_node_refs"):
|
|
MissingModel(reference=reference, all_node_refs=0)
|
|
with pytest.raises(TypeError, match="missing_models"):
|
|
WorkflowAnalysisResult(missing_models=0)
|
|
with pytest.raises(TypeError, match="available_models"):
|
|
WorkflowModelInventory(available_models=0)
|
|
|
|
|
|
@pytest.mark.parametrize("field_name", ["similarity", "confidence"])
|
|
def test_model_match_rejects_malformed_numeric_fields(field_name):
|
|
with pytest.raises(ValueError, match=field_name):
|
|
ModelMatch.from_mapping(
|
|
{
|
|
"model": {"path": "models/model.safetensors"},
|
|
field_name: "not-a-number",
|
|
}
|
|
)
|
|
|
|
|
|
def test_model_contracts_are_not_mapping_compatibility_wrappers():
|
|
reference = ModelReference(node_id=1, original_path="model.safetensors")
|
|
model = ResolvedModel(path="models/model.safetensors")
|
|
|
|
with pytest.raises(TypeError):
|
|
reference["original_path"]
|
|
with pytest.raises(TypeError):
|
|
model["path"]
|
|
|
|
|
|
def test_contract_collections_are_immutable_and_thaw_at_json_boundary():
|
|
result = SearchResult(
|
|
source="civitai",
|
|
tags=["tag-a"],
|
|
hashes={"SHA256": "abc123"},
|
|
extra={"nested": {"values": ["one"]}},
|
|
)
|
|
|
|
assert result.tags == ("tag-a",)
|
|
serialized = result.to_dict()
|
|
assert serialized["tags"] == ["tag-a"]
|
|
assert serialized["hashes"] == {"SHA256": "abc123"}
|
|
assert serialized["nested"]["values"] == ["one"]
|
|
assert result.extra_value("nested")["values"] == ("one",)
|
|
|
|
with pytest.raises(AttributeError):
|
|
result.tags.append("tag-b")
|
|
with pytest.raises(TypeError):
|
|
result.hashes["MD5"] = "hash"
|
|
with pytest.raises(TypeError):
|
|
result.extra_value("nested")["values"] = ("two",)
|
|
|
|
model = ResolvedModel.from_mapping(
|
|
{
|
|
"path": "models/model.safetensors",
|
|
"extra": {"nested": {"values": ["one"]}},
|
|
}
|
|
)
|
|
assert model.to_dict()["extra"]["nested"]["values"] == ["one"]
|
|
with pytest.raises(TypeError):
|
|
model.raw["path"] = "other.safetensors"
|
|
|
|
|
|
def test_workflow_inventory_and_custom_node_metadata_are_typed_and_immutable():
|
|
model = ResolvedModel(path="models/model.safetensors")
|
|
reference = ModelReference(
|
|
node_id=1,
|
|
original_path="model.safetensors",
|
|
category="checkpoints",
|
|
)
|
|
inventory = WorkflowModelInventory(
|
|
available_models=[model],
|
|
model_refs=[reference],
|
|
)
|
|
assert inventory.available_models == (model,)
|
|
assert inventory.model_refs == (reference,)
|
|
with pytest.raises(AttributeError):
|
|
inventory.available_models.append(model)
|
|
|
|
metadata = get_custom_node_resolution_metadata(
|
|
ModelReference.from_mapping(
|
|
{
|
|
"node_id": 2,
|
|
"original_path": "old-lora",
|
|
"custom_node_adapter": "lora-manager",
|
|
"custom_node_original_identity": "old-lora",
|
|
}
|
|
)
|
|
)
|
|
assert isinstance(metadata, CustomNodeMetadata)
|
|
assert metadata.adapter_id == "lora-manager"
|
|
assert metadata.original_identity == "old-lora"
|
|
|
|
adapter = CustomNodeModelAdapter(
|
|
adapter_id="test",
|
|
node_types=["TestLoader"],
|
|
widget_categories={0: "loras"},
|
|
)
|
|
assert adapter.node_types == ("TestLoader",)
|
|
assert adapter.widget_categories == {0: "loras"}
|
|
with pytest.raises(TypeError):
|
|
adapter.widget_categories[1] = "checkpoints"
|
|
|
|
with pytest.raises(TypeError, match="node_types"):
|
|
CustomNodeModelAdapter(
|
|
adapter_id="test",
|
|
node_types=0,
|
|
widget_categories={},
|
|
)
|
|
with pytest.raises(TypeError, match="widget_categories"):
|
|
CustomNodeModelAdapter(
|
|
adapter_id="test",
|
|
node_types=("TestLoader",),
|
|
widget_categories=[],
|
|
)
|
|
with pytest.raises(TypeError, match="update_model_path"):
|
|
CustomNodeModelAdapter(
|
|
adapter_id="test",
|
|
node_types=("TestLoader",),
|
|
widget_categories={},
|
|
update_model_path="not-callable",
|
|
)
|
|
|
|
|
|
def test_search_orchestrator_normalizes_and_serializes_typed_result():
|
|
typed = SearchResult(
|
|
source="civitai",
|
|
name="Model",
|
|
filename="model.safetensors",
|
|
download_url="https://example.test/model",
|
|
size=4096,
|
|
)
|
|
result = SearchOrchestrator.normalize_search_result(
|
|
"civitai",
|
|
typed,
|
|
)
|
|
|
|
assert result is typed
|
|
assert result.source == "civitai"
|
|
assert SearchOrchestrator.serialize_search_result(result)["size"] == 4096
|
|
assert SearchOrchestrator.serialize_search_result(result)["download_url"] == (
|
|
"https://example.test/model"
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="untyped result"):
|
|
SearchOrchestrator.normalize_search_result(
|
|
"model_list",
|
|
{"filename": "legacy.safetensors"},
|
|
)
|
|
|
|
|
|
def test_missing_model_and_match_use_typed_contracts():
|
|
missing = MissingModel.from_mapping(
|
|
{
|
|
"node_id": 4,
|
|
"node_type": "CheckpointLoaderSimple",
|
|
"widget_index": 0,
|
|
"original_path": "missing.safetensors",
|
|
"category": "checkpoints",
|
|
"exists": False,
|
|
"all_node_refs": [
|
|
{
|
|
"node_id": 4,
|
|
"node_type": "CheckpointLoaderSimple",
|
|
"widget_index": 0,
|
|
"original_path": "missing.safetensors",
|
|
"category": "checkpoints",
|
|
"exists": False,
|
|
}
|
|
],
|
|
"matches": [
|
|
{
|
|
"model": {
|
|
"path": "models/checkpoint.safetensors",
|
|
"filename": "checkpoint.safetensors",
|
|
"category": "checkpoints",
|
|
},
|
|
"filename": "checkpoint.safetensors",
|
|
"similarity": 0.9,
|
|
"confidence": 90,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert isinstance(missing.reference, ModelReference)
|
|
assert isinstance(missing.matches[0], ModelMatch)
|
|
assert isinstance(missing.matches[0].model, ResolvedModel)
|
|
assert missing.all_node_refs[0].node_id == 4
|
|
assert missing.matches[0].model.path == "models/checkpoint.safetensors"
|
|
assert missing.to_dict()["matches"][0]["model"]["path"] == (
|
|
"models/checkpoint.safetensors"
|
|
)
|
|
|
|
|
|
def test_model_match_keeps_typed_nested_resolved_model():
|
|
model = ResolvedModel(
|
|
path="models/checkpoint.safetensors",
|
|
filename="checkpoint.safetensors",
|
|
)
|
|
|
|
match = ModelMatch.from_mapping(
|
|
{
|
|
"model": model,
|
|
"filename": "checkpoint.safetensors",
|
|
"similarity": 0.9,
|
|
}
|
|
)
|
|
|
|
assert match.model is model
|
|
assert match.model.path == "models/checkpoint.safetensors"
|
|
|
|
|
|
def test_workflow_analysis_result_serializes_typed_models():
|
|
missing = MissingModel.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"original_path": "missing.safetensors",
|
|
"category": "checkpoints",
|
|
"exists": False,
|
|
}
|
|
)
|
|
result = WorkflowAnalysisResult(
|
|
missing_models=(missing,),
|
|
resolved_models=(
|
|
ModelReference(
|
|
node_id=2,
|
|
widget_index=0,
|
|
original_path="loaded.safetensors",
|
|
category="checkpoints",
|
|
exists=True,
|
|
),
|
|
),
|
|
total_missing=1,
|
|
total_resolved=1,
|
|
total_models_analyzed=2,
|
|
).to_dict()
|
|
|
|
assert result["total_missing"] == 1
|
|
assert result["missing_models"][0]["original_path"] == "missing.safetensors"
|
|
assert result["resolved_models"][0]["matches"] == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field_name, value, error_type",
|
|
[
|
|
("total_missing", "1", TypeError),
|
|
("total_resolved", 1.5, TypeError),
|
|
("total_models_analyzed", -1, ValueError),
|
|
],
|
|
)
|
|
def test_workflow_analysis_result_requires_non_negative_integer_counts(
|
|
field_name, value, error_type
|
|
):
|
|
with pytest.raises(error_type, match=field_name):
|
|
WorkflowAnalysisResult(**{field_name: value})
|
|
|
|
|
|
def test_workflow_analysis_result_counts_match_model_collections():
|
|
missing = MissingModel.from_mapping(
|
|
{"node_id": 1, "original_path": "missing.safetensors"}
|
|
)
|
|
resolved = ModelReference(node_id=2, original_path="loaded.safetensors")
|
|
|
|
with pytest.raises(ValueError, match="total_missing"):
|
|
WorkflowAnalysisResult(
|
|
missing_models=(missing,),
|
|
total_missing=0,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="total_resolved"):
|
|
WorkflowAnalysisResult(
|
|
resolved_models=(resolved,),
|
|
total_resolved=0,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="total_models_analyzed"):
|
|
WorkflowAnalysisResult(
|
|
missing_models=(missing,),
|
|
total_missing=1,
|
|
total_models_analyzed=0,
|
|
)
|
|
|
|
|
|
def test_download_spec_normalizes_mapping_and_keeps_expected_hash_in_metadata():
|
|
spec = DownloadSpec.from_mapping(
|
|
{
|
|
"download_url": " https://example.test/model.safetensors ",
|
|
"name": " model.safetensors ",
|
|
"category": "checkpoints",
|
|
"expected_sha256": " abc123 ",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
}
|
|
)
|
|
|
|
kwargs = spec.to_kwargs()
|
|
|
|
assert spec.url == "https://example.test/model.safetensors"
|
|
assert spec.filename == "model.safetensors"
|
|
assert spec.expected_sha256 == "abc123"
|
|
assert kwargs["headers"] == {"Authorization": "Bearer token"}
|
|
assert kwargs["metadata"]["sha256"] == "abc123"
|
|
|
|
|
|
def test_download_spec_uses_sha256_alias_when_expected_hash_is_blank():
|
|
spec = DownloadSpec.from_mapping(
|
|
{
|
|
"url": "https://example.test/model.safetensors",
|
|
"filename": "model.safetensors",
|
|
"category": "checkpoints",
|
|
"expected_sha256": " ",
|
|
"sha256": " alias-hash ",
|
|
}
|
|
)
|
|
|
|
assert spec.expected_sha256 == "alias-hash"
|
|
assert spec.to_kwargs()["metadata"]["sha256"] == "alias-hash"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field_name, value",
|
|
[
|
|
("url", 123),
|
|
("filename", 456),
|
|
("category", 789),
|
|
("subfolder", 123),
|
|
("base_directory", 456),
|
|
],
|
|
)
|
|
def test_download_spec_rejects_non_string_text_fields(field_name, value):
|
|
payload = {
|
|
"url": "https://example.test/model.safetensors",
|
|
"filename": "model.safetensors",
|
|
"category": "checkpoints",
|
|
"subfolder": "",
|
|
"base_directory": "",
|
|
}
|
|
payload[field_name] = value
|
|
|
|
with pytest.raises(TypeError, match=rf"DownloadSpec {field_name}"):
|
|
DownloadSpec(**payload)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field_name, value, alias_key",
|
|
[
|
|
("url", 0, "download_url"),
|
|
("filename", False, "name"),
|
|
("category", 0, None),
|
|
("subfolder", 0, None),
|
|
("base_directory", False, None),
|
|
],
|
|
)
|
|
def test_download_spec_mapping_rejects_falsey_non_string_text_fields(
|
|
field_name, value, alias_key
|
|
):
|
|
payload = {
|
|
"url": "https://example.test/model.safetensors",
|
|
"filename": "model.safetensors",
|
|
"category": "checkpoints",
|
|
}
|
|
payload[field_name] = value
|
|
if alias_key:
|
|
payload[alias_key] = (
|
|
"https://example.test/model.safetensors"
|
|
if alias_key == "download_url"
|
|
else "model.safetensors"
|
|
)
|
|
|
|
with pytest.raises(TypeError, match=rf"DownloadSpec {field_name}"):
|
|
DownloadSpec.from_mapping(payload)
|
|
|
|
|
|
def test_download_spec_rejects_non_string_headers():
|
|
with pytest.raises(TypeError, match="DownloadSpec headers"):
|
|
DownloadSpec(
|
|
url="https://example.test/model.safetensors",
|
|
filename="model.safetensors",
|
|
category="checkpoints",
|
|
headers={"X-Test": 123},
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="DownloadSpec headers"):
|
|
DownloadSpec(
|
|
url="https://example.test/model.safetensors",
|
|
filename="model.safetensors",
|
|
category="checkpoints",
|
|
headers=["invalid"],
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="DownloadSpec headers"):
|
|
DownloadSpec.from_mapping(
|
|
{
|
|
"url": "https://example.test/model.safetensors",
|
|
"filename": "model.safetensors",
|
|
"category": "checkpoints",
|
|
"headers": [],
|
|
}
|
|
)
|
|
|
|
|
|
def test_download_spec_rejects_non_mapping_metadata():
|
|
with pytest.raises(TypeError, match="DownloadSpec metadata"):
|
|
DownloadSpec(
|
|
url="https://example.test/model.safetensors",
|
|
filename="model.safetensors",
|
|
category="checkpoints",
|
|
metadata=[],
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="DownloadSpec metadata"):
|
|
DownloadSpec.from_mapping(
|
|
{
|
|
"url": "https://example.test/model.safetensors",
|
|
"filename": "model.safetensors",
|
|
"category": "checkpoints",
|
|
"metadata": [],
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, 123, True, [], {}])
|
|
def test_download_spec_rejects_non_string_expected_sha256(value):
|
|
with pytest.raises(TypeError, match="DownloadSpec expected_sha256"):
|
|
DownloadSpec(
|
|
url="https://example.test/model.safetensors",
|
|
filename="model.safetensors",
|
|
category="checkpoints",
|
|
expected_sha256=value,
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="DownloadSpec expected_sha256"):
|
|
DownloadSpec.from_mapping(
|
|
{
|
|
"url": "https://example.test/model.safetensors",
|
|
"filename": "model.safetensors",
|
|
"category": "checkpoints",
|
|
"expected_sha256": value,
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload, message",
|
|
[
|
|
({"filename": "model.safetensors", "category": "checkpoints"}, "URL"),
|
|
({"url": "https://example.test/model.safetensors", "category": "checkpoints"}, "filename"),
|
|
({"url": "https://example.test/model.safetensors", "filename": "model.safetensors"}, "category"),
|
|
],
|
|
)
|
|
def test_download_spec_requires_core_fields(payload, message):
|
|
with pytest.raises(ValueError, match=message):
|
|
DownloadSpec.from_mapping(payload)
|
|
|
|
|
|
def test_resolution_uses_model_reference_and_preserves_workflow_context():
|
|
resolution = Resolution.from_mapping(
|
|
{
|
|
"node_id": 7,
|
|
"widget_index": 1,
|
|
"original_path": "old.safetensors",
|
|
"resolved_path": "new.safetensors",
|
|
"category": "checkpoints",
|
|
"subgraph_id": "group-1",
|
|
"is_top_level": False,
|
|
"nested_key": "model",
|
|
}
|
|
)
|
|
|
|
resolution.validate()
|
|
resolution = resolution.with_custom_node_metadata(
|
|
{"custom_node_adapter": "test"}
|
|
)
|
|
|
|
assert isinstance(resolution.reference, ModelReference)
|
|
assert resolution.reference.original_path == "old.safetensors"
|
|
assert resolution.node_id == 7
|
|
assert resolution.widget_index == 1
|
|
assert resolution.subgraph_id == "group-1"
|
|
assert resolution.nested_key == "model"
|
|
assert isinstance(resolution.custom_node_metadata, CustomNodeMetadata)
|
|
assert resolution.custom_node_metadata.adapter_id == "test"
|
|
|
|
|
|
def test_resolution_accepts_path_from_resolved_model():
|
|
resolution = Resolution.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"resolved_model": {"path": "models/checkpoint.safetensors"},
|
|
}
|
|
)
|
|
|
|
resolution.validate()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": "0",
|
|
"resolved_path": "new.safetensors",
|
|
},
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": -1,
|
|
"resolved_path": "new.safetensors",
|
|
},
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"resolved_path": 123,
|
|
},
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"resolved_model": {"path": 123},
|
|
},
|
|
],
|
|
)
|
|
def test_resolution_rejects_invalid_field_types(payload):
|
|
with pytest.raises((TypeError, ValueError)):
|
|
Resolution.from_mapping(payload).validate()
|
|
|
|
|
|
def test_apply_resolution_accepts_typed_resolution():
|
|
workflow = {
|
|
"nodes": [
|
|
{
|
|
"id": 1,
|
|
"type": "CheckpointLoaderSimple",
|
|
"widgets_values": ["old.safetensors"],
|
|
}
|
|
]
|
|
}
|
|
|
|
resolution = Resolution.from_mapping(
|
|
{
|
|
"node_id": 1,
|
|
"widget_index": 0,
|
|
"resolved_path": "new.safetensors",
|
|
"category": "checkpoints",
|
|
"resolved_model": {
|
|
"path": "new.safetensors",
|
|
"base_directory": "models",
|
|
},
|
|
}
|
|
)
|
|
updated = apply_resolution(workflow, [resolution])
|
|
|
|
assert updated["nodes"][0]["widgets_values"] == ["new.safetensors"]
|
|
|
|
|
|
def test_apply_resolution_skips_invalid_payload_without_raising():
|
|
workflow = {"nodes": []}
|
|
|
|
updated = apply_resolution(workflow, [Resolution()])
|
|
|
|
assert updated == workflow
|