import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; const showToastMock = vi.fn(); const showActionToastMock = vi.fn(); const handleUndoDeleteMock = vi.fn(); const resetAndReloadMock = vi.fn(); vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: showToastMock, showActionToast: showActionToastMock, })); vi.mock('../../../static/js/utils/undoHelpers.js', () => ({ handleUndoDelete: handleUndoDeleteMock, })); vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ resetAndReload: resetAndReloadMock, })); vi.mock('../../../static/js/utils/modalUtils.js', () => ({})); const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js'); const { state } = await import('../../../static/js/state/index.js'); const carPath = '/models/loras/aspark-owl.safetensors'; const copyPath = '/models/loras/aspark-owl-copy.safetensors'; const stalePath = '/models/loras/old-mismatch.safetensors'; function createModel(filePath, sha256, modelName = 'Aspark Owl - 2019') { return { file_path: filePath, file_name: filePath.split('/').pop(), model_name: modelName, sha256, preview_url: '', preview_nsfw_level: 0, modified: Date.now(), civitai: { name: 'Version 1' }, }; } function createGroup(hash = 'actual-hash') { return { hash, models: [ createModel(carPath, hash), createModel(copyPath, hash, 'Aspark Owl - 2019 Copy'), ], }; } async function createManager() { document.body.innerHTML = `
`; global.fetch = vi.fn().mockResolvedValue({ ok: true, statusText: 'OK', json: async () => ({ success: true, duplicates: [] }), }); const manager = new ModelDuplicatesManager({}, 'loras'); await Promise.resolve(); await Promise.resolve(); global.fetch.mockClear(); return manager; } beforeEach(() => { vi.clearAllMocks(); state.loadingManager = { showSimpleLoading: vi.fn(), hide: vi.fn(), }; }); afterEach(() => { vi.restoreAllMocks(); state.loadingManager = null; }); describe('ModelDuplicatesManager verification state', () => { it('clears stale Different Hash state when a later verification confirms the group is duplicate', async () => { const manager = await createManager(); const group = createGroup(); manager.duplicateGroups = [group]; manager.mismatchedFiles.set(carPath, 'old-actual-hash'); manager.renderDuplicateGroups(); expect(document.querySelector(`[data-file-path="${carPath}"]`).classList.contains('hash-mismatch')).toBe(true); global.fetch.mockResolvedValueOnce({ ok: true, statusText: 'OK', json: async () => ({ success: true, verified_as_duplicates: true, mismatched_files: [], new_hash_map: {}, }), }); await manager.handleVerifyHashes(group); const carCard = document.querySelector(`[data-file-path="${carPath}"]`); const carCheckbox = carCard.querySelector('.selector-checkbox'); expect(manager.mismatchedFiles.has(carPath)).toBe(false); expect(carCard.classList.contains('hash-mismatch')).toBe(false); expect(carCard.querySelector('.mismatch-badge')).toBeNull(); expect(carCheckbox.disabled).toBe(false); }); it('keeps showing Different Hash for files returned as mismatched by the current verification', async () => { const manager = await createManager(); const group = createGroup('metadata-hash'); manager.duplicateGroups = [group]; manager.selectedForDeletion.add(carPath); manager.selectedForDeletion.add(copyPath); global.fetch.mockResolvedValueOnce({ ok: true, statusText: 'OK', json: async () => ({ success: true, verified_as_duplicates: false, mismatched_files: [carPath], new_hash_map: { [carPath]: 'actual-car-hash', }, }), }); await manager.handleVerifyHashes(group); const carCard = document.querySelector(`[data-file-path="${carPath}"]`); const carCheckbox = carCard.querySelector('.selector-checkbox'); expect(manager.mismatchedFiles.get(carPath)).toBe('actual-car-hash'); expect(manager.selectedForDeletion.has(carPath)).toBe(false); expect(manager.selectedForDeletion.has(copyPath)).toBe(true); expect(carCard.classList.contains('hash-mismatch')).toBe(true); expect(carCard.querySelector('.mismatch-badge')?.textContent).toContain('Different Hash'); expect(carCheckbox.disabled).toBe(true); }); it('refreshes selected count and delete button when selected files become mismatched', async () => { const manager = await createManager(); const group = createGroup('metadata-hash'); manager.duplicateGroups = [group]; manager.selectedForDeletion.add(carPath); manager.updateSelectedCount(); expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('1'); expect(document.querySelector('.btn-delete-selected').disabled).toBe(false); global.fetch.mockResolvedValueOnce({ ok: true, statusText: 'OK', json: async () => ({ success: true, verified_as_duplicates: false, mismatched_files: [carPath], new_hash_map: { [carPath]: 'actual-car-hash', }, }), }); await manager.handleVerifyHashes(group); expect(manager.selectedForDeletion.size).toBe(0); expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('0'); expect(document.querySelector('.btn-delete-selected').disabled).toBe(true); expect(document.querySelector('.btn-delete-selected').classList.contains('disabled')).toBe(true); }); it('preserves valid selected deletion candidates when verification succeeds', async () => { const manager = await createManager(); const group = createGroup(); manager.duplicateGroups = [group]; manager.selectedForDeletion.add(carPath); manager.selectedForDeletion.add(copyPath); global.fetch.mockResolvedValueOnce({ ok: true, statusText: 'OK', json: async () => ({ success: true, verified_as_duplicates: true, mismatched_files: [], new_hash_map: {}, }), }); await manager.handleVerifyHashes(group); expect(manager.selectedForDeletion.has(carPath)).toBe(true); expect(manager.selectedForDeletion.has(copyPath)).toBe(true); expect(document.querySelector(`[data-file-path="${carPath}"] .selector-checkbox`).checked).toBe(true); expect(document.querySelector(`[data-file-path="${copyPath}"] .selector-checkbox`).checked).toBe(true); }); it('prunes mismatch and verified state that no longer belongs to refreshed duplicate groups', async () => { const manager = await createManager(); const visibleGroup = createGroup('visible-hash'); manager.mismatchedFiles.set(stalePath, 'stale-hash'); manager.mismatchedFiles.set(carPath, 'visible-mismatch'); manager.verifiedGroups.add('stale-group-hash'); manager.verifiedGroups.add('visible-hash'); global.fetch.mockResolvedValueOnce({ ok: true, statusText: 'OK', json: async () => ({ success: true, duplicates: [visibleGroup], }), }); await manager.findDuplicates(); expect(manager.mismatchedFiles.has(stalePath)).toBe(false); expect(manager.mismatchedFiles.has(carPath)).toBe(true); expect(manager.verifiedGroups.has('stale-group-hash')).toBe(false); expect(manager.verifiedGroups.has('visible-hash')).toBe(true); }); }); describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => { function mockDeleteAndRecheck(deletePayload) { global.fetch = vi.fn((url) => { if (String(url).includes('bulk-delete')) { return Promise.resolve({ ok: true, statusText: 'OK', json: async () => deletePayload, }); } return Promise.resolve({ ok: true, statusText: 'OK', json: async () => ({ success: true, duplicates: [] }), }); }); } function lastActionToastOptions() { const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1]; return call[3]; } beforeEach(() => { handleUndoDeleteMock.mockResolvedValue(true); state.virtualScroller = { enable: vi.fn(), disable: vi.fn() }; globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() }; }); afterEach(() => { state.virtualScroller = null; delete globalThis.modalManager; }); it('shows the undo action toast with the batch id and refreshes models on undo', async () => { const manager = await createManager(); mockDeleteAndRecheck({ success: true, total_deleted: 1, batch_id: 'model-batch-1' }); manager.inDuplicateMode = true; manager.selectedForDeletion.add(carPath); await manager.confirmDeleteDuplicates(); expect(showActionToastMock).toHaveBeenCalledTimes(1); expect(showActionToastMock).toHaveBeenCalledWith( 'toast.undo.deletedBulk', { count: 1 }, 'success', expect.objectContaining({ actionText: 'toast.undo.action', onAction: expect.any(Function), }) ); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.duplicates.deleteSuccess', expect.anything(), expect.anything() ); // The existing reset + find-duplicates re-check path still runs expect(resetAndReloadMock).toHaveBeenCalledWith(true); // No remaining duplicates -> duplicate mode exited expect(manager.inDuplicateMode).toBe(false); lastActionToastOptions().onAction(); expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1); expect(handleUndoDeleteMock).toHaveBeenCalledWith('model-batch-1', expect.any(Function)); const refreshFn = handleUndoDeleteMock.mock.calls[0][1]; resetAndReloadMock.mockClear(); refreshFn(); expect(resetAndReloadMock).toHaveBeenCalledTimes(1); expect(resetAndReloadMock).toHaveBeenCalledWith(true); }); it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => { const manager = await createManager(); mockDeleteAndRecheck({ success: true, total_deleted: 2, batch_ids: ['mb-1', 'mb-2'] }); manager.inDuplicateMode = true; manager.selectedForDeletion.add(carPath); manager.selectedForDeletion.add(copyPath); await manager.confirmDeleteDuplicates(); expect(showActionToastMock).toHaveBeenCalledTimes(1); resetAndReloadMock.mockClear(); await lastActionToastOptions().onAction(); expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2); expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['mb-1', null, { showToast: false, refresh: false }]); expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['mb-2', null, { showToast: false, refresh: false }]); expect(resetAndReloadMock).toHaveBeenCalledTimes(1); expect(resetAndReloadMock).toHaveBeenCalledWith(true); expect(showToastMock).toHaveBeenCalledTimes(1); expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success'); }); it('keeps the legacy success toast when the response carries no batch field', async () => { const manager = await createManager(); mockDeleteAndRecheck({ success: true, total_deleted: 1 }); manager.inDuplicateMode = true; manager.selectedForDeletion.add(carPath); await manager.confirmDeleteDuplicates(); expect(showActionToastMock).not.toHaveBeenCalled(); expect(showToastMock).toHaveBeenCalledWith( 'toast.duplicates.deleteSuccess', { count: 1, type: 'loras' }, 'success' ); }); });