mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-24 06:21:26 -03:00
fix(ui): show empty folders as move and download destinations (#999)
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { describe, it, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
BASE_MODEL_API_MODULE,
|
||||
STATE_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
STORAGE_MODULE,
|
||||
API_CONFIG_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {},
|
||||
getCurrentPageState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_MODULE, () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
getSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
saveMapToStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(API_CONFIG_MODULE, () => ({
|
||||
getCompleteApiConfig: vi.fn(() => ({
|
||||
endpoints: { unifiedFolderTree: '/api/lm/loras/unified-folder-tree' },
|
||||
config: { displayName: 'LoRA', singularName: 'LoRA' },
|
||||
})),
|
||||
getCurrentModelType: vi.fn(() => 'loras'),
|
||||
isValidModelType: vi.fn(() => true),
|
||||
DOWNLOAD_ENDPOINTS: {},
|
||||
HF_ENDPOINTS: {},
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||
sidebarManager: { refresh: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('BaseModelApiClient.fetchUnifiedFolderTree', () => {
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
async function createClient() {
|
||||
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||
class TestClient extends BaseModelApiClient {}
|
||||
return new TestClient('loras');
|
||||
}
|
||||
|
||||
it('requests the plain endpoint by default', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, tree: {} }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
await client.fetchUnifiedFolderTree();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/unified-folder-tree');
|
||||
});
|
||||
|
||||
it('appends include_empty=1 when requested', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, tree: {} }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
await client.fetchUnifiedFolderTree({ includeEmpty: true });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/unified-folder-tree?include_empty=1');
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,12 @@ describe('MoveManager', () => {
|
||||
expect(moveManager.folderTreeManager.getSelectedPath()).toBe('');
|
||||
});
|
||||
|
||||
it('should fetch the folder tree including empty directories', async () => {
|
||||
await moveManager.initializeFolderTree();
|
||||
|
||||
expect(mockApiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
|
||||
});
|
||||
|
||||
it('should ignore manual folder selection when useDefaultPath is true', async () => {
|
||||
// Setup state
|
||||
moveManager.useDefaultPath = true;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
DOWNLOAD_MANAGER_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
LOADING_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
FOLDER_TREE_MANAGER_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
SUMMARY_MODULE,
|
||||
mockApiClient,
|
||||
mockLoadingManager,
|
||||
mockFolderTreeManager,
|
||||
showToastMock,
|
||||
} = vi.hoisted(() => {
|
||||
const mockApiClient = {
|
||||
modelType: 'loras',
|
||||
apiConfig: {
|
||||
config: {
|
||||
displayName: 'LoRA',
|
||||
singularName: 'lora',
|
||||
},
|
||||
},
|
||||
fetchModelRoots: vi.fn(async () => ({ roots: ['/models/loras'] })),
|
||||
fetchUnifiedFolderTree: vi.fn(async () => ({ success: true, tree: {} })),
|
||||
};
|
||||
|
||||
const mockLoadingManager = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||
showCancelButton: vi.fn(),
|
||||
};
|
||||
|
||||
const mockFolderTreeManager = {
|
||||
clearSelection: vi.fn(),
|
||||
init: vi.fn(),
|
||||
loadTree: vi.fn(async () => {}),
|
||||
getSelectedPath: vi.fn(() => ''),
|
||||
};
|
||||
|
||||
return {
|
||||
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||
mockApiClient,
|
||||
mockLoadingManager,
|
||||
mockFolderTreeManager,
|
||||
showToastMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
global: {
|
||||
settings: {},
|
||||
},
|
||||
loadingManager: mockLoadingManager,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||
LoadingManager: vi.fn(() => mockLoadingManager),
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => mockApiClient),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||
FolderTreeManager: vi.fn(() => mockFolderTreeManager),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||
}));
|
||||
|
||||
vi.mock(SUMMARY_MODULE, () => ({
|
||||
showDownloadBatchSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DownloadManager folder tree', () => {
|
||||
let DownloadManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should fetch the folder tree including empty directories', async () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.apiClient = mockApiClient;
|
||||
|
||||
await manager.initializeFolderTree();
|
||||
|
||||
expect(mockApiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
|
||||
expect(mockFolderTreeManager.loadTree).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
@@ -97,3 +97,102 @@ async def test_model_query_handler_search_tags_clamps_negative_limit():
|
||||
)
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
|
||||
class DummyFolderCache:
|
||||
def __init__(self, folders):
|
||||
self.folders = list(folders)
|
||||
|
||||
|
||||
class DummyFolderService:
|
||||
"""Minimal service stub for the folders/tree endpoints."""
|
||||
|
||||
def __init__(self, folders, all_folders):
|
||||
self.scanner = SimpleNamespace()
|
||||
cache = DummyFolderCache(folders)
|
||||
|
||||
async def get_cached_data(*_, **__):
|
||||
return cache
|
||||
|
||||
async def get_all_folders():
|
||||
return list(all_folders)
|
||||
|
||||
self.scanner.get_cached_data = get_cached_data
|
||||
self.scanner.get_all_folders = get_all_folders
|
||||
self.received_include_empty = None
|
||||
|
||||
async def get_folder_tree(self, model_root, include_empty=False):
|
||||
self.received_include_empty = include_empty
|
||||
return {"tree": "per-root"}
|
||||
|
||||
async def get_unified_folder_tree(self, include_empty=False):
|
||||
self.received_include_empty = include_empty
|
||||
return {"tree": "unified"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_folders_defaults_to_models_only_folders():
|
||||
service = DummyFolderService(["a"], ["a", "empty"])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.get_folders(
|
||||
SimpleNamespace(query={}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["folders"] == ["a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_folders_include_empty_returns_all_folders():
|
||||
service = DummyFolderService(["a"], ["a", "empty"])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.get_folders(
|
||||
SimpleNamespace(query={"include_empty": "1"}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["folders"] == ["a", "empty"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unified_folder_tree_threads_include_empty():
|
||||
service = DummyFolderService(["a"], ["a", "empty"])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.get_unified_folder_tree(
|
||||
SimpleNamespace(query={"include_empty": "1"}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert service.received_include_empty is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unified_folder_tree_defaults_include_empty_false():
|
||||
service = DummyFolderService(["a"], ["a", "empty"])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.get_unified_folder_tree(
|
||||
SimpleNamespace(query={}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert service.received_include_empty is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_folder_tree_threads_include_empty():
|
||||
service = DummyFolderService(["a"], ["a", "empty"])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.get_folder_tree(
|
||||
SimpleNamespace(query={"model_root": "/models/loras", "include_empty": "true"}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert service.received_include_empty is True
|
||||
|
||||
@@ -1394,3 +1394,71 @@ class TestApplyHashFilters:
|
||||
result = await service._apply_hash_filters(data, {})
|
||||
|
||||
assert result == data
|
||||
|
||||
|
||||
class FolderTreeCache:
|
||||
def __init__(self, folders):
|
||||
self.folders = list(folders)
|
||||
|
||||
|
||||
class FolderTreeScanner:
|
||||
def __init__(self, cache, all_folders=None):
|
||||
self._cache = cache
|
||||
self._all_folders = list(all_folders) if all_folders is not None else None
|
||||
|
||||
async def get_cached_data(self, *_, **__):
|
||||
return self._cache
|
||||
|
||||
async def get_all_folders(self):
|
||||
if self._all_folders is None:
|
||||
raise AssertionError("get_all_folders should not be called")
|
||||
return list(self._all_folders)
|
||||
|
||||
def get_model_roots(self):
|
||||
return ["/models/loras"]
|
||||
|
||||
|
||||
def _make_folder_tree_service(folders, all_folders=None):
|
||||
cache = FolderTreeCache(folders)
|
||||
scanner = FolderTreeScanner(cache, all_folders)
|
||||
settings = StubSettings({})
|
||||
return DummyService(
|
||||
model_type="stub",
|
||||
scanner=scanner,
|
||||
metadata_class=BaseModelMetadata,
|
||||
cache_repository=ModelCacheRepository(scanner),
|
||||
filter_set=ModelFilterSet(settings),
|
||||
search_strategy=SearchStrategy(),
|
||||
settings_provider=settings,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unified_folder_tree_defaults_to_models_only_folders():
|
||||
service = _make_folder_tree_service(["a/b"])
|
||||
|
||||
tree = await service.get_unified_folder_tree()
|
||||
|
||||
assert tree == {"a": {"b": {}}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unified_folder_tree_include_empty_uses_live_enumeration():
|
||||
service = _make_folder_tree_service(["a/b"], ["a/b", "empty", "empty/dir"])
|
||||
|
||||
tree = await service.get_unified_folder_tree(include_empty=True)
|
||||
|
||||
assert tree == {"a": {"b": {}}, "empty": {"dir": {}}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_folder_tree_include_empty_uses_live_enumeration():
|
||||
service = _make_folder_tree_service(["a/b"], ["a/b", "empty"])
|
||||
|
||||
default_tree = await service.get_folder_tree("/models/loras")
|
||||
include_empty_tree = await service.get_folder_tree(
|
||||
"/models/loras", include_empty=True
|
||||
)
|
||||
|
||||
assert default_tree == {"a": {"b": {}}}
|
||||
assert include_empty_tree == {"a": {"b": {}}, "empty": {}}
|
||||
|
||||
@@ -1210,3 +1210,111 @@ async def test_bulk_delete_cancelled_after_one_staged_batch_present(
|
||||
assert not first.exists()
|
||||
# The second file was never touched.
|
||||
assert second.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_enumerates_empty_directories_live(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
(tmp_path / "empty").mkdir()
|
||||
(tmp_path / "empty" / "nested_empty").mkdir()
|
||||
(tmp_path / ".hidden").mkdir()
|
||||
(tmp_path / "visible" / ".hidden_child").mkdir(parents=True)
|
||||
(tmp_path / PENDING_DELETE_DIR_NAME).mkdir()
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
all_folders = await scanner.get_all_folders()
|
||||
|
||||
# cache.folders stays models-only
|
||||
assert sorted(cache.folders) == ["", "nested"]
|
||||
|
||||
# Live enumeration includes empty directories and stays a superset
|
||||
assert set(cache.folders) <= set(all_folders)
|
||||
assert "empty" in all_folders
|
||||
assert "empty/nested_empty" in all_folders
|
||||
assert "visible" in all_folders
|
||||
|
||||
# Hidden directories (any segment starting with '.') are excluded
|
||||
assert not any(
|
||||
segment.startswith(".")
|
||||
for folder in all_folders
|
||||
for segment in folder.split("/")
|
||||
)
|
||||
# The pending-delete staging dir is excluded
|
||||
assert PENDING_DELETE_DIR_NAME not in all_folders
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_uses_ttl_cache(tmp_path: Path, monkeypatch):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
walk_calls = {"n": 0}
|
||||
real_walk = os.walk
|
||||
|
||||
def counting_walk(*args, **kwargs):
|
||||
walk_calls["n"] += 1
|
||||
return real_walk(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(model_scanner.os, "walk", counting_walk)
|
||||
|
||||
first = await scanner.get_all_folders()
|
||||
assert walk_calls["n"] == 1
|
||||
|
||||
# Second call within the TTL reuses the cached result without re-walking
|
||||
second = await scanner.get_all_folders()
|
||||
assert walk_calls["n"] == 1
|
||||
assert second == first
|
||||
|
||||
# After the TTL expires the roots are walked again
|
||||
real_monotonic = time.monotonic
|
||||
monkeypatch.setattr(
|
||||
model_scanner.time,
|
||||
"monotonic",
|
||||
lambda: real_monotonic() + model_scanner.ALL_FOLDERS_CACHE_TTL_SECONDS + 1,
|
||||
)
|
||||
third = await scanner.get_all_folders()
|
||||
assert walk_calls["n"] == 2
|
||||
assert third == first
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_invalidated_after_move(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
|
||||
await scanner._initialize_cache()
|
||||
|
||||
cached = await scanner.get_all_folders()
|
||||
assert scanner._all_folders_ttl_cache is not None
|
||||
assert "new/deep" not in cached
|
||||
|
||||
# Simulate a move: target directories exist on disk (created by
|
||||
# os.makedirs in move_model) and the cache entry is relocated.
|
||||
(tmp_path / "new" / "deep").mkdir(parents=True)
|
||||
original = _normalize_path(first)
|
||||
new_path = _normalize_path(tmp_path / "new" / "deep" / "one.txt")
|
||||
moved_metadata = {
|
||||
"file_path": new_path,
|
||||
"file_name": "one",
|
||||
"model_name": "one",
|
||||
"sha256": "hash-one",
|
||||
"tags": ["alpha"],
|
||||
"size": 1,
|
||||
"modified": 1.0,
|
||||
}
|
||||
|
||||
await scanner.update_single_model_cache(original, new_path, moved_metadata)
|
||||
|
||||
# The TTL cache was invalidated by the move
|
||||
assert scanner._all_folders_ttl_cache is None
|
||||
|
||||
all_folders = await scanner.get_all_folders()
|
||||
cache = await scanner.get_cached_data()
|
||||
assert sorted(cache.folders) == ["nested", "new/deep"]
|
||||
assert "new" in all_folders
|
||||
assert "new/deep" in all_folders
|
||||
assert set(cache.folders) <= set(all_folders)
|
||||
|
||||
Reference in New Issue
Block a user