mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-07 18:12:12 -03:00
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.
Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
answer which directories a library-relative folder maps to
(folder_path/root/is_symlink), in scanner root order, skipping directories
no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
so the sidebar can tell "this node is stale, refresh" from a failed
operation.
Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
absolute paths: default root first, the old root-prefix fallback only
while a single root is configured, and an explicit unresolved error for a
multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
absolute path. Several copies render one checkbox row per copy, each
dry-run against the delete guard ("no models" / "contains N model
file(s)..." / a deletion is still pending / no longer exists / symbolic
link): a blocked copy is unticked, disabled and explained, the button
reads "Delete N folders", every ticked copy is deleted and guarded on its
own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
ticking a box no longer rebuilds the list, steals focus or resizes the
modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
parent's root when the parent resolves to exactly one directory, and the
undo restores every copy a delete removed.
i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.
Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
397 lines
11 KiB
Python
397 lines
11 KiB
Python
import json
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from py.routes.handlers.model_handlers import ModelMoveHandler
|
|
|
|
|
|
class FakeMoveService:
|
|
def __init__(self, result):
|
|
self._result = result
|
|
self.received_path = None
|
|
self.received_dry_run = None
|
|
self.received_new_name = None
|
|
self.received_folder = None
|
|
|
|
async def create_folder(self, folder_path):
|
|
self.received_path = folder_path
|
|
return self._result
|
|
|
|
async def delete_folder(self, folder_path, dry_run=False):
|
|
self.received_path = folder_path
|
|
self.received_dry_run = dry_run
|
|
return self._result
|
|
|
|
async def rename_folder(self, folder_path, new_name):
|
|
self.received_path = folder_path
|
|
self.received_new_name = new_name
|
|
return self._result
|
|
|
|
def resolve_folder(self, folder):
|
|
self.received_folder = folder
|
|
return self._result
|
|
|
|
|
|
class FakeRequest:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
async def json(self):
|
|
return self._payload
|
|
|
|
|
|
class FakeQueryRequest:
|
|
"""Request stand-in exposing only the query string."""
|
|
|
|
def __init__(self, query):
|
|
self.query = query
|
|
|
|
|
|
def _make_handler(result):
|
|
service = FakeMoveService(result)
|
|
handler = ModelMoveHandler(
|
|
move_service=service, logger=logging.getLogger(__name__)
|
|
)
|
|
return handler, service
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_folder_success():
|
|
handler, service = _make_handler(
|
|
{"success": True, "folder": "characters/anime", "created": True}
|
|
)
|
|
|
|
response = await handler.create_folder(
|
|
FakeRequest({"folder_path": "/library/characters/anime"})
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is True
|
|
assert payload["folder"] == "characters/anime"
|
|
assert service.received_path == "/library/characters/anime"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_folder_missing_path():
|
|
handler, service = _make_handler({"success": True})
|
|
|
|
response = await handler.create_folder(FakeRequest({}))
|
|
|
|
assert response.status == 400
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
assert service.received_path is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_folder_service_failure_maps_to_400():
|
|
handler, _service = _make_handler(
|
|
{
|
|
"success": False,
|
|
"error": "Folder path '/etc/evil' is outside configured library directories",
|
|
}
|
|
)
|
|
|
|
response = await handler.create_folder(FakeRequest({"folder_path": "/etc/evil"}))
|
|
|
|
assert response.status == 400
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
assert "outside configured library" in payload["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_folder_invalid_json_body():
|
|
class BadJsonRequest:
|
|
async def json(self):
|
|
raise ValueError("bad json")
|
|
|
|
handler, _service = _make_handler({"success": True})
|
|
|
|
response = await handler.create_folder(BadJsonRequest())
|
|
|
|
assert response.status == 400
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_success():
|
|
handler, service = _make_handler(
|
|
{
|
|
"success": True,
|
|
"folder": "characters/anime",
|
|
"model_count": 0,
|
|
"restorable": True,
|
|
}
|
|
)
|
|
|
|
response = await handler.delete_folder(
|
|
FakeRequest({"folder_path": "/library/characters/anime"})
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is True
|
|
assert payload["restorable"] is True
|
|
assert service.received_path == "/library/characters/anime"
|
|
assert service.received_dry_run is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_forwards_dry_run():
|
|
handler, service = _make_handler({"success": True, "dry_run": True})
|
|
|
|
response = await handler.delete_folder(
|
|
FakeRequest({"folder_path": "/library/empty", "dry_run": True})
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert service.received_dry_run is True
|
|
assert json.loads(response.text)["dry_run"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_folder_returns_the_matching_roots():
|
|
handler, service = _make_handler(
|
|
{
|
|
"success": True,
|
|
"folder": "recipes",
|
|
"candidates": [
|
|
{
|
|
"folder_path": "/library/recipes",
|
|
"root": "/library",
|
|
"is_symlink": False,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
response = await handler.resolve_folder(FakeQueryRequest({"folder": "recipes"}))
|
|
|
|
assert response.status == 200
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is True
|
|
assert payload["candidates"][0]["folder_path"] == "/library/recipes"
|
|
assert service.received_folder == "recipes"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_folder_maps_a_refused_path_to_400():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "error": "Folder path is required"}
|
|
)
|
|
|
|
response = await handler.resolve_folder(FakeQueryRequest({}))
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_missing_path():
|
|
handler, service = _make_handler({"success": True})
|
|
|
|
response = await handler.delete_folder(FakeRequest({}))
|
|
|
|
assert response.status == 400
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
assert service.received_path is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_not_empty_maps_to_409():
|
|
handler, _service = _make_handler(
|
|
{
|
|
"success": False,
|
|
"code": "not_empty",
|
|
"error": "Folder still contains 2 model file(s); delete or move them first",
|
|
"manifest": {"model_count": 2},
|
|
}
|
|
)
|
|
|
|
response = await handler.delete_folder(
|
|
FakeRequest({"folder_path": "/library/full"})
|
|
)
|
|
|
|
assert response.status == 409
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
assert payload["code"] == "not_empty"
|
|
assert payload["manifest"]["model_count"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_busy_maps_to_409():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "code": "busy", "error": "staged delete pending"}
|
|
)
|
|
|
|
response = await handler.delete_folder(
|
|
FakeRequest({"folder_path": "/library/full"})
|
|
)
|
|
|
|
assert response.status == 409
|
|
assert json.loads(response.text)["code"] == "busy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_containment_failure_maps_to_400():
|
|
handler, _service = _make_handler(
|
|
{
|
|
"success": False,
|
|
"error": "Folder path '/etc/evil' is outside configured library directories",
|
|
}
|
|
)
|
|
|
|
response = await handler.delete_folder(FakeRequest({"folder_path": "/etc/evil"}))
|
|
|
|
assert response.status == 400
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
assert "outside configured library" in payload["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_reports_a_vanished_directory_as_missing():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "code": "missing", "error": "Folder no longer exists"}
|
|
)
|
|
|
|
response = await handler.delete_folder(
|
|
FakeRequest({"folder_path": "/library/gone"})
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["code"] == "missing"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_folder_invalid_json_body():
|
|
class BadJsonRequest:
|
|
async def json(self):
|
|
raise ValueError("bad json")
|
|
|
|
handler, _service = _make_handler({"success": True})
|
|
|
|
response = await handler.delete_folder(BadJsonRequest())
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_success():
|
|
handler, service = _make_handler(
|
|
{
|
|
"success": True,
|
|
"renamed": True,
|
|
"folder": "characters/animation",
|
|
"previous_folder": "characters/anime",
|
|
}
|
|
)
|
|
|
|
response = await handler.rename_folder(
|
|
FakeRequest(
|
|
{"folder_path": "/library/characters/anime", "new_name": "animation"}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is True
|
|
assert payload["folder"] == "characters/animation"
|
|
assert service.received_path == "/library/characters/anime"
|
|
assert service.received_new_name == "animation"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_missing_path():
|
|
handler, service = _make_handler({"success": True})
|
|
|
|
response = await handler.rename_folder(FakeRequest({"new_name": "animation"}))
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|
|
assert service.received_path is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_missing_name():
|
|
handler, service = _make_handler({"success": True})
|
|
|
|
response = await handler.rename_folder(
|
|
FakeRequest({"folder_path": "/library/characters/anime"})
|
|
)
|
|
|
|
assert response.status == 400
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is False
|
|
assert service.received_new_name is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_target_exists_maps_to_409():
|
|
handler, _service = _make_handler(
|
|
{
|
|
"success": False,
|
|
"code": "target_exists",
|
|
"error": 'A folder named "animation" already exists here',
|
|
}
|
|
)
|
|
|
|
response = await handler.rename_folder(
|
|
FakeRequest(
|
|
{"folder_path": "/library/characters/anime", "new_name": "animation"}
|
|
)
|
|
)
|
|
|
|
assert response.status == 409
|
|
payload = json.loads(response.text)
|
|
assert payload["code"] == "target_exists"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_busy_maps_to_409():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "code": "busy", "error": "staged delete pending"}
|
|
)
|
|
|
|
response = await handler.rename_folder(
|
|
FakeRequest({"folder_path": "/library/full", "new_name": "renamed"})
|
|
)
|
|
|
|
assert response.status == 409
|
|
assert json.loads(response.text)["code"] == "busy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_invalid_name_maps_to_400():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "error": "Invalid characters in folder name"}
|
|
)
|
|
|
|
response = await handler.rename_folder(
|
|
FakeRequest({"folder_path": "/library/full", "new_name": "a/b"})
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_folder_invalid_json_body():
|
|
class BadJsonRequest:
|
|
async def json(self):
|
|
raise ValueError("bad json")
|
|
|
|
handler, _service = _make_handler({"success": True})
|
|
|
|
response = await handler.rename_folder(BadJsonRequest())
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|