Files
ComfyUI-Lora-Manager/tests/frontend/managers/MoveManager.test.js
T
Will Miao a03dc4002f fix(move): recalculate sub_type when moving models across roots
Moving a checkpoint into a unet root (or vice versa) moved the file and
updated the in-memory cache, but three stale spots survived until a
manual cache rebuild:

- The moved .metadata.json kept the old sub_type, and the opportunistic
  sync_cache_from_metadata path (fired by get_model_metadata and example
  image metadata updates) trusted it, reverting the cache entry and the
  SQLite snapshot to the pre-move sub_type. Loader nodes filter strictly
  on sub_type, so the model stayed listed under the old type.
- The manager page discarded the move response's cache_entry, so the
  card badge (CKPT/DM) and context menu label kept showing the old type.

Fixes:
- move_model now re-resolves sub_type from the target location (new
  resolve_sub_type_for_path hook) and persists it into the moved
  .metadata.json.
- _sync_cache_from_metadata_impl runs desired entries through
  adjust_cached_entry so location-derived fields cannot be re-poisoned
  by stale metadata snapshots.
- MoveManager carries cache_entry.sub_type into the in-place card
  update so badge and context menu reflect the new type immediately.
2026-09-09 17:28:41 +08:00

238 lines
8.9 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { moveManager } from '../../../static/js/managers/MoveManager.js';
import { state } from '../../../static/js/state/index.js';
import { modalManager } from '../../../static/js/managers/ModalManager.js';
import { getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
import * as storageHelpers from '../../../static/js/utils/storageHelpers.js';
// Mock dependencies
vi.mock('../../../static/js/state/index.js', () => ({
state: {
currentPageType: 'loras',
selectedModels: new Set(),
global: {
settings: {
download_path_templates: {
lora: '{base_model}/unstaged'
}
}
}
},
getCurrentPageState: vi.fn(() => ({ activeFolder: null, searchOptions: {} }))
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn()
}
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn()
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(),
setStorageItem: vi.fn()
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn()
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn(key => key)
}));
describe('MoveManager', () => {
let mockApiClient;
beforeEach(() => {
vi.clearAllMocks();
// Setup DOM
document.body.innerHTML = `
<div id="moveModal">
<h2 id="moveModalTitle"></h2>
<label id="moveRootLabel"></label>
<select id="moveModelRoot"></select>
<input type="checkbox" id="moveUseDefaultPath" />
<div id="moveManualPathSelection">
<input id="moveFolderPath" />
<div id="moveFolderTree"></div>
</div>
<div id="moveTargetPathDisplay"><span class="path-text"></span></div>
</div>
`;
mockApiClient = {
apiConfig: {
config: {
displayName: 'LoRA',
supportsMove: true
},
endpoints: {
moveModel: '/api/move'
}
},
modelType: 'loras',
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
moveSingleModel: vi.fn().mockResolvedValue({ success: true })
};
getModelApiClient.mockReturnValue(mockApiClient);
});
it('should reset folder selection when showing move modal', async () => {
// Manually set a selected path in folderTreeManager
moveManager.folderTreeManager.selectedPath = 'previous/path';
await moveManager.showMoveModal('some/file.safetensors');
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;
moveManager.currentFilePath = '/models/loras/flux/my-lora.safetensors';
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
// Manually set a selected path despite useDefaultPath being true
moveManager.folderTreeManager.selectedPath = 'wrong/folder';
await moveManager.moveModel();
// Should call moveSingleModel with the root path, NOT including the 'wrong/folder'
expect(mockApiClient.moveSingleModel).toHaveBeenCalledWith(
'/models/loras/flux/my-lora.safetensors',
'/models/loras',
true
);
});
it('should include manual folder selection when useDefaultPath is false', async () => {
// Setup state
moveManager.useDefaultPath = false;
moveManager.currentFilePath = '/models/loras/flux/my-lora.safetensors';
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
// Set a selected path
moveManager.folderTreeManager.selectedPath = 'my/organized/folder';
await moveManager.moveModel();
// Should call moveSingleModel with root + selected folder
expect(mockApiClient.moveSingleModel).toHaveBeenCalledWith(
'/models/loras/flux/my-lora.safetensors',
'/models/loras/my/organized/folder',
false
);
});
it('should handle bulk move and ignore manual folder selection when useDefaultPath is true', async () => {
// Setup state
moveManager.useDefaultPath = true;
moveManager.bulkFilePaths = [
'/models/loras/flux/lora1.safetensors',
'/models/loras/flux/lora2.safetensors'
];
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
// Manually set a selected path
moveManager.folderTreeManager.selectedPath = 'wrong/folder';
mockApiClient.moveBulkModels = vi.fn().mockResolvedValue({ success: true });
await moveManager.moveModel();
// Should call moveBulkModels with the root path, NOT including the 'wrong/folder'
expect(mockApiClient.moveBulkModels).toHaveBeenCalledWith(
moveManager.bulkFilePaths,
'/models/loras',
true
);
});
it('should propagate the recalculated sub_type from the move response to the card', async () => {
// Setup state: moving a checkpoint into the unet root
moveManager.useDefaultPath = false;
moveManager.bulkFilePaths = null;
moveManager.currentFilePath = '/models/checkpoints/model.safetensors';
moveManager.modelRoots = ['/models/checkpoints', '/models/unet'];
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/unet">/models/unet</option>';
document.getElementById('moveModelRoot').value = '/models/unet';
moveManager.folderTreeManager.selectedPath = '';
const updateSingleItem = vi.fn();
state.virtualScroller = {
updateSingleItem,
removeMultipleItemsByFilePath: vi.fn()
};
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
success: true,
original_file_path: '/models/checkpoints/model.safetensors',
new_file_path: '/models/unet/model.safetensors',
cache_entry: { sub_type: 'diffusion_model' }
});
try {
await moveManager.moveModel();
expect(updateSingleItem).toHaveBeenCalledWith(
'/models/checkpoints/model.safetensors',
expect.objectContaining({
file_path: '/models/unet/model.safetensors',
sub_type: 'diffusion_model'
})
);
} finally {
delete state.virtualScroller;
}
});
it('should omit sub_type from the card update when the response has no cache entry', async () => {
moveManager.useDefaultPath = false;
moveManager.bulkFilePaths = null;
moveManager.currentFilePath = '/models/loras/a.safetensors';
moveManager.modelRoots = ['/models/loras'];
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
moveManager.folderTreeManager.selectedPath = '';
const updateSingleItem = vi.fn();
state.virtualScroller = {
updateSingleItem,
removeMultipleItemsByFilePath: vi.fn()
};
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
success: true,
original_file_path: '/models/loras/a.safetensors',
new_file_path: '/models/loras/b/a.safetensors'
});
try {
await moveManager.moveModel();
expect(updateSingleItem).toHaveBeenCalledWith(
'/models/loras/a.safetensors',
expect.not.objectContaining({ sub_type: expect.anything() })
);
} finally {
delete state.virtualScroller;
}
});
});