Files
Will Miao e95ff360cf fix(download): decode percent-encoded stored filenames before saving
CivitAI stores non-ASCII file names percent-encoded; the mini endpoint's
raw fileName (preferred since #1100) returned that encoded string, so it
landed on disk literally as "%E7%8A%AC..." and the path-budget
truncation could cut mid-escape. Decode to valid non-ASCII UTF-8 names
before the on-disk name is fixed; literal % (stored as %25) and invalid
escapes pass through unchanged.
2026-10-19 08:15:03 +08:00

796 lines
27 KiB
Python

import os
import pytest
from py.services.settings_manager import SettingsManager, get_settings_manager
from py.services.service_registry import ServiceRegistry
from py.utils.constants import (
MAX_FILENAME_COMPONENT_BYTES,
MAX_FILENAME_STEM_LENGTH,
MAX_FOLDER_NAME_LENGTH,
MAX_FULL_PATH_LENGTH,
MAX_PATH_TAG_LENGTH,
PATH_BUDGET_SUFFIX_ALLOWANCE,
)
from py.utils.utils import (
calculate_filename_for_model,
calculate_recipe_fingerprint,
calculate_relative_path_for_model,
decode_percent_encoded_filename,
fit_stem_to_path_budget,
get_lora_info,
get_lora_info_absolute,
sanitize_folder_name,
)
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
# a whole keyword list into a single tag.
KEYWORD_DUMP_TAG = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
class _FakeCache:
def __init__(self, items):
self.raw_data = list(items)
class _FakeScanner:
def __init__(self, items):
self._cache = _FakeCache(items)
async def get_cached_data(self):
return self._cache
@pytest.fixture
def mock_lora_scanner(monkeypatch):
def _setup(items):
scanner = _FakeScanner(items)
async def get_scanner():
return scanner
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", get_scanner)
return scanner
return _setup
@pytest.fixture
def isolated_settings(monkeypatch):
manager = get_settings_manager()
default_settings = manager._get_default_settings()
default_settings.update(
{
"download_path_templates": {
"lora": "{base_model}/{first_tag}",
"checkpoint": "{base_model}/{first_tag}",
"embedding": "{base_model}/{first_tag}",
},
"base_model_path_mappings": {},
}
)
monkeypatch.setattr(manager, "settings", default_settings)
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
return default_settings
def test_calculate_relative_path_for_embedding_replaces_spaces(isolated_settings):
model_data = {
"base_model": "Base Model",
"tags": ["tag with space"],
"civitai": {"id": 1, "creator": {"username": "Author Name"}},
}
relative_path = calculate_relative_path_for_model(model_data, "embedding")
assert relative_path == "Base_Model/tag_with_space"
def test_calculate_relative_path_for_model_uses_mappings_and_defaults(isolated_settings):
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}/{author}"
isolated_settings["base_model_path_mappings"] = {"SDXL": "SDXL-mapped"}
model_data = {
"base_model": "SDXL",
"tags": [],
"civitai": {"id": 12, "creator": {"username": "Creator"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL-mapped/no tags/Creator"
def test_calculate_relative_path_supports_model_and_version(isolated_settings):
isolated_settings["download_path_templates"]["lora"] = "{model_name}/{version_name}"
model_data = {
"model_name": "Fancy Model",
"base_model": "SDXL",
"tags": ["tag"],
"civitai": {"id": 1, "name": "Version One", "creator": {"username": "Creator"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "Fancy Model/Version One"
def test_calculate_relative_path_sanitizes_model_and_version_names(isolated_settings):
isolated_settings["download_path_templates"]["lora"] = "{model_name}/{version_name}"
model_data = {
"model_name": "Fancy:Model*",
"base_model": "SDXL",
"tags": ["tag"],
"civitai": {"id": 1, "name": "Version:One?", "creator": {"username": "Creator"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "Fancy_Model/Version_One"
def test_calculate_relative_path_sanitizes_leading_slash(isolated_settings):
"""Test that empty base_model does NOT produce a leading slash in the path."""
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}"
model_data = {
"base_model": "",
"tags": [],
"civitai": {"id": 1, "creator": {"username": "Author"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert not relative_path.startswith("/")
assert relative_path == "no tags"
def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
"""Test that empty substitutions don't produce double slashes."""
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}/{author}"
model_data = {
"base_model": "",
"tags": [],
"civitai": {"id": 1, "creator": {"username": "Author"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert "//" not in relative_path
assert relative_path == "no tags/Author"
def test_calculate_relative_path_ignores_keyword_dump_tag(isolated_settings):
"""A tag holding a whole keyword list must not become a folder name (#1119)."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "Krea 2/no tags"
def test_calculate_relative_path_uses_next_usable_tag(isolated_settings):
"""Unusable tags are skipped instead of hijacking the folder name (#1119)."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "portrait"]}
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/portrait"
def test_calculate_relative_path_ignores_civitai_meta_tag(isolated_settings):
"""Civitai's "base model" label is not content, so it is not a folder."""
model_data = {"base_model": "Krea 2", "tags": ["base model"]}
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/no tags"
def test_calculate_relative_path_ignores_full_1119_tag_list(isolated_settings):
"""The reported model carries only a keyword dump and the meta label."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "base model"]}
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/no tags"
def test_calculate_relative_path_sanitizes_tag_segment(isolated_settings):
"""A tag with path separators must not create nested folders."""
model_data = {"base_model": "SDXL", "tags": ["a/b:c"]}
assert calculate_relative_path_for_model(model_data, "lora") == "SDXL/a_b_c"
def test_calculate_relative_path_caps_tag_segment(isolated_settings):
"""A long configured priority tag is truncated to the tag length budget."""
long_tag = "y" * 80
isolated_settings["priority_tags"] = {"lora": long_tag}
model_data = {"base_model": "SDXL", "tags": [long_tag]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL/" + "y" * MAX_PATH_TAG_LENGTH
def test_calculate_relative_path_keeps_tag_within_budget(isolated_settings):
model_data = {"base_model": "SDXL", "tags": ["t" * 40]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL/" + "t" * 40
def test_calculate_relative_path_caps_model_and_version_names(isolated_settings):
isolated_settings["download_path_templates"]["lora"] = "{model_name}/{version_name}"
model_data = {
"model_name": "m" * 300,
"base_model": "SDXL",
"tags": [],
"civitai": {"id": 1, "name": "v" * 300, "creator": {"username": "Creator"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == (
"m" * MAX_FOLDER_NAME_LENGTH + "/" + "v" * MAX_FOLDER_NAME_LENGTH
)
def test_calculate_recipe_fingerprint_filters_and_sorts():
loras = [
{"hash": "ABC", "strength": 0.1234},
{"hash": "", "isDeleted": True, "modelVersionId": 42, "strength": 0.5},
{"hash": "def", "weight": 0.345},
{"hash": "skip", "exclude": True, "strength": 0.9},
{"hash": "", "strength": 0.1},
]
fingerprint = calculate_recipe_fingerprint(loras)
assert fingerprint == "42:0.5|abc:0.12|def:0.34"
def test_calculate_recipe_fingerprint_empty_input():
assert calculate_recipe_fingerprint([]) == ""
def _set_filename_templates(isolated_settings, template, model_types=("lora", "checkpoint", "embedding")):
isolated_settings["download_filename_templates"] = {
model_type: template for model_type in model_types
}
def test_calculate_filename_returns_empty_without_template(isolated_settings):
model_data = {"model_name": "Model", "file_path": "/models/V1.safetensors"}
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_substitutes_all_placeholders(isolated_settings):
_set_filename_templates(
isolated_settings,
"{base_model}-{model_name}-{version_name}-{author}-{first_tag}-{hash_short}-{original_name}",
)
model_data = {
"model_name": "My Model",
"base_model": "SDXL",
"tags": ["Style"],
"sha256": "ABCDEF0123456789",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1, "name": "v3", "creator": {"username": "Author"}},
}
result = calculate_filename_for_model(model_data, "lora")
assert result == "SDXL-My Model-v3-Author-style-abcdef0123-V1"
def test_calculate_filename_hash_short_empty_when_unknown(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}-{hash_short}")
model_data = {
"model_name": "My Model",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
# Missing hash leaves an empty segment; the dangling separator collapses.
assert calculate_filename_for_model(model_data, "lora") == "My Model"
def test_calculate_filename_missing_metadata_produces_empty_segments(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
model_data = {
"model_name": "My Model",
"base_model": "",
"tags": [],
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == "My Model"
def test_calculate_filename_rejects_path_separators(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}/{model_name}")
model_data = {
"model_name": "My Model",
"base_model": "SDXL",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == ""
_set_filename_templates(isolated_settings, "{base_model}\\{model_name}")
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_strips_illegal_characters(isolated_settings):
_set_filename_templates(isolated_settings, '{model_name}:"custom"')
model_data = {
"model_name": "My:Model*",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
result = calculate_filename_for_model(model_data, "lora")
assert result == "My_Modelcustom"
def test_calculate_filename_empty_result_returns_empty(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
model_data = {
"base_model": "",
"tags": [],
"file_path": "/models/V1.safetensors",
}
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_uses_base_model_mapping(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
isolated_settings["base_model_path_mappings"] = {"SDXL": "sdxl-mapped"}
model_data = {
"model_name": "Model",
"base_model": "SDXL",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == "sdxl-mapped-Model"
def test_calculate_filename_embedding_replaces_spaces(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model} {model_name}")
model_data = {
"model_name": "My Model",
"base_model": "Base Model",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "embedding") == "Base_Model_My_Model"
def test_calculate_filename_original_name_falls_back_to_file_name(isolated_settings):
_set_filename_templates(isolated_settings, "{original_name}-{hash_short}")
model_data = {
"file_name": "legacy-name",
"sha256": "0123456789abcdef",
}
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
def test_calculate_filename_drops_keyword_dump_tag(isolated_settings):
"""The keyword-dump tag collapses instead of filling the filename (#1119)."""
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
model_data = {
"base_model": "Krea 2",
"tags": [KEYWORD_DUMP_TAG],
"file_path": "/models/V1.safetensors",
}
assert calculate_filename_for_model(model_data, "lora") == "Krea 2"
def test_calculate_filename_caps_rendered_stem(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}")
model_data = {
"model_name": "m" * 400,
"file_path": "/models/V1.safetensors",
}
result = calculate_filename_for_model(model_data, "lora")
assert len(result) == MAX_FILENAME_STEM_LENGTH
@pytest.mark.parametrize(
"original, expected",
[
("ValidName", "ValidName"),
("Invalid:Name", "Invalid_Name"),
("Trailing. ", "Trailing"),
("", ""),
(":::", "unnamed"),
],
)
def test_sanitize_folder_name(original, expected):
assert sanitize_folder_name(original) == expected
def test_sanitize_folder_name_without_max_length_is_unbounded():
assert sanitize_folder_name("x" * 300) == "x" * 300
@pytest.mark.parametrize(
"original, max_length, expected",
[
("abcdefghij", 4, "abcd"),
# Re-trim separators and spaces exposed by the cut.
("abc...defg", 4, "abc"),
("abcdefg hij", 8, "abcdefg"),
# Shorter than the cap is returned untouched.
("short", 10, "short"),
# A cut that leaves only separators falls back to "unnamed".
("...abcdef", 3, "unnamed"),
],
)
def test_sanitize_folder_name_truncates_to_max_length(original, max_length, expected):
assert sanitize_folder_name(original, max_length=max_length) == expected
def test_get_lora_info_absolute_bare_name(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
])
path, triggers = get_lora_info_absolute("mylora")
assert path == "/models/Lora/SDXL/mylora.safetensors"
assert triggers == ["trigger1"]
def test_get_lora_info_absolute_with_path(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL/Styles", "file_path": "/models/Lora/SDXL/Styles/mylora.safetensors", "civitai": {"trainedWords": ["artistic"]}},
{"file_name": "other", "folder": "", "file_path": "/models/Lora/other.safetensors", "civitai": {}},
])
path, triggers = get_lora_info_absolute("SDXL/Styles/mylora")
assert path == "/models/Lora/SDXL/Styles/mylora.safetensors"
assert triggers == ["artistic"]
def test_get_lora_info_absolute_path_fallback_to_basename(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "RenamedFolder", "file_path": "/models/Lora/RenamedFolder/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
])
path, triggers = get_lora_info_absolute("OldFolder/mylora")
assert path == "/models/Lora/RenamedFolder/mylora.safetensors"
assert triggers == ["trigger1"]
def test_get_lora_info_absolute_prefers_folder_match(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "V1", "file_path": "/models/Lora/V1/mylora.safetensors", "civitai": {"trainedWords": ["v1"]}},
{"file_name": "mylora", "folder": "V2", "file_path": "/models/Lora/V2/mylora.safetensors", "civitai": {"trainedWords": ["v2"]}},
])
path, triggers = get_lora_info_absolute("V2/mylora")
assert path == "/models/Lora/V2/mylora.safetensors"
assert triggers == ["v2"]
def test_get_lora_info_absolute_no_folder_in_cache_no_path_in_name(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "", "file_path": "/models/Lora/mylora.safetensors", "civitai": {}},
])
path, triggers = get_lora_info_absolute("mylora")
assert path == "/models/Lora/mylora.safetensors"
assert triggers == []
def test_get_lora_info_absolute_strips_extension(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["hello"]}},
])
path, triggers = get_lora_info_absolute("SDXL/mylora.safetensors")
assert path == "/models/Lora/SDXL/mylora.safetensors"
assert triggers == ["hello"]
def test_get_lora_info_absolute_not_found_returns_original(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {}},
])
path, triggers = get_lora_info_absolute("nonexistent")
assert path == "nonexistent"
assert triggers == []
def test_get_lora_info_bare_name(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
])
path, triggers = get_lora_info("mylora")
assert triggers == ["trigger1"]
def test_get_lora_info_with_path(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL/Styles", "file_path": "/models/Lora/SDXL/Styles/mylora.safetensors", "civitai": {"trainedWords": ["artistic"]}},
{"file_name": "other", "folder": "", "file_path": "/models/Lora/other.safetensors", "civitai": {}},
])
path, triggers = get_lora_info("SDXL/Styles/mylora")
assert triggers == ["artistic"]
def test_get_lora_info_not_found_returns_original(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {}},
])
path, triggers = get_lora_info("nonexistent")
assert path == "nonexistent"
assert triggers == []
def test_get_lora_info_absolute_preserves_exact_stack_path(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "same", "folder": "a", "file_path": "/models/a/same.safetensors", "civitai": {"trainedWords": ["wrong"]}},
{"file_name": "same", "folder": "b", "file_path": "/models/b/same.safetensors", "civitai": {"trainedWords": ["right"]}},
])
assert get_lora_info_absolute("/models/b/same.safetensors") == (
"/models/b/same.safetensors", ["right"]
)
def test_get_lora_info_absolute_does_not_substitute_missing_absolute_path(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "same", "folder": "a", "file_path": "/models/a/same.safetensors"},
])
assert get_lora_info_absolute("/models/missing/same.safetensors") == (
"/models/missing/same.safetensors", []
)
# Budget available to "directory + stem"; suffixes (.safetensors,
# .metadata.json, previews) are covered by PATH_BUDGET_SUFFIX_ALLOWANCE.
PATH_BUDGET = MAX_FULL_PATH_LENGTH - PATH_BUDGET_SUFFIX_ALLOWANCE
@pytest.fixture
def alongside_sidecar_dir(monkeypatch):
"""Pin sidecar resolution to alongside mode regardless of global settings.
Plain dirname, no abspath: abspath prepends the drive letter on Windows,
which would make the sidecar path two chars longer than the model path
and skew the budget math these tests assert on.
"""
monkeypatch.setattr(
"py.utils.utils.get_sidecar_dir",
lambda path: os.path.dirname(path),
)
def test_fit_stem_to_path_budget_returns_stem_within_budget(alongside_sidecar_dir):
assert fit_stem_to_path_budget("/models", "abc", ".safetensors") == "abc"
def test_fit_stem_to_path_budget_empty_stem(alongside_sidecar_dir):
assert fit_stem_to_path_budget("/models", "") == ""
def test_fit_stem_to_path_budget_truncates_overlong_stem(alongside_sidecar_dir):
dir_path = "/" + "d" * 200 # 201 chars
stem = "n" * 100
result = fit_stem_to_path_budget(dir_path, stem, ".safetensors")
assert len(os.path.join(dir_path, result)) == PATH_BUDGET
assert result == "n" * (len(stem) - (201 + 1 + len(stem) - PATH_BUDGET))
def test_fit_stem_to_path_budget_retrims_separators_exposed_by_the_cut(
alongside_sidecar_dir,
):
dir_path = "/" + "d" * 226 # 227 chars -> cut leaves "aaaaaaaa____"
stem = "aaaaaaaa______bbbb"
result = fit_stem_to_path_budget(dir_path, stem)
assert result == "aaaaaaaa"
def test_fit_stem_to_path_budget_returns_empty_when_dir_leaves_no_room(
alongside_sidecar_dir,
):
dir_path = "/" + "d" * 235 # 236 chars
assert fit_stem_to_path_budget(dir_path, "n" * 30) == ""
def test_fit_stem_to_path_budget_counts_the_centralized_sidecar_mirror(
monkeypatch,
):
"""The mirror tree is typically deeper than the model dir (#1142)."""
mirror_dir = "/" + "m" * 225 # 226 chars
monkeypatch.setattr("py.utils.utils.get_sidecar_dir", lambda path: mirror_dir)
result = fit_stem_to_path_budget("/models", "n" * 100, ".safetensors")
assert len(os.path.join(mirror_dir, result)) == PATH_BUDGET
def test_fit_stem_to_path_budget_enforces_component_byte_limit_for_cjk(
alongside_sidecar_dir,
):
"""POSIX NAME_MAX counts bytes: a 100-char CJK stem (300 bytes) passes
every character cap yet cannot be created on disk (ENAMETOOLONG)."""
result = fit_stem_to_path_budget("/models", "界" * 100, ".safetensors")
assert 0 < len(result) < 100
# stem + longest companion suffix (".metadata.json.bak") fits NAME_MAX.
assert (
len(result.encode("utf-8")) + PATH_BUDGET_SUFFIX_ALLOWANCE
<= MAX_FILENAME_COMPONENT_BYTES
)
def test_fit_stem_to_path_budget_honors_probed_name_max(
monkeypatch, alongside_sidecar_dir
):
"""eCryptfs (Ubuntu encrypted home, Synology encrypted shares) allows
only 143 bytes per component; the probe must tighten the budget."""
monkeypatch.setattr("py.utils.utils._probe_name_max_bytes", lambda path: 143)
result = fit_stem_to_path_budget("/models", "か" * 60, ".safetensors")
assert 0 < len(result) < 60
assert len(result.encode("utf-8")) + PATH_BUDGET_SUFFIX_ALLOWANCE <= 143
def test_fit_stem_to_path_budget_byte_cut_never_splits_a_multibyte_character(
alongside_sidecar_dir,
):
stem = "a" * 233 + "界界" # 239 bytes; the 235-byte cut lands mid-界
result = fit_stem_to_path_budget("/", stem, ".safetensors")
assert result == "a" * 233
def test_fit_stem_to_path_budget_returns_empty_when_byte_budget_is_not_viable(
monkeypatch, alongside_sidecar_dir
):
"""A name-max probe below the viable stem size must skip, not stub."""
monkeypatch.setattr("py.utils.utils._probe_name_max_bytes", lambda path: 24)
assert fit_stem_to_path_budget("/models", "n" * 30, ".safetensors") == ""
def test_calculate_filename_with_target_dir_trims_to_path_budget(
isolated_settings, alongside_sidecar_dir
):
"""The fixed stem cap ignores the real depth; target_dir tightens it."""
_set_filename_templates(isolated_settings, "{model_name}")
dir_path = "/" + "d" * 200 # 201 chars: 150-cap stem would overflow
model_data = {"model_name": "m" * 400, "file_path": "/models/V1.safetensors"}
result = calculate_filename_for_model(model_data, "lora", target_dir=dir_path)
assert result
assert len(result) < MAX_FILENAME_STEM_LENGTH
assert len(os.path.join(dir_path, result)) == PATH_BUDGET
def test_calculate_filename_with_target_dir_returns_empty_when_unviable(
isolated_settings, alongside_sidecar_dir
):
_set_filename_templates(isolated_settings, "{model_name}")
dir_path = "/" + "d" * 235 # 236 chars: no viable stem fits
model_data = {"model_name": "m" * 400, "file_path": "/models/V1.safetensors"}
assert calculate_filename_for_model(model_data, "lora", target_dir=dir_path) == ""
def test_calculate_filename_without_target_dir_keeps_fixed_cap(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}")
model_data = {"model_name": "m" * 400, "file_path": "/models/V1.safetensors"}
result = calculate_filename_for_model(model_data, "lora")
assert len(result) == MAX_FILENAME_STEM_LENGTH
# Real raw stored fileName served by CivitAI's mini endpoint for model
# version 1714548: non-ASCII names are percent-encoded server-side.
PERCENT_ENCODED_STORED_NAME = (
"%E7%8A%AC%E5%A4%9C%E5%8F%89%E6%97%A5%E6%9A%AE%E3%81%8B%E3%81%94"
"%E3%82%812000%E5%B9%B4%E4%BB%A3%E3%82%A2%E3%83%8B%E3%83%A1%E3%82%A2"
"%E3%83%8A%E3%83%AD%E3%82%B0%E4%BD%9C%E7%94%BB%E9%A2%A8illustrious"
"InuyashaHigurashi_Kagome2000s_anime_analog_painting_Styleillustrious"
".safetensors"
)
def test_decode_percent_encoded_filename_decodes_non_ascii_names():
result = decode_percent_encoded_filename(PERCENT_ENCODED_STORED_NAME)
assert result == (
"犬夜叉日暮かごめ2000年代アニメアナログ作画風illustrious"
"InuyashaHigurashi_Kagome2000s_anime_analog_painting_Styleillustrious"
".safetensors"
)
def test_decode_percent_encoded_filename_passes_plain_names_through():
assert decode_percent_encoded_filename("model.safetensors") == "model.safetensors"
def test_decode_percent_encoded_filename_keeps_ascii_only_escapes_literal():
# A literal "%" is stored as "%25", so decoding would yield pure ASCII:
# treat the string as a real name and leave it alone.
assert (
decode_percent_encoded_filename("100%25cool.safetensors")
== "100%25cool.safetensors"
)
def test_decode_percent_encoded_filename_keeps_invalid_escapes_literal():
assert (
decode_percent_encoded_filename("100%zzcool.safetensors")
== "100%zzcool.safetensors"
)
def test_decode_percent_encoded_filename_keeps_truncated_escapes_literal():
# A mid-triplet cut (e.g. from a previous length-truncation bug) does not
# decode to valid UTF-8 and must pass through untouched.
truncated = PERCENT_ENCODED_STORED_NAME[:100]
assert decode_percent_encoded_filename(truncated) == truncated