feat(ui): add global, bulk and per-recipe rematch actions

This commit is contained in:
Will Miao
2026-08-09 11:31:00 +08:00
parent 3001f0f0ef
commit 420530f532
21 changed files with 991 additions and 1 deletions

View File

@@ -216,4 +216,66 @@ describe('RecipeSidebarApiClient bulk operations', () => {
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
});
it('posts exactly recipe_ids when rematching in bulk', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
total: 2,
rematched: 2,
skipped: 0,
errors: 0,
recipes: [],
}),
});
const result = await api.rematchBulkModels(['/recipes/a.webp', '/recipes/b.webp']);
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipes/rematch-bulk',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
);
// Exact-body assertion: no extra fields beyond recipe_ids
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
recipe_ids: ['a', 'b'],
});
expect(result).toMatchObject({ success: true, rematched: 2 });
});
it('derives recipe IDs via extractRecipeId and skips empty paths', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true, total: 1, rematched: 0, skipped: 1, errors: 0 }),
});
await api.rematchBulkModels(['', '/recipes/sub folder/recipe-1.webp']);
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
recipe_ids: ['recipe-1'],
});
});
it('rejects bulk rematch without file paths', async () => {
const api = new RecipeSidebarApiClient();
await expect(api.rematchBulkModels([])).rejects.toThrow('No file paths provided');
expect(global.fetch).not.toHaveBeenCalled();
});
it('throws the backend error when bulk rematch fails', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: false,
json: async () => ({ success: false, error: 'Rematch already running' }),
});
await expect(api.rematchBulkModels(['/recipes/a.webp'])).rejects.toThrow('Rematch already running');
});
});

View File

@@ -2186,4 +2186,127 @@ describe('Interaction-level regression coverage', () => {
document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
});
it('runs global recipe rematch with polling and toasts the rematched count', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
</div>
`;
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
const menu = new GlobalContextMenu();
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
const progressUI = {
updateProgress: vi.fn(),
showCancelButton: vi.fn(),
complete: vi.fn().mockResolvedValue(undefined),
};
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
window.recipesPage = { refresh: vi.fn() };
// Menu item is recipes-page only
stateStub.currentPageType = 'recipes';
menu.showMenu(100, 200);
expect(rematchItem.classList.contains('hidden')).toBe(false);
stateStub.currentPageType = 'loras';
menu.showMenu(100, 200);
expect(rematchItem.classList.contains('hidden')).toBe(true);
stateStub.currentPageType = 'recipes';
menu.showMenu(100, 200);
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
progress: { status: 'completed', rematched: 2, skipped: 1, total: 3 },
}),
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(rematchItem.classList.contains('disabled')).toBe(true);
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
expect(progressUI.complete).toHaveBeenCalledWith('Rematched 2 recipes.');
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.success',
{ count: 2 },
'success'
);
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
expect(rematchItem.classList.contains('disabled')).toBe(false);
expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage;
delete stateStub.currentPageType;
});
it('toasts the rematched count when a global rematch is cancelled', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
</div>
`;
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
const menu = new GlobalContextMenu();
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
const progressUI = {
updateProgress: vi.fn(),
showCancelButton: vi.fn(),
complete: vi.fn().mockResolvedValue(undefined),
};
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
stateStub.currentPageType = 'recipes';
menu.showMenu(100, 200);
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
progress: { status: 'cancelled', rematched: 1, skipped: 0, total: 3 },
}),
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes were rematched.');
expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.cancelled',
{ count: 1 },
'info'
);
expect(menu._rematchInProgress).toBe(false);
delete stateStub.currentPageType;
});
});

View File

@@ -0,0 +1,188 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const updateSingleItemMock = vi.fn();
const handleCommonMenuActionsMock = vi.fn(() => false);
const stateStub = {
virtualScroller: { updateSingleItem: updateSingleItemMock },
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
updateRecipeMetadata: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
moveManager: { showMoveModal: vi.fn() },
}));
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
ModelContextMenuMixin: {
handleCommonMenuActions: handleCommonMenuActionsMock,
initNSFWSelector: vi.fn(),
},
}));
const flushAsyncTasks = async (rounds = 5) => {
for (let i = 0; i < rounds; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
};
describe('RecipeContextMenu.rematchRecipe', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="rematch"></div>
<div class="context-menu-item download-missing-item" data-action="download-missing"></div>
</div>
<div id="card" class="model-card" data-id="recipe-1" data-filepath="/recipes/recipe-1.webp"></div>
`;
global.fetch = vi.fn();
});
afterEach(() => {
delete global.fetch;
});
async function createMenu() {
const { RecipeContextMenu } = await import(
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
);
return new RecipeContextMenu();
}
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
// mirror would fire the skipped toast here.
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
global.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 2, skipped: 0 }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 0, total: 1 },
'success'
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
expect.anything()
);
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
id: 'recipe-1',
title: 'Updated Recipe',
});
});
it('toasts the skipped message when nothing was rematched', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
{ total: 1 },
'info'
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
expect.anything(),
expect.anything()
);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
// Oracle R4-F2 pin: failure surfaces `result.error` (e.g. the 409 body).
it('surfaces result.error when the rematch is rejected', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
global.fetch.mockResolvedValueOnce({
ok: false,
status: 409,
json: async () => ({ success: false, error: 'Recipe rematch already in progress' }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
{ message: 'Recipe rematch already in progress' },
'error'
);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('toasts the failure message when the fetch throws', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
global.fetch.mockRejectedValueOnce(new Error('network down'));
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
{ message: 'network down' },
'error'
);
});
});

View File

@@ -0,0 +1,226 @@
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(),
}));
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: 2,
skipped: 1,
errors: 0,
recipes: [rematchedRecipe],
});
await bulk.rematchSelectedRecipes();
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
'/recipes/a.webp',
'/recipes/b.webp',
'/recipes/c.webp',
]);
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 1, total: 3 },
'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('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();
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();
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();
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();
});
});