From ecf7ea21e407167ea837609ea0c2b20e530cc331 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 21 Apr 2026 16:22:04 +0800 Subject: [PATCH] fix(duplicates): clear stale hash mismatch state (#900) --- .../js/components/ModelDuplicatesManager.js | 48 +++- .../components/modelDuplicatesManager.test.js | 232 ++++++++++++++++++ 2 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 tests/frontend/components/modelDuplicatesManager.test.js diff --git a/static/js/components/ModelDuplicatesManager.js b/static/js/components/ModelDuplicatesManager.js index 9e6cd209..7c0f7ee2 100644 --- a/static/js/components/ModelDuplicatesManager.js +++ b/static/js/components/ModelDuplicatesManager.js @@ -121,6 +121,7 @@ export class ModelDuplicatesManager { } this.duplicateGroups = data.duplicates || []; + this._pruneVerificationState(); // Update the badge with the current count this.updateDuplicatesBadge(this.duplicateGroups.length); @@ -402,6 +403,44 @@ export class ModelDuplicatesManager { } }); } + + _getGroupFilePaths(group) { + return new Set((group?.models || []).map(model => model.file_path)); + } + + _clearMismatchStateForGroup(group) { + this._getGroupFilePaths(group).forEach(filePath => { + this.mismatchedFiles.delete(filePath); + }); + } + + _pruneVerificationState() { + const visiblePaths = new Set(); + const visibleHashes = new Set(); + + this.duplicateGroups.forEach(group => { + visibleHashes.add(group.hash); + this._getGroupFilePaths(group).forEach(filePath => visiblePaths.add(filePath)); + }); + + Array.from(this.mismatchedFiles.keys()).forEach(filePath => { + if (!visiblePaths.has(filePath)) { + this.mismatchedFiles.delete(filePath); + } + }); + + Array.from(this.selectedForDeletion).forEach(filePath => { + if (!visiblePaths.has(filePath)) { + this.selectedForDeletion.delete(filePath); + } + }); + + Array.from(this.verifiedGroups).forEach(hash => { + if (!visibleHashes.has(hash)) { + this.verifiedGroups.delete(hash); + } + }); + } renderModelCard(model, groupHash) { // Create basic card structure @@ -619,10 +658,11 @@ export class ModelDuplicatesManager { toggleSelectAllInGroup(hash) { const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`); - const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked); + const selectableCheckboxes = Array.from(checkboxes).filter(checkbox => !checkbox.disabled); + const allSelected = selectableCheckboxes.length > 0 && selectableCheckboxes.every(checkbox => checkbox.checked); // If all are selected, deselect all; otherwise select all - checkboxes.forEach(checkbox => { + selectableCheckboxes.forEach(checkbox => { checkbox.checked = !allSelected; const filePath = checkbox.dataset.filePath; const card = checkbox.closest('.model-card'); @@ -830,11 +870,14 @@ export class ModelDuplicatesManager { // Process verification results const verifiedAsDuplicates = data.verified_as_duplicates; const mismatchedFiles = data.mismatched_files || []; + + this._clearMismatchStateForGroup(group); // Update mismatchedFiles map if (data.new_hash_map) { Object.entries(data.new_hash_map).forEach(([path, hash]) => { this.mismatchedFiles.set(path, hash); + this.selectedForDeletion.delete(path); }); } @@ -843,6 +886,7 @@ export class ModelDuplicatesManager { // Re-render the duplicate groups to show verification status this.renderDuplicateGroups(); + this.updateSelectedCount(); // Show appropriate toast message if (mismatchedFiles.length > 0) { diff --git a/tests/frontend/components/modelDuplicatesManager.test.js b/tests/frontend/components/modelDuplicatesManager.test.js new file mode 100644 index 00000000..4bc47244 --- /dev/null +++ b/tests/frontend/components/modelDuplicatesManager.test.js @@ -0,0 +1,232 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const resetAndReloadMock = vi.fn(); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + resetAndReload: resetAndReloadMock, +})); + +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); + }); +});