Files
wildminder-ComfyUI-VibeVoice/tests/test_config_name_normalization.py
WildAi 08df29df25 feat: auto-detect config_name, drop VibeVoice-Large (v2.7.0)
Config auto-detection:
- config_detect: read the checkpoint embedding shape as an architecture
  fingerprint (header-only for safetensors, reuses the open GGUF reader);
  7B=[152064,3584], 1.5B=[151936,1536]; orientation-agnostic for
  shape-reversed GGUF files
- Auto-detect is the new default config_name; resolves the family before
  any heavy load, or fails fast with an actionable error (.bin/.pt and
  unknown families cannot be fingerprinted)
- an explicit config_name that contradicts the weights self-corrects to
  the detected family with one WARNING (reconcile_config)
- loader: friendly shape pre-check in _apply_state_dict names the
  offending tensors and hints at config_name instead of torch's raw
  size-mismatch RuntimeError

Dropdown dedup:
- VibeVoice-Large removed from config_name options (duplicate of 7B);
  kept as a legacy alias so saved workflows still load (normalize at node
  + loader entry; validate_inputs(**kwargs) override makes core skip its
  combo-membership check)
- node resolves Auto-detect before computing the cache identity so the
  request key matches the consumer's bundle-derived key (no per-run churn)

Console noise:
- demote ~30 internal INFO logs to DEBUG across loaders/patcher/registry
- drop two stray tie_weights prints; tied lm_head.weight no longer warned
  as missing (expected under tie_word_embeddings)

Tests: 1060 passed / 5 pre-existing failures / 4 skipped
2026-08-27 20:33:19 +03:00

322 lines
12 KiB
Python

