mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
1b5cbbbaa0
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched entries can be undone via the existing restore flow - Bulk missing-LoRA downloads mark unresolvable failures hash-invalid, flipping those entries from download to reconnect candidacy - Recipe modal always offers a reconnect action next to download for missing LoRA entries - Rematch runs collect an opt-in relaxed-matching choice (also reconnect missing models by file name) via a pre-run options dialog on the global, bulk and single-recipe entries - L4 (filename-level) matches are listed in a results dialog with per-entry undo
445 lines
13 KiB
JavaScript
445 lines
13 KiB
JavaScript
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
|
|
const showToastMock = vi.fn();
|
|
const rematchBulkModelsMock = vi.fn();
|
|
const updateSingleItemMock = vi.fn();
|
|
|
|
const loadingManagerStub = {
|
|
showSimpleLoading: vi.fn(),
|
|
hide: vi.fn(),
|
|
restoreProgressBar: vi.fn(),
|
|
};
|
|
|
|
const stateStub = {
|
|
currentPageType: 'recipes',
|
|
bulkMode: false,
|
|
selectedModels: new Set(),
|
|
loadingManager: loadingManagerStub,
|
|
virtualScroller: { updateSingleItem: updateSingleItemMock },
|
|
global: { settings: {} },
|
|
};
|
|
|
|
vi.mock('../../../static/js/state/index.js', () => ({
|
|
state: stateStub,
|
|
getCurrentPageState: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|
showToast: showToastMock,
|
|
copyToClipboard: vi.fn(),
|
|
sendLoraToWorkflow: vi.fn(),
|
|
sendEmbeddingToWorkflow: vi.fn(),
|
|
buildLoraSyntax: vi.fn(),
|
|
getNSFWLevelName: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
|
getModelApiClient: vi.fn(),
|
|
resetAndReload: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
|
RecipeSidebarApiClient: class {
|
|
constructor() {
|
|
this.rematchBulkModels = rematchBulkModelsMock;
|
|
}
|
|
},
|
|
updateRecipeMetadata: vi.fn(),
|
|
extractRecipeId: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
|
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
|
MODEL_CONFIG: {},
|
|
}));
|
|
|
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
|
updateCardsForBulkMode: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
|
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
|
|
getPriorityTagSuggestions: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
|
getNsfwLevelSelector: vi.fn(),
|
|
}));
|
|
|
|
// The real RematchModalManager runs against the mocked modalManager; confirm
|
|
// is invoked explicitly, mirroring the user clicking Rematch in the dialog.
|
|
async function confirmRematchOptions() {
|
|
const { rematchModalManager } = await import(
|
|
'../../../static/js/managers/RematchModalManager.js'
|
|
);
|
|
return rematchModalManager.confirmOptions();
|
|
}
|
|
|
|
async function cancelRematchOptions() {
|
|
const { rematchModalManager } = await import(
|
|
'../../../static/js/managers/RematchModalManager.js'
|
|
);
|
|
rematchModalManager.cancelOptions();
|
|
}
|
|
|
|
describe('BulkManager.rematchSelectedRecipes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
stateStub.currentPageType = 'recipes';
|
|
stateStub.bulkMode = false;
|
|
stateStub.selectedModels.clear();
|
|
});
|
|
|
|
async function createBulkManager() {
|
|
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
|
return new BulkManager();
|
|
}
|
|
|
|
it('exposes the rematch action on the recipes page action config', async () => {
|
|
const bulk = await createBulkManager();
|
|
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
|
});
|
|
|
|
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind
|
|
// `repaired` mirror would fire the skipped toast with count 0 here.
|
|
it('toasts the rematched count when the bulk rematch succeeds', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
stateStub.selectedModels.add('/recipes/b.webp');
|
|
stateStub.selectedModels.add('/recipes/c.webp');
|
|
|
|
const rematchedRecipe = { file_path: '/recipes/a.webp', title: 'A' };
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 3,
|
|
rematched: 4,
|
|
skipped: 1,
|
|
errors: 0,
|
|
matched_recipes: 2,
|
|
matched_entries: 4,
|
|
unresolved_recipes: 1,
|
|
unresolved_entries: 1,
|
|
recipes: [rematchedRecipe],
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(rematchBulkModelsMock).toHaveBeenCalledWith(
|
|
[
|
|
'/recipes/a.webp',
|
|
'/recipes/b.webp',
|
|
'/recipes/c.webp',
|
|
],
|
|
{ relaxed: false }
|
|
);
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchComplete',
|
|
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
|
|
'success'
|
|
);
|
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
|
'toast.recipes.rematchSkipped',
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
|
|
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
|
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
|
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the errors toast variant when the bulk rematch has failures', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
stateStub.selectedModels.add('/recipes/b.webp');
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 2,
|
|
rematched: 3,
|
|
skipped: 0,
|
|
errors: 2,
|
|
matched_recipes: 1,
|
|
matched_entries: 3,
|
|
unresolved_recipes: 0,
|
|
unresolved_entries: 0,
|
|
recipes: [],
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchCompleteErrors',
|
|
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 },
|
|
'warning'
|
|
);
|
|
});
|
|
|
|
it('toasts an error when every selected recipe failed to rematch', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
stateStub.selectedModels.add('/recipes/b.webp');
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 2,
|
|
rematched: 0,
|
|
skipped: 0,
|
|
errors: 2,
|
|
matched_recipes: 0,
|
|
matched_entries: 0,
|
|
unresolved_recipes: 0,
|
|
unresolved_entries: 0,
|
|
recipes: [],
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchAllFailed',
|
|
{ total: 2, failures: 2 },
|
|
'error'
|
|
);
|
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
|
'toast.recipes.rematchSkipped',
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('toasts an info message when entries had no local match', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
stateStub.selectedModels.add('/recipes/b.webp');
|
|
stateStub.selectedModels.add('/recipes/c.webp');
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 3,
|
|
rematched: 0,
|
|
skipped: 2,
|
|
errors: 0,
|
|
matched_recipes: 0,
|
|
matched_entries: 0,
|
|
unresolved_recipes: 1,
|
|
unresolved_entries: 2,
|
|
recipes: [],
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchUnmatched',
|
|
{ entries: 2, recipes: 1, total: 3 },
|
|
'info'
|
|
);
|
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
|
'toast.recipes.rematchSkipped',
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('toasts the skipped message when nothing was rematched', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
stateStub.selectedModels.add('/recipes/b.webp');
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 2,
|
|
rematched: 0,
|
|
skipped: 2,
|
|
errors: 0,
|
|
recipes: [],
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchSkipped',
|
|
{ total: 2 },
|
|
'info'
|
|
);
|
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
|
'toast.recipes.rematchComplete',
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
|
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces the backend error message when the bulk rematch fails', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: false,
|
|
error: 'Rematch already in progress',
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchFailed',
|
|
{ message: 'Rematch already in progress' },
|
|
'error'
|
|
);
|
|
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
|
});
|
|
|
|
it('toasts the failure message when the API call throws', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
|
|
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.rematchFailed',
|
|
{ message: 'network down' },
|
|
'error'
|
|
);
|
|
});
|
|
|
|
it('warns and does not call the API when nothing is selected', async () => {
|
|
const bulk = await createBulkManager();
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.noRecipesSelected',
|
|
{},
|
|
'warning'
|
|
);
|
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns and does not call the API outside the recipes page', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.currentPageType = 'loras';
|
|
stateStub.selectedModels.add('/models/a.safetensors');
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'This operation is only available for recipes',
|
|
{},
|
|
'warning'
|
|
);
|
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not start the rematch until the options dialog is confirmed', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
|
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 1,
|
|
rematched: 1,
|
|
skipped: 0,
|
|
errors: 0,
|
|
matched_recipes: 1,
|
|
matched_entries: 1,
|
|
recipes: [],
|
|
});
|
|
|
|
await confirmRematchOptions();
|
|
|
|
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false });
|
|
});
|
|
|
|
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
document.body.innerHTML = '<input type="checkbox" id="rematchOptionsRelaxed">';
|
|
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 1,
|
|
rematched: 0,
|
|
skipped: 1,
|
|
errors: 0,
|
|
recipes: [],
|
|
});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
// The dialog resets the checkbox to unchecked on open; the user opts in.
|
|
document.getElementById('rematchOptionsRelaxed').checked = true;
|
|
await confirmRematchOptions();
|
|
|
|
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true });
|
|
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('starts nothing when the options dialog is cancelled', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await cancelRematchOptions();
|
|
|
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
|
'toast.recipes.rematchComplete',
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('shows the L4 results modal when the bulk result carries l4_matches', async () => {
|
|
const bulk = await createBulkManager();
|
|
stateStub.selectedModels.add('/recipes/a.webp');
|
|
|
|
const l4Matches = [
|
|
{ recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
|
];
|
|
rematchBulkModelsMock.mockResolvedValue({
|
|
success: true,
|
|
total: 1,
|
|
rematched: 1,
|
|
skipped: 0,
|
|
errors: 0,
|
|
matched_recipes: 1,
|
|
matched_entries: 1,
|
|
recipes: [],
|
|
l4_matches: l4Matches,
|
|
});
|
|
|
|
const { rematchModalManager } = await import(
|
|
'../../../static/js/managers/RematchModalManager.js'
|
|
);
|
|
const showResultsSpy = vi
|
|
.spyOn(rematchModalManager, 'showResultsModal')
|
|
.mockImplementation(() => {});
|
|
|
|
await bulk.rematchSelectedRecipes();
|
|
await confirmRematchOptions();
|
|
|
|
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
|
|
showResultsSpy.mockRestore();
|
|
});
|
|
});
|