mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(sidebar): rename folders from the sidebar (#999)
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.
Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.
Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.
The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
This commit is contained in:
@@ -59,6 +59,12 @@ function createApiClient(overrides = {}) {
|
||||
dir_count: 0,
|
||||
restorable: true,
|
||||
}),
|
||||
renameFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
renamed: true,
|
||||
folder: 'renamed',
|
||||
previous_folder: 'empty',
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -677,3 +683,166 @@ describe('SidebarManager folder deletion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidebarManager folder rename', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
state.global.settings = {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renameInput() {
|
||||
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
||||
}
|
||||
|
||||
it('turns the node into a prefilled inline row in tree mode', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
|
||||
const row = document.getElementById('sidebarRenameFolderInput');
|
||||
expect(row).not.toBeNull();
|
||||
expect(renameInput().value).toBe('anime');
|
||||
// The node is hidden in place, not removed: the row sits right before it
|
||||
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
||||
expect(node.style.display).toBe('none');
|
||||
expect(row.nextElementSibling).toBe(node);
|
||||
expect(manager._renameFolderPath).toBe('characters/anime');
|
||||
});
|
||||
|
||||
it('inserts the row in place in list mode', () => {
|
||||
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||
manager.foldersList = ['characters', 'characters/anime'];
|
||||
manager.renderFolderList();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
|
||||
const row = document.getElementById('sidebarRenameFolderInput');
|
||||
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
|
||||
const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]');
|
||||
expect(row.nextElementSibling).toBe(item);
|
||||
});
|
||||
|
||||
it('restores the node when the edit is canceled', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
manager.handleRenameFolderCancel();
|
||||
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||
expect(manager._renameFolderPath).toBeNull();
|
||||
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
||||
expect(node.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('renames through the API and re-keys the persisted selection', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
manager.selectedPath = 'characters/anime';
|
||||
manager.expandedNodes = new Set(['characters', 'characters/anime']);
|
||||
manager.pageControls = { pageState: { activeFolder: 'characters/anime' } };
|
||||
|
||||
const success = await manager._renameFolder('characters/anime', 'animation');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation');
|
||||
expect(manager.selectedPath).toBe('renamed');
|
||||
expect(manager.pageControls.pageState.activeFolder).toBe('renamed');
|
||||
expect(getStorageItem('loras_activeFolder')).toBe('renamed');
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.renameFolderResult.success', { name: 'animation' }, 'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('re-keys the expanded subtree and the selection', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']);
|
||||
manager.selectedPath = 'a/b/c';
|
||||
manager.saveExpandedState = vi.fn();
|
||||
|
||||
manager._rekeyFolderPath('a/b', 'a/z');
|
||||
|
||||
expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']);
|
||||
expect(manager.selectedPath).toBe('a/z/c');
|
||||
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits the inline edit and skips the API for an unchanged name', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
renameInput().value = 'anime';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid names before calling the API', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
renameInput().value = 'bad/name';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
||||
// The row stays open so the name can be corrected
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a name collision', async () => {
|
||||
const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' });
|
||||
const apiClient = createApiClient({
|
||||
renameFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._renameFolder('characters/anime', 'animation');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning');
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes the context-menu action to the inline rename row', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.showRenameFolderInput = vi.fn();
|
||||
|
||||
manager._performFolderAction('rename-folder', 'characters/anime');
|
||||
|
||||
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
|
||||
});
|
||||
|
||||
it('hides the rename entry when folder management is unsupported', () => {
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rename-folder"></div>
|
||||
</div>`);
|
||||
const apiClient = createApiClient();
|
||||
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
manager._showFolderContextMenu(10, 10, 'empty');
|
||||
|
||||
const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]');
|
||||
expect(item.style.display).toBe('none');
|
||||
|
||||
manager._closeFolderContextMenu();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class FakeMoveService:
|
||||
self._result = result
|
||||
self.received_path = None
|
||||
self.received_dry_run = None
|
||||
self.received_new_name = None
|
||||
|
||||
async def create_folder(self, folder_path):
|
||||
self.received_path = folder_path
|
||||
@@ -21,6 +22,11 @@ class FakeMoveService:
|
||||
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
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, payload):
|
||||
@@ -212,3 +218,116 @@ async def test_delete_folder_invalid_json_body():
|
||||
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ class FakeScanner:
|
||||
self._roots = [str(root) for root in roots]
|
||||
self.known_folders: List[str] = []
|
||||
self.removed_folders: List[str] = []
|
||||
self.renamed_folders: List[tuple] = []
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
return list(self._roots)
|
||||
@@ -25,6 +26,9 @@ class FakeScanner:
|
||||
async def remove_known_folder(self, folder: str) -> None:
|
||||
self.removed_folders.append(folder)
|
||||
|
||||
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
|
||||
self.renamed_folders.append((previous, current, kwargs))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
||||
@@ -271,3 +275,154 @@ async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_p
|
||||
# The linked model is not part of the subtree being deleted
|
||||
assert result["model_count"] == 0
|
||||
assert (real / "model.safetensors").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_moves_directory_and_forwards_rekey(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "animation")
|
||||
|
||||
renamed = tmp_path / "characters" / "animation"
|
||||
assert result["success"] is True
|
||||
assert result["renamed"] is True
|
||||
assert result["folder"] == "characters/animation"
|
||||
assert result["previous_folder"] == "characters/anime"
|
||||
assert renamed.is_dir()
|
||||
assert (renamed / "model.safetensors").exists()
|
||||
assert not target.exists()
|
||||
|
||||
previous, current, kwargs = scanner.renamed_folders[0]
|
||||
assert previous == "characters/anime"
|
||||
assert current == "characters/animation"
|
||||
assert kwargs["previous_path"] == target.as_posix()
|
||||
assert kwargs["new_path"] == renamed.as_posix()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_noop_when_name_is_unchanged(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "anime")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["renamed"] is False
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_existing_target(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(tmp_path / "characters" / "animation").mkdir()
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "animation")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "target_exists"
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("new_name", ["", " ", "a/b", "..", ".", "bad:name", "back\\slash"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_rejects_invalid_names(tmp_path: Path, new_name: str):
|
||||
target = _make_nested(tmp_path)
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), new_name)
|
||||
|
||||
assert result["success"] is False
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_the_library_root_itself(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(tmp_path), "renamed-root")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "root" in result["error"].lower()
|
||||
assert tmp_path.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_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.rename_folder(str(outside), "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert outside.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_reports_missing_directory(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(tmp_path / "gone"), "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "no longer exists" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_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.rename_folder(str(link), "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "symlink" in result["error"].lower()
|
||||
assert link.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_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.rename_folder(str(target), "animation")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "busy"
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_requires_path(tmp_path: Path):
|
||||
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||
|
||||
result = await service.rename_folder("", "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@@ -1609,6 +1609,161 @@ async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
|
||||
assert cache.all_folders == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path):
|
||||
_, second, _ = _create_files(tmp_path)
|
||||
nested = tmp_path / "nested"
|
||||
preview = nested / "two.preview.png"
|
||||
preview.write_text("png", encoding="utf-8")
|
||||
(nested / "two.metadata.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file_path": _normalize_path(second),
|
||||
"preview_url": _normalize_path(preview),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
entry = next(item for item in cache.raw_data if item["model_name"] == "two")
|
||||
entry["preview_url"] = _normalize_path(preview)
|
||||
|
||||
renamed = tmp_path / "renamed"
|
||||
old_abs = _normalize_path(nested)
|
||||
new_abs = _normalize_path(renamed)
|
||||
os.rename(nested, renamed)
|
||||
|
||||
changed = await scanner.rename_known_folder(
|
||||
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
assert changed is True
|
||||
assert "renamed" in cache.all_folders
|
||||
assert "nested" not in cache.all_folders
|
||||
assert "renamed" in cache.folders
|
||||
assert "nested" not in cache.folders
|
||||
assert entry["folder"] == "renamed"
|
||||
assert entry["file_path"] == _normalize_path(renamed / "two.txt")
|
||||
assert entry["preview_url"] == _normalize_path(renamed / "two.preview.png")
|
||||
assert scanner._hash_index.get_path("hash-two") == _normalize_path(
|
||||
renamed / "two.txt"
|
||||
)
|
||||
|
||||
# The sidecar travelled with the directory and was re-pointed in place
|
||||
payload = json.loads(
|
||||
(renamed / "two.metadata.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert payload["file_path"] == _normalize_path(renamed / "two.txt")
|
||||
assert payload["preview_url"] == _normalize_path(renamed / "two.preview.png")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_handles_nested_targets(tmp_path: Path):
|
||||
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
|
||||
model = tmp_path / "a" / "b" / "c" / "m.txt"
|
||||
model.write_text("m", encoding="utf-8")
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
old_abs = _normalize_path(tmp_path / "a" / "b")
|
||||
new_abs = _normalize_path(tmp_path / "a" / "z")
|
||||
os.rename(tmp_path / "a" / "b", tmp_path / "a" / "z")
|
||||
|
||||
await scanner.rename_known_folder(
|
||||
"a/b", "a/z", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
assert "a/b" not in cache.all_folders
|
||||
assert "a/b/c" not in cache.all_folders
|
||||
assert "a/z" in cache.all_folders
|
||||
assert "a/z/c" in cache.all_folders
|
||||
# The parent is an untouched directory in its own right
|
||||
assert "a" in cache.all_folders
|
||||
|
||||
entry = next(item for item in cache.raw_data if item["model_name"] == "m")
|
||||
assert entry["folder"] == "a/z/c"
|
||||
assert entry["file_path"] == _normalize_path(tmp_path / "a" / "z" / "c" / "m.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_keeps_unrelated_entries(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
old_abs = _normalize_path(tmp_path / "nested")
|
||||
new_abs = _normalize_path(tmp_path / "renamed")
|
||||
os.rename(tmp_path / "nested", tmp_path / "renamed")
|
||||
|
||||
await scanner.rename_known_folder(
|
||||
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
root_entry = next(item for item in cache.raw_data if item["model_name"] == "one")
|
||||
assert root_entry["folder"] == ""
|
||||
assert root_entry["file_path"] == _normalize_path(first)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_rekeys_excluded_models(tmp_path: Path):
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "one.txt").write_text("one", encoding="utf-8")
|
||||
(nested / "skip-me.txt").write_text("skip", encoding="utf-8")
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
assert scanner._excluded_models == [_normalize_path(nested / "skip-me.txt")]
|
||||
|
||||
old_abs = _normalize_path(nested)
|
||||
new_abs = _normalize_path(tmp_path / "renamed")
|
||||
os.rename(nested, tmp_path / "renamed")
|
||||
|
||||
await scanner.rename_known_folder(
|
||||
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
assert scanner._excluded_models == [
|
||||
_normalize_path(tmp_path / "renamed" / "skip-me.txt")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_ignores_unchanged_or_empty_names(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
before = list(cache.all_folders)
|
||||
|
||||
assert (
|
||||
await scanner.rename_known_folder(
|
||||
"nested",
|
||||
"nested",
|
||||
previous_path=_normalize_path(tmp_path / "nested"),
|
||||
new_path=_normalize_path(tmp_path / "nested"),
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
await scanner.rename_known_folder(
|
||||
"",
|
||||
"renamed",
|
||||
previous_path=_normalize_path(tmp_path),
|
||||
new_path=_normalize_path(tmp_path / "renamed"),
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
assert cache.all_folders == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user