Shared chrome borrowed the model-library nouns on the Recipes and Workflows pages: - The header filter panel is titled per page (`recipes.filter.title`, `workflows.filter.title`) instead of "Filter Models". - Bulk guards and move failures use page-specific copy (`recipes.bulk.noSelection`, `recipes.move.failed`, `workflows.bulk.noSelection`) instead of "No models selected" / "Failed to move model(s)", covering the bulk menu, the sidebar drag-and-drop path and the recipe move client. - Workflows got a bulk-action config, so model-only bulk entries (send to workflow, auto-organize, re-import/rematch, copy syntax) stay hidden instead of appearing unfiltered. - The workflows page's global context menu no longer offers the model-only example-image entries. - MoveManager no longer assumes every API client exposes `modelType`: the recipe client has none, which made the "use default path" preview throw and stored the toggle under `use_default_path_undefined`. Recipes card copy is translatable now too: the card tooltips (favorites, share, send, delete, blur, NSFW levels) and the delete dialog go through i18n instead of hard-coded English. 10 new keys (the move-only noSelection key folded into `workflows.bulk.noSelection`), translated in all 9 locales; no [TODO: Translate] placeholder remains.
293 lines
11 KiB
JavaScript
293 lines
11 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()
|
|
}));
|
|
|
|
const { showToastMock } = vi.hoisted(() => ({ showToastMock: vi.fn() }));
|
|
|
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|
showToast: showToastMock
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
|
translate: vi.fn((key, params = {}, fallback = null) => fallback || key)
|
|
}));
|
|
|
|
describe('MoveManager', () => {
|
|
let mockApiClient;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
state.currentPageType = 'loras';
|
|
state.selectedModels.clear();
|
|
|
|
// 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 name workflows in the move dialog on the workflows page', async () => {
|
|
state.currentPageType = 'workflows';
|
|
mockApiClient.modelType = 'workflows';
|
|
mockApiClient.apiConfig.config.displayName = 'Workflow';
|
|
|
|
try {
|
|
await moveManager.showMoveModal('/library/workflows/flow.json');
|
|
|
|
expect(document.getElementById('moveModalTitle').textContent).toBe('Move Workflow');
|
|
expect(document.getElementById('moveRootLabel').textContent).toBe('Select Workflow Root:');
|
|
} finally {
|
|
state.currentPageType = 'loras';
|
|
mockApiClient.modelType = 'loras';
|
|
mockApiClient.apiConfig.config.displayName = 'LoRA';
|
|
}
|
|
});
|
|
|
|
it('should keep the model wording in the move dialog on model pages', async () => {
|
|
await moveManager.showMoveModal('/models/loras/a.safetensors');
|
|
|
|
expect(document.getElementById('moveModalTitle').textContent).toBe('Move LoRA');
|
|
expect(document.getElementById('moveRootLabel').textContent).toBe('Select LoRA Root:');
|
|
});
|
|
|
|
it('should use page-specific copy for move toasts', () => {
|
|
state.currentPageType = 'recipes';
|
|
expect(moveManager._moveFailedKey()).toBe('recipes.move.failed');
|
|
expect(moveManager._noSelectionKey()).toBe('recipes.bulk.noSelection');
|
|
|
|
state.currentPageType = 'workflows';
|
|
expect(moveManager._moveFailedKey()).toBe('workflows.modals.move.failed');
|
|
expect(moveManager._noSelectionKey()).toBe('workflows.bulk.noSelection');
|
|
|
|
state.currentPageType = 'loras';
|
|
expect(moveManager._moveFailedKey()).toBe('toast.models.moveFailed');
|
|
expect(moveManager._noSelectionKey()).toBe('toast.models.noModelsSelected');
|
|
});
|
|
|
|
it('should use the page-specific warning when bulk-moving with nothing selected', async () => {
|
|
state.currentPageType = 'workflows';
|
|
state.selectedModels.clear();
|
|
|
|
try {
|
|
await moveManager.showMoveModal('bulk');
|
|
expect(showToastMock).toHaveBeenCalledWith('workflows.bulk.noSelection', {}, 'warning');
|
|
expect(mockApiClient.fetchModelRoots).not.toHaveBeenCalled();
|
|
} finally {
|
|
state.currentPageType = 'loras';
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
});
|
|
});
|