"""Tests for config_name de-duplication + legacy alias normalization.
Plan 2026-08-27 (Phase 1): 'VibeVoice-Large' was removed from the dropdown
(it mapped to the exact same packaged config as 'VibeVoice-7B'), but saved
workflows carrying the removed value must keep loading via the alias map.
"""
import inspect
from unittest.mock import MagicMock, patch
import pytest
from ComfyUI_VibeVoice.modules.external_loader import (
AUTO_CONFIG_NAME,
ASR_CONFIG_NAMES,
EXTERNAL_CONFIG_OPTIONS,
_get_packaged_config_path,
is_asr_config_name,
normalize_config_name,
)
from ComfyUI_VibeVoice.nodes.external_loader_node import VibeVoiceExternalLoaderNode
class TestNormalizeConfigName:
"""normalize_config_name(): alias map + passthrough semantics."""
def test_legacy_large_maps_to_7b(self):
assert normalize_config_name("VibeVoice-Large") == "VibeVoice-7B"
def test_alias_lookup_is_case_insensitive(self):
assert normalize_config_name("vibevoice-large") == "VibeVoice-7B"
assert normalize_config_name("VIBEVOICE-LARGE") == "VibeVoice-7B"
def test_current_options_pass_through_unchanged(self):
for name in EXTERNAL_CONFIG_OPTIONS:
assert normalize_config_name(name) == name
def test_unknown_value_returned_as_is(self):
assert normalize_config_name("Not-A-Model") == "Not-A-Model"
def test_empty_and_none_safe(self):
assert normalize_config_name("") == ""
assert normalize_config_name(None) is None
class TestDropdownDeDuplication:
"""The visible option list no longer contains the ambiguous alias."""
def test_large_not_in_options(self):
assert "VibeVoice-Large" not in EXTERNAL_CONFIG_OPTIONS
def test_7b_still_in_options(self):
assert "VibeVoice-7B" in EXTERNAL_CONFIG_OPTIONS
assert "VibeVoice-1.5B" in EXTERNAL_CONFIG_OPTIONS
def test_large_has_no_packaged_default_anymore(self):
assert _get_packaged_config_path("VibeVoice-Large") == ""
def test_7b_still_maps_to_large_config_file(self):
# The packaged FILE keeps its historical name; only the option was
# removed. Pin the mapping so a rename accident breaks the build.
path = _get_packaged_config_path("VibeVoice-7B")
assert path.replace("\\", "/").endswith(
"src/vibevoice/configs/default_VibeVoice-Large_config.json"
)
class TestNodeValidateInputs:
"""validate_inputs override: accepts legacy aliases, rejects junk.
The **kwargs signature is load-bearing: ComfyUI core's execution.py skips
its built-in combo-membership check when the node's validate function has
var-keywords, which is what lets saved 'VibeVoice-Large' workflows queue.
"""
def test_signature_has_varkw(self):
spec = inspect.getfullargspec(VibeVoiceExternalLoaderNode.validate_inputs)
assert spec.varkw is not None
def test_accepts_legacy_alias(self):
assert VibeVoiceExternalLoaderNode.validate_inputs(
config_name="VibeVoice-Large"
) is True
def test_accepts_current_options(self):
for name in EXTERNAL_CONFIG_OPTIONS:
assert VibeVoiceExternalLoaderNode.validate_inputs(
config_name=name
) is True
def test_rejects_unknown_with_message(self):
result = VibeVoiceExternalLoaderNode.validate_inputs(config_name="bogus")
assert isinstance(result, str)
assert "bogus" in result
assert "VibeVoice-7B" in result
class TestExecuteNormalization:
"""execute() normalizes BEFORE identity computation (plan F11/D1)."""
def test_legacy_alias_reaches_loader_as_7b(self):
fake_bundle = {"model": MagicMock()}
with patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
return_value=fake_bundle,
) as mock_load, patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
return_value="/fake/path/model.safetensors",
):
VibeVoiceExternalLoaderNode.execute(
model_file="model.safetensors",
config_name="VibeVoice-Large",
attention_mode="sdpa",
quantize_llm_4bit=False,
dtype="auto",
)
assert mock_load.call_args[1]["config_name"] == "VibeVoice-7B"
def test_identity_computed_with_normalized_name(self):
"""Legacy and modern workflows must produce the same cache identity."""
fake_bundle = {"model": MagicMock()}
with patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
return_value=fake_bundle,
), patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
return_value="/fake/path/model.safetensors",
), patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.identity_for_external",
return_value="external:key",
) as mock_identity:
VibeVoiceExternalLoaderNode.execute(
model_file="model.safetensors",
config_name="VibeVoice-Large",
attention_mode="sdpa",
quantize_llm_4bit=False,
dtype="auto",
)
assert mock_identity.call_args[0][1] == "VibeVoice-7B"
class TestLoaderEntryNormalization:
"""load_external_vibevoice_model normalizes at its own entry (defense in
depth: direct callers/tests bypass the node)."""
def test_tts_entry_normalizes_before_dispatch(self, tmp_path):
"""The ASR dispatch gate sees the NORMALIZED name."""
fake_weights = tmp_path / "model.safetensors"
fake_weights.write_bytes(b"not a real checkpoint")
_captured = []
with patch(
"ComfyUI_VibeVoice.modules.external_loader.is_asr_config_name",
wraps=lambda name: (_captured.append(name), False)[1],
), patch(
"ComfyUI_VibeVoice.modules.external_loader.load_external_vibevoice_asr_model",
):
try:
from ComfyUI_VibeVoice.modules.external_loader import (
load_external_vibevoice_model,
)
load_external_vibevoice_model(
weight_path=str(fake_weights),
config_name="VibeVoice-Large",
)
except Exception:
# Downstream loading will fail on the fake file — the
# assertion below only needs the dispatch-gate capture.
pass
assert _captured == ["VibeVoice-7B"]
def test_asr_entry_normalizes_idempotently(self, tmp_path):
"""The ASR function's own normalization is a safe no-op on canonical
names and maps aliases when called directly."""
fake_weights = tmp_path / "asr.safetensors"
fake_weights.write_bytes(b"not a real checkpoint")
captured = []
def _fake_resolve(weight_path, config_name):
captured.append(config_name)
raise RuntimeError("stop here")
with patch(
"ComfyUI_VibeVoice.modules.external_loader._load_weight_state_dict",
return_value={},
), patch(
"ComfyUI_VibeVoice.modules.external_loader.resolve_sidecar_config",
side_effect=_fake_resolve,
):
from ComfyUI_VibeVoice.modules.external_loader import (
load_external_vibevoice_asr_model,
)
with pytest.raises(RuntimeError, match="stop here"):
load_external_vibevoice_asr_model(
weight_path=str(fake_weights),
config_name="VibeVoice-ASR",
)
assert captured == ["VibeVoice-ASR"]
class TestAutoDetectOption:
"""Step 4.1: the Auto-detect sentinel option (plan 2026-08-27, D6)."""
def test_options_content_and_order(self):
assert EXTERNAL_CONFIG_OPTIONS == [
"Auto-detect",
"VibeVoice-1.5B",
"VibeVoice-7B",
"VibeVoice-Realtime-0.5B",
"VibeVoice-ASR",
]
def test_auto_constant_is_first_option(self):
assert AUTO_CONFIG_NAME == "Auto-detect"
assert EXTERNAL_CONFIG_OPTIONS[0] == AUTO_CONFIG_NAME
def test_auto_is_not_asr(self):
assert is_asr_config_name("Auto-detect") is False
assert AUTO_CONFIG_NAME not in ASR_CONFIG_NAMES
def test_normalize_passes_auto_through(self):
assert normalize_config_name(AUTO_CONFIG_NAME) == AUTO_CONFIG_NAME
def test_validate_inputs_accepts_auto(self):
assert VibeVoiceExternalLoaderNode.validate_inputs(
config_name=AUTO_CONFIG_NAME
) is True
def test_schema_default_is_auto_detect(self):
with patch(
"ComfyUI_VibeVoice.nodes.external_loader_node."
"list_external_model_files",
return_value=["fake.safetensors"],
):
schema = VibeVoiceExternalLoaderNode.define_schema()
cfg = next(i for i in schema.inputs if i.id == "config_name")
assert cfg.default == "Auto-detect"
assert cfg.options[0] == "Auto-detect"
class TestNodeAutoDetectResolution:
"""execute() resolves Auto-detect BEFORE the identity is computed.
The consumer (generation.py) keys its patcher cache off the bundle's
recorded model_name, so the node's request key must carry the same
resolved name — otherwise the unload-before-load gate and patcher cache
churn on every run (deviation from plan D7; see implementation notes).
"""
def _execute(self, config_name, resolve_side_effect):
fake_bundle = {"model": MagicMock()}
with patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
return_value=fake_bundle,
) as mock_load, patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
return_value="/fake/path/model.safetensors",
), patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.resolve_auto_config_name",
side_effect=resolve_side_effect,
) as mock_resolve, patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.identity_for_external",
return_value="external:key",
) as mock_identity:
result = VibeVoiceExternalLoaderNode.execute(
model_file="model.safetensors",
config_name=config_name,
attention_mode="sdpa",
quantize_llm_4bit=False,
dtype="auto",
)
return result, mock_load, mock_resolve, mock_identity
def test_auto_resolved_before_identity(self):
_, mock_load, mock_resolve, mock_identity = self._execute(
"Auto-detect", resolve_side_effect=lambda wp: "VibeVoice-7B"
)
mock_resolve.assert_called_once_with("/fake/path/model.safetensors")
# Identity and the loader both see the RESOLVED family name.
assert mock_identity.call_args[0][1] == "VibeVoice-7B"
assert mock_load.call_args[1]["config_name"] == "VibeVoice-7B"
def test_explicit_selection_skips_detection(self):
_, mock_load, mock_resolve, mock_identity = self._execute(
"VibeVoice-1.5B", resolve_side_effect=lambda wp: "SHOULD-NOT-RUN"
)
mock_resolve.assert_not_called()
assert mock_identity.call_args[0][1] == "VibeVoice-1.5B"
assert mock_load.call_args[1]["config_name"] == "VibeVoice-1.5B"
def test_auto_inconclusive_fails_fast_without_loading(self):
fake_bundle = {"model": MagicMock()}
with patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
return_value=fake_bundle,
) as mock_load, patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
return_value="/fake/path/model.bin",
), patch(
"ComfyUI_VibeVoice.nodes.external_loader_node.resolve_auto_config_name",
side_effect=ValueError("could not determine architecture"),
):
with pytest.raises(ValueError, match="could not determine"):
VibeVoiceExternalLoaderNode.execute(
model_file="model.bin",
config_name="Auto-detect",
attention_mode="sdpa",
quantize_llm_4bit=False,
dtype="auto",
)
mock_load.assert_not_called()