Files
Will Miao 72705c570e fix(move): stop drag-and-drop from nesting the target under the source dir (#1148)
The sidebar drag-and-drop derived the library root by stripping the card's
cached folder off its file path. Any disagreement between the two -- a stale
or wrong-cased folder recorded by an older version -- made the strip fail and
silently returned the file's own directory as the root, so the move created
<current dir>/<target path> on disk and physically nested the library.

Two defenses:

* SidebarManager now resolves the move root from the authoritative library
  roots (longest prefix match) and only falls back to the card heuristic,
  which compares case-insensitively and aborts the move on a real mismatch
  instead of fabricating a root.
* Reconcile repairs existing entries whose folder no longer matches where
  their file_path sits. Refresh never rewrote existing rows, so folders
  recorded wrong by 1.2.2-1.2.5 kept poisoning moves forever; now a plain
  Refresh heals them (no manual Rebuild Cache needed).
2026-10-17 19:39:37 +08:00

171 lines
6.3 KiB
JavaScript

import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
SIDEBAR_MANAGER_MODULE,
MODEL_API_FACTORY_MODULE,
I18N_MODULE,
BULK_MANAGER_MODULE,
UI_HELPERS_MODULE,
UPDATE_CHECK_MODULE,
STATE_MODULE,
MODAL_MANAGER_MODULE,
} = vi.hoisted(() => ({
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
}));
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
const { showToast } = await import(UI_HELPERS_MODULE);
const { state } = await import(STATE_MODULE);
const ROOT = '/models/loras';
const SOURCE_FILE = `${ROOT}/anima/character/x.safetensors`;
const TARGET = 'anima/series/test';
function createManager(moveSingleModel, { roots = [ROOT] } = {}) {
const manager = new SidebarManager();
manager.pageType = 'loras';
manager.displayMode = 'tree';
manager.apiClient = {
apiConfig: { config: { supportsMove: true } },
moveSingleModel,
fetchModelRoots: roots instanceof Error
? vi.fn().mockRejectedValue(roots)
: vi.fn().mockResolvedValue({ roots }),
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
fetchModelFolders: vi.fn().mockResolvedValue({ folders: [] }),
};
manager.pageControls = { pageState: { searchOptions: {} } };
return manager;
}
function buildDom() {
document.body.innerHTML = `
<div class="folder-sidebar" id="folderSidebar">
<div class="sidebar-tree-container">
<div class="sidebar-tree" id="sidebarFolderTree"></div>
</div>
</div>
<div id="modelGrid">
<div class="model-card"
data-filepath="${SOURCE_FILE}"
data-name="X" data-file_name="x.safetensors">
</div>
</div>
`;
}
function createDragEvent(target) {
return {
target,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
setDragImage: vi.fn(),
},
};
}
function dragCard(manager, folderValue) {
const card = document.querySelector('.model-card');
if (folderValue !== undefined) {
card.dataset.folder = folderValue;
}
manager.handleCardDragStart(createDragEvent(card));
}
describe('issue #1148: drag-drop move root resolution', () => {
beforeEach(() => {
localStorage.clear();
buildDom();
state.bulkMode = false;
state.selectedModels = new Set();
state.virtualScroller = null;
vi.clearAllMocks();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('consistent card: destination is root + target', async () => {
const moveSingleModel = vi.fn().mockResolvedValue({ success: true, new_file_path: `${ROOT}/${TARGET}/x.safetensors` });
const manager = createManager(moveSingleModel);
dragCard(manager, 'anima/character');
await manager.performDragMove(TARGET);
expect(moveSingleModel).toHaveBeenCalledWith(SOURCE_FILE, `${ROOT}/${TARGET}`);
});
it('polluted card (folder=""): root still resolves from the library roots', async () => {
const moveSingleModel = vi.fn().mockResolvedValue({ success: true, new_file_path: `${ROOT}/${TARGET}/x.safetensors` });
const manager = createManager(moveSingleModel);
dragCard(manager, '');
await manager.performDragMove(TARGET);
expect(moveSingleModel).toHaveBeenCalledWith(SOURCE_FILE, `${ROOT}/${TARGET}`);
});
it('polluted card (folder case mismatch): root still resolves from the library roots', async () => {
const moveSingleModel = vi.fn().mockResolvedValue({ success: true, new_file_path: `${ROOT}/${TARGET}/x.safetensors` });
const manager = createManager(moveSingleModel);
dragCard(manager, 'Anima/Character');
await manager.performDragMove(TARGET);
expect(moveSingleModel).toHaveBeenCalledWith(SOURCE_FILE, `${ROOT}/${TARGET}`);
});
it('roots API down + mismatched folder: move aborts instead of nesting the target', async () => {
const moveSingleModel = vi.fn();
const manager = createManager(moveSingleModel, { roots: new Error('offline') });
dragCard(manager, 'somewhere/else');
const result = await manager.performDragMove(TARGET);
expect(result).toBe(false);
expect(moveSingleModel).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalled();
});
it('roots API down + consistent card: heuristic still resolves the root', async () => {
const moveSingleModel = vi.fn().mockResolvedValue({ success: true, new_file_path: `${ROOT}/${TARGET}/x.safetensors` });
const manager = createManager(moveSingleModel, { roots: new Error('offline') });
dragCard(manager, 'anima/character');
await manager.performDragMove(TARGET);
expect(moveSingleModel).toHaveBeenCalledWith(SOURCE_FILE, `${ROOT}/${TARGET}`);
});
it('roots API down + case-only mismatch: heuristic resolves case-insensitively', async () => {
const moveSingleModel = vi.fn().mockResolvedValue({ success: true, new_file_path: `${ROOT}/${TARGET}/x.safetensors` });
const manager = createManager(moveSingleModel, { roots: new Error('offline') });
dragCard(manager, 'Anima/Character');
await manager.performDragMove(TARGET);
expect(moveSingleModel).toHaveBeenCalledWith(SOURCE_FILE, `${ROOT}/${TARGET}`);
});
});