mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -03:00
fix(duplicates): clear stale hash mismatch state (#900)
This commit is contained in:
@@ -121,6 +121,7 @@ export class ModelDuplicatesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.duplicateGroups = data.duplicates || [];
|
this.duplicateGroups = data.duplicates || [];
|
||||||
|
this._pruneVerificationState();
|
||||||
|
|
||||||
// Update the badge with the current count
|
// Update the badge with the current count
|
||||||
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||||
@@ -403,6 +404,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) {
|
renderModelCard(model, groupHash) {
|
||||||
// Create basic card structure
|
// Create basic card structure
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -619,10 +658,11 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
toggleSelectAllInGroup(hash) {
|
toggleSelectAllInGroup(hash) {
|
||||||
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${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
|
// If all are selected, deselect all; otherwise select all
|
||||||
checkboxes.forEach(checkbox => {
|
selectableCheckboxes.forEach(checkbox => {
|
||||||
checkbox.checked = !allSelected;
|
checkbox.checked = !allSelected;
|
||||||
const filePath = checkbox.dataset.filePath;
|
const filePath = checkbox.dataset.filePath;
|
||||||
const card = checkbox.closest('.model-card');
|
const card = checkbox.closest('.model-card');
|
||||||
@@ -831,10 +871,13 @@ export class ModelDuplicatesManager {
|
|||||||
const verifiedAsDuplicates = data.verified_as_duplicates;
|
const verifiedAsDuplicates = data.verified_as_duplicates;
|
||||||
const mismatchedFiles = data.mismatched_files || [];
|
const mismatchedFiles = data.mismatched_files || [];
|
||||||
|
|
||||||
|
this._clearMismatchStateForGroup(group);
|
||||||
|
|
||||||
// Update mismatchedFiles map
|
// Update mismatchedFiles map
|
||||||
if (data.new_hash_map) {
|
if (data.new_hash_map) {
|
||||||
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
|
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
|
||||||
this.mismatchedFiles.set(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
|
// Re-render the duplicate groups to show verification status
|
||||||
this.renderDuplicateGroups();
|
this.renderDuplicateGroups();
|
||||||
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Show appropriate toast message
|
// Show appropriate toast message
|
||||||
if (mismatchedFiles.length > 0) {
|
if (mismatchedFiles.length > 0) {
|
||||||
|
|||||||
232
tests/frontend/components/modelDuplicatesManager.test.js
Normal file
232
tests/frontend/components/modelDuplicatesManager.test.js
Normal file
@@ -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 = `
|
||||||
|
<div id="modelGrid"></div>
|
||||||
|
<span id="duplicatesBadge"></span>
|
||||||
|
<span id="duplicatesSelectedCount"></span>
|
||||||
|
<button class="btn-delete-selected"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user