Files
ComfyUI-Lora-Manager/tests/services/test_model_file_service.py
T
Will Miao 4938faa049 feat(sidebar): delete folders from the sidebar (#999)
Folders created from the sidebar had no in-app way back out: the only
removal path was to leave ComfyUI, delete the directory by hand and
rescan. A typo'd folder also polluted the move/download destination
picker permanently, since it reads the same all_folders source.

Adds POST /api/lm/{prefix}/delete-folder, restricted to directories
whose subtree holds no model weight files — a folder-level cascade would
bypass the per-model lifecycle bookkeeping (metadata sidecars, previews,
cache entries, pending-delete staging, recipe references). The service
walks the directory itself instead of trusting the possibly stale cache,
reports what it would remove (models / files / subfolders / symlinks),
and refuses library roots, top-level symlinks (shutil.rmtree rejects
those) and folders holding a staged delete, whose manifest would be
invalidated by the move. Symbolic links inside the subtree are counted
but never followed.

ModelScanner.remove_known_folder mirrors add_known_folder: the removed
subtree leaves all_folders while ancestors are kept (every recorded
ancestor exists on disk in its own right), stale cache entries under the
prefix are purged and the folder list recomputed. The handler broadcasts
models_changed so destination pickers drop the folder too.

The sidebar entry is a destructive context-menu item. The modal opens in
a confirm state for model-free folders and an explanatory one when the
subtree still holds models, decided from the models-only set that already
dims empty nodes; a stale tree is caught by the 409 not_empty/busy
conflict. Truly empty folders get the existing 20s undo affordance,
implemented by re-creating the directory.
2026-09-15 20:05:10 +08:00

274 lines
8.3 KiB
Python

"""Tests for ModelMoveService folder creation."""
from __future__ import annotations
from pathlib import Path
from typing import List
import pytest
from py.services.model_file_service import ModelMoveService
class FakeScanner:
def __init__(self, roots: List[Path]) -> None:
self._roots = [str(root) for root in roots]
self.known_folders: List[str] = []
self.removed_folders: List[str] = []
def get_model_roots(self) -> List[str]:
return list(self._roots)
async def add_known_folder(self, folder: str) -> None:
self.known_folders.append(folder)
async def remove_known_folder(self, folder: str) -> None:
self.removed_folders.append(folder)
@pytest.mark.asyncio
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
target = tmp_path / "characters" / "anime"
result = await service.create_folder(str(target))
assert result["success"] is True
assert result["created"] is True
assert result["folder"] == "characters/anime"
assert target.is_dir()
assert scanner.known_folders == ["characters/anime"]
@pytest.mark.asyncio
async def test_create_folder_existing_directory_reports_not_created(tmp_path: Path):
(tmp_path / "existing").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.create_folder(str(tmp_path / "existing"))
assert result["success"] is True
assert result["created"] is False
assert result["folder"] == "existing"
@pytest.mark.asyncio
async def test_create_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
outside = tmp_path / "outside"
result = await service.create_folder(str(outside))
assert result["success"] is False
assert "error" in result
assert not outside.exists()
assert scanner.known_folders == []
@pytest.mark.asyncio
async def test_create_folder_rejects_traversal_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.create_folder(str(root / ".." / "escape"))
assert result["success"] is False
assert not (tmp_path / "escape").exists()
assert scanner.known_folders == []
@pytest.mark.asyncio
async def test_create_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.create_folder("")
assert result["success"] is False
def _make_nested(root: Path) -> Path:
target = root / "characters" / "anime"
target.mkdir(parents=True)
return target
@pytest.mark.asyncio
async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Path):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["folder"] == "characters/anime"
assert result["model_count"] == 0
assert result["restorable"] is True
assert not target.exists()
assert scanner.removed_folders == ["characters/anime"]
@pytest.mark.asyncio
async def test_delete_folder_reports_non_model_leftovers_as_not_restorable(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "notes.txt").write_text("keep me?", encoding="utf-8")
(target / "nested").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["file_count"] == 1
assert result["dir_count"] == 1
assert result["restorable"] is False
assert not target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
target = _make_nested(tmp_path)
model_file = target / "model.safetensors"
model_file.write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "not_empty"
assert result["manifest"]["model_count"] == 1
assert target.exists()
assert model_file.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "busy"
assert target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path))
assert result["success"] is False
assert "root" in result["error"].lower()
assert tmp_path.exists()
@pytest.mark.asyncio
async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(outside))
assert result["success"] is False
assert "error" in result
assert outside.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "gone"))
assert result["success"] is False
assert "no longer exists" in result["error"]
@pytest.mark.asyncio
async def test_delete_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.delete_folder("")
assert result["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_dry_run_reports_without_removing(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "leftover.webp").write_text("preview", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target), dry_run=True)
assert result["success"] is True
assert result["dry_run"] is True
assert result["file_count"] == 1
assert target.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_refuses_symlinked_directory(tmp_path: Path):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
try:
link.symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(link))
assert result["success"] is False
assert "symlink" in result["error"].lower()
assert link.is_symlink()
assert real.is_dir()
@pytest.mark.asyncio
async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_path: Path):
target = _make_nested(tmp_path)
real = tmp_path / "real"
real.mkdir()
(real / "model.safetensors").write_text("weights", encoding="utf-8")
try:
(target / "linked").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["symlink_count"] == 1
# The linked model is not part of the subtree being deleted
assert result["model_count"] == 0
assert (real / "model.safetensors").exists()