feat(delete): add undo toasts and harden delete modals

This commit is contained in:
Will Miao
2026-08-11 14:09:10 +08:00
parent eb0f6dd3b6
commit b2c68e6a65
27 changed files with 2555 additions and 51 deletions
@@ -0,0 +1,201 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
BASE_MODEL_API_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
STORAGE_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
SIDEBAR_MANAGER_MODULE,
} = vi.hoisted(() => ({
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const showSimpleLoadingMock = vi.fn();
const showCancelButtonMock = vi.fn();
const hideLoadingMock = vi.fn();
vi.mock(STATE_MODULE, () => ({
state: {
loadingManager: {
showSimpleLoading: showSimpleLoadingMock,
showCancelButton: showCancelButtonMock,
hide: hideLoadingMock,
},
virtualScroller: {
removeItemByFilePath: vi.fn(),
},
},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
getSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
saveMapToStorage: vi.fn(),
}));
vi.mock(API_CONFIG_MODULE, () => ({
getCompleteApiConfig: vi.fn(() => ({
endpoints: { bulkDelete: '/api/lm/loras/bulk-delete' },
config: { displayName: 'LoRA', singularName: 'LoRA' },
})),
getCurrentModelType: vi.fn(() => 'loras'),
isValidModelType: vi.fn(() => true),
DOWNLOAD_ENDPOINTS: {},
HF_ENDPOINTS: {},
WS_ENDPOINTS: {},
}));
vi.mock(API_FACTORY_MODULE, () => ({
resetAndReload: vi.fn(),
}));
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
sidebarManager: { refresh: vi.fn() },
}));
describe('BaseModelApiClient.bulkDeleteModels undo contract', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
delete global.fetch;
});
async function createClient() {
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
class TestClient extends BaseModelApiClient {}
return new TestClient('loras');
}
function mockBulkDeleteResponse(payload) {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => payload,
});
}
it('posts the file paths and defaults both batch fields to null', async () => {
mockBulkDeleteResponse({
success: true,
status: 'success',
total_deleted: 3,
total_attempted: 3,
cache_updated: true,
results: [],
});
const client = await createClient();
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/loras/bulk-delete',
expect.objectContaining({ method: 'POST' })
);
expect(result).toEqual({
success: true,
deleted_count: 3,
failed_count: 0,
errors: [],
batch_id: null,
batch_ids: null,
});
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
});
it('passes through the merged batch_id when the backend staged the bulk delete', async () => {
mockBulkDeleteResponse({
success: true,
status: 'success',
total_deleted: 2,
total_attempted: 2,
cache_updated: true,
results: [],
batch_id: 'merged-batch-1',
});
const client = await createClient();
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
expect(result.batch_id).toBe('merged-batch-1');
expect(result.batch_ids).toBeNull();
});
it('passes through the batch_ids fallback array when the merge failed', async () => {
mockBulkDeleteResponse({
success: true,
status: 'success',
total_deleted: 2,
total_attempted: 2,
cache_updated: true,
results: [],
batch_ids: ['batch-1', 'batch-2'],
});
const client = await createClient();
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
expect(result.batch_id).toBeNull();
expect(result.batch_ids).toEqual(['batch-1', 'batch-2']);
});
it('keeps the batch field on the cancelled-status path (staged subset is undoable)', async () => {
mockBulkDeleteResponse({
success: true,
status: 'cancelled',
total_deleted: 1,
total_attempted: 2,
cache_updated: true,
results: [],
batch_id: 'partial-batch',
});
const client = await createClient();
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
expect(result.success).toBe(true);
expect(result.deleted_count).toBe(1);
expect(result.batch_id).toBe('partial-batch');
expect(result.batch_ids).toBeNull();
});
it('returns the cancelled marker when the user aborts the fetch', async () => {
const abortError = new Error('The user aborted a request.');
abortError.name = 'AbortError';
global.fetch = vi.fn().mockRejectedValue(abortError);
const client = await createClient();
const result = await client.bulkDeleteModels(['/models/a.safetensors']);
expect(result).toEqual({ success: false, cancelled: true });
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
});
it('throws the backend error message when the bulk delete fails', async () => {
mockBulkDeleteResponse({ success: false, error: 'disk full' });
const client = await createClient();
await expect(client.bulkDeleteModels(['/models/a.safetensors'])).rejects.toThrow('disk full');
});
});
@@ -0,0 +1,161 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
BASE_MODEL_API_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
STORAGE_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
SIDEBAR_MANAGER_MODULE,
} = vi.hoisted(() => ({
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const removeItemByFilePathMock = vi.fn();
const showSimpleLoadingMock = vi.fn();
const hideLoadingMock = vi.fn();
vi.mock(STATE_MODULE, () => ({
state: {
loadingManager: {
showSimpleLoading: showSimpleLoadingMock,
hide: hideLoadingMock,
},
virtualScroller: {
removeItemByFilePath: removeItemByFilePathMock,
},
},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
getSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
saveMapToStorage: vi.fn(),
}));
vi.mock(API_CONFIG_MODULE, () => ({
getCompleteApiConfig: vi.fn(() => ({
endpoints: { delete: '/api/lm/loras/delete' },
config: { displayName: 'LoRA', singularName: 'LoRA' },
})),
getCurrentModelType: vi.fn(() => 'loras'),
isValidModelType: vi.fn(() => true),
DOWNLOAD_ENDPOINTS: {},
HF_ENDPOINTS: {},
WS_ENDPOINTS: {},
}));
vi.mock(API_FACTORY_MODULE, () => ({
resetAndReload: vi.fn(),
}));
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
sidebarManager: { refresh: vi.fn() },
}));
describe('BaseModelApiClient.deleteModel undo contract', () => {
beforeEach(() => {
showToastMock.mockReset();
removeItemByFilePathMock.mockReset();
showSimpleLoadingMock.mockReset();
hideLoadingMock.mockReset();
});
afterEach(() => {
delete global.fetch;
});
async function createClient() {
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
class TestClient extends BaseModelApiClient {}
return new TestClient('loras');
}
it('returns the batch id and suppresses the legacy success toast when staged', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-42' }),
});
const client = await createClient();
const result = await client.deleteModel('/models/foo.safetensors');
expect(result).toEqual({ success: true, batch_id: 'batch-42' });
// The card is still removed from the scroller — the file is gone either way
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
// No legacy toast: the caller shows the undo action toast instead
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.deleteSuccess',
expect.anything(),
expect.anything()
);
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
});
it('keeps the legacy success toast when the delete was not staged', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, deleted_files: ['/models/foo.safetensors'] }),
});
const client = await createClient();
const result = await client.deleteModel('/models/foo.safetensors');
expect(result).toEqual({ success: true, batch_id: null });
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
expect(showToastMock).toHaveBeenCalledWith('toast.api.deleteSuccess', { type: 'LoRA' }, 'success');
});
it('returns a truthy result so undo-blind callers keep working (ModelVersionsTab)', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-7' }),
});
const client = await createClient();
const result = await client.deleteModel('/models/v2.safetensors');
// ModelVersionsTab.js:1136-1144 awaits deleteModel and treats any truthy
// result as success — the new object must satisfy that check shape.
expect(result).toBeTruthy();
expect(Boolean(result && result.success)).toBe(true);
});
it('returns false and shows the failure toast when the server reports failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: false, error: 'disk error' }),
});
const client = await createClient();
const result = await client.deleteModel('/models/foo.safetensors');
expect(result).toBe(false);
expect(removeItemByFilePathMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.deleteFailed',
expect.objectContaining({ type: 'LoRA' }),
'error'
);
});
});
+40
View File
@@ -142,10 +142,50 @@ describe('RecipeSidebarApiClient bulk operations', () => {
success: true,
deleted_count: 2,
failed_count: 0,
batch_id: null,
batch_ids: null,
});
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
it('passes through the merged batch_id from a staged bulk delete', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
total_deleted: 2,
total_failed: 0,
failed: [],
batch_id: 'merged-recipe-batch',
}),
});
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
expect(result.batch_id).toBe('merged-recipe-batch');
expect(result.batch_ids).toBeNull();
});
it('passes through the batch_ids fallback array when the merge failed', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
total_deleted: 2,
total_failed: 0,
failed: [],
batch_ids: ['recipe-batch-1', 'recipe-batch-2'],
}),
});
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
expect(result.batch_id).toBeNull();
expect(result.batch_ids).toEqual(['recipe-batch-1', 'recipe-batch-2']);
});
it('encodes recipe IDs when fetching recipe details', async () => {
global.fetch.mockResolvedValue({
ok: true,
@@ -123,6 +123,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showExcludeModal: vi.fn(),
showDeleteModal: vi.fn(),
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
@@ -1,11 +1,18 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const showActionToastMock = vi.fn();
const handleUndoDeleteMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
const translateMock = vi.fn((key) => key);
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
showActionToast: showActionToastMock,
}));
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
handleUndoDelete: handleUndoDeleteMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
@@ -20,6 +27,17 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
armDeleteButton: (modalElement) => {
if (!modalElement) return null;
const buttons = modalElement.querySelectorAll('.delete-btn');
buttons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
buttons.forEach((button) => { button.disabled = false; });
}, 1500);
},
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
}));
@@ -211,3 +229,152 @@ describe('DuplicatesManager prompt matching toggle', () => {
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
});
});
describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
beforeEach(() => {
vi.clearAllMocks();
setCurrentPageType('recipes');
setupDom();
state.pendingLayoutRecreate = false;
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
handleUndoDeleteMock.mockResolvedValue(true);
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
globalThis.recipeManager = { loadRecipes: vi.fn() };
});
afterEach(() => {
state.pendingLayoutRecreate = false;
state.virtualScroller = null;
delete globalThis.modalManager;
delete globalThis.recipeManager;
delete globalThis.fetch;
});
function mockBulkDelete(payload) {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => payload,
});
}
function lastActionToastOptions() {
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
return call[3];
}
it('shows the undo action toast with the batch id and reloads recipes on undo', async () => {
mockBulkDelete({ success: true, total_deleted: 2, batch_id: 'recipe-batch-1' });
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
manager.selectedForDeletion.add('r1');
manager.selectedForDeletion.add('r2');
await manager.confirmDeleteDuplicates();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showActionToastMock).toHaveBeenCalledWith(
'toast.undo.deletedBulk',
{ count: 2 },
'success',
expect.objectContaining({
actionText: 'toast.undo.action',
onAction: expect.any(Function),
})
);
// The legacy duplicates success toast is replaced, not duplicated
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.duplicates.deleteSuccess',
expect.anything(),
expect.anything()
);
// exitDuplicateMode still runs for successful deletions
expect(manager.inDuplicateMode).toBe(false);
lastActionToastOptions().onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
refreshFn();
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
});
it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
mockBulkDelete({ success: true, total_deleted: 2, batch_ids: ['rb-1', 'rb-2'] });
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
manager.selectedForDeletion.add('r1');
manager.selectedForDeletion.add('r2');
await manager.confirmDeleteDuplicates();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
await lastActionToastOptions().onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['rb-1', null, { showToast: false, refresh: false }]);
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['rb-2', null, { showToast: false, refresh: false }]);
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledTimes(1);
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
});
it('keeps the legacy success toast when the response carries no batch field', async () => {
mockBulkDelete({ success: true, total_deleted: 1 });
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
manager.selectedForDeletion.add('r1');
await manager.confirmDeleteDuplicates();
expect(showActionToastMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.duplicates.deleteSuccess',
{ count: 1, type: 'recipes' },
'success'
);
});
});
describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
setCurrentPageType('recipes');
setupDom();
document.body.insertAdjacentHTML('beforeend', `
<div id="duplicateDeleteModal" class="modal delete-modal">
<div class="delete-model-info"><p><span id="duplicateDeleteCount">0</span></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`);
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.modalManager;
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const manager = new DuplicatesManager({});
manager.selectedForDeletion.add('r1');
await manager.deleteSelectedDuplicates();
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal');
const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -1,16 +1,34 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const showActionToastMock = vi.fn();
const handleUndoDeleteMock = vi.fn();
const resetAndReloadMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
showActionToast: showActionToastMock,
}));
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
handleUndoDelete: handleUndoDeleteMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
armDeleteButton: (modalElement) => {
if (!modalElement) return null;
const buttons = modalElement.querySelectorAll('.delete-btn');
buttons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
buttons.forEach((button) => { button.disabled = false; });
}, 1500);
},
}));
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.js');
@@ -230,3 +248,153 @@ describe('ModelDuplicatesManager verification state', () => {
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
});
});
describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
function mockDeleteAndRecheck(deletePayload) {
global.fetch = vi.fn((url) => {
if (String(url).includes('bulk-delete')) {
return Promise.resolve({
ok: true,
statusText: 'OK',
json: async () => deletePayload,
});
}
return Promise.resolve({
ok: true,
statusText: 'OK',
json: async () => ({ success: true, duplicates: [] }),
});
});
}
function lastActionToastOptions() {
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
return call[3];
}
beforeEach(() => {
handleUndoDeleteMock.mockResolvedValue(true);
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
state.virtualScroller = null;
delete globalThis.modalManager;
});
it('shows the undo action toast with the batch id and refreshes models on undo', async () => {
const manager = await createManager();
mockDeleteAndRecheck({ success: true, total_deleted: 1, batch_id: 'model-batch-1' });
manager.inDuplicateMode = true;
manager.selectedForDeletion.add(carPath);
await manager.confirmDeleteDuplicates();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showActionToastMock).toHaveBeenCalledWith(
'toast.undo.deletedBulk',
{ count: 1 },
'success',
expect.objectContaining({
actionText: 'toast.undo.action',
onAction: expect.any(Function),
})
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.duplicates.deleteSuccess',
expect.anything(),
expect.anything()
);
// The existing reset + find-duplicates re-check path still runs
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
// No remaining duplicates -> duplicate mode exited
expect(manager.inDuplicateMode).toBe(false);
lastActionToastOptions().onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
expect(handleUndoDeleteMock).toHaveBeenCalledWith('model-batch-1', expect.any(Function));
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
resetAndReloadMock.mockClear();
refreshFn();
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
const manager = await createManager();
mockDeleteAndRecheck({ success: true, total_deleted: 2, batch_ids: ['mb-1', 'mb-2'] });
manager.inDuplicateMode = true;
manager.selectedForDeletion.add(carPath);
manager.selectedForDeletion.add(copyPath);
await manager.confirmDeleteDuplicates();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
resetAndReloadMock.mockClear();
await lastActionToastOptions().onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['mb-1', null, { showToast: false, refresh: false }]);
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['mb-2', null, { showToast: false, refresh: false }]);
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
});
it('keeps the legacy success toast when the response carries no batch field', async () => {
const manager = await createManager();
mockDeleteAndRecheck({ success: true, total_deleted: 1 });
manager.inDuplicateMode = true;
manager.selectedForDeletion.add(carPath);
await manager.confirmDeleteDuplicates();
expect(showActionToastMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.duplicates.deleteSuccess',
{ count: 1, type: 'loras' },
'success'
);
});
});
describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.modalManager;
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const manager = await createManager();
document.body.insertAdjacentHTML('beforeend', `
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
<div class="delete-model-info"><p><span id="modelDuplicateDeleteCount">0</span></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`);
manager.selectedForDeletion.add(carPath);
await manager.deleteSelectedDuplicates();
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal');
const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,191 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
RECIPE_CARD_MODULE,
UI_HELPERS_MODULE,
RECIPE_API_MODULE,
MODEL_CARD_MODULE,
MODAL_MANAGER_MODULE,
STATE_MODULE,
BULK_MANAGER_MODULE,
CONSTANTS_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
} = vi.hoisted(() => ({
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const showActionToastMock = vi.fn();
const handleUndoDeleteMock = vi.fn();
const translateMock = vi.fn((key) => key);
const closeModalMock = vi.fn();
const removeItemByFilePathMock = vi.fn();
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
showActionToast: showActionToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock(RECIPE_API_MODULE, () => ({
updateRecipeMetadata: vi.fn(),
}));
vi.mock(MODEL_CARD_MODULE, () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: closeModalMock,
},
}));
vi.mock(STATE_MODULE, () => ({
state: {
virtualScroller: {
removeItemByFilePath: removeItemByFilePathMock,
},
},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(BULK_MANAGER_MODULE, () => ({
bulkManager: {},
}));
vi.mock(CONSTANTS_MODULE, () => ({
NSFW_LEVELS: {},
getBaseModelAbbreviation: vi.fn(),
getMatureBlurThreshold: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: translateMock,
}));
vi.mock(UNDO_HELPERS_MODULE, () => ({
handleUndoDelete: handleUndoDeleteMock,
}));
function setupDeleteModal() {
document.body.innerHTML = `
<div id="deleteModal" data-recipe-id="recipe-1" data-file-path="/recipes/r1.json">
<button class="delete-btn">Delete</button>
</div>
`;
const deleteModal = document.getElementById('deleteModal');
// jsdom maps data-file-path to dataset.filePath
deleteModal.dataset.recipeId = 'recipe-1';
deleteModal.dataset.filePath = '/recipes/r1.json';
return deleteModal;
}
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('RecipeCard confirmDeleteRecipe undo flow', () => {
beforeEach(() => {
showToastMock.mockReset();
showActionToastMock.mockReset();
handleUndoDeleteMock.mockReset();
translateMock.mockClear();
closeModalMock.mockReset();
removeItemByFilePathMock.mockReset();
setupDeleteModal();
window.recipeManager = { loadRecipes: vi.fn() };
});
afterEach(() => {
delete global.fetch;
delete window.recipeManager;
document.body.innerHTML = '';
});
async function createCard() {
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
const card = Object.create(RecipeCard.prototype);
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json' };
return card;
}
it('shows the undo action toast and wires undo to handleUndoDelete + loadRecipes(true)', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, message: 'deleted', batch_id: 'recipe-batch-1' }),
});
const card = await createCard();
card.confirmDeleteRecipe();
await flushPromises();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1', expect.objectContaining({
method: 'DELETE',
}));
// No legacy success toast when the delete was staged
expect(showToastMock).not.toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
expect(showActionToastMock).toHaveBeenCalledTimes(1);
const [key, params, type, options] = showActionToastMock.mock.calls[0];
expect(key).toBe('toast.undo.deleted');
expect(params).toEqual({ name: 'My Recipe' });
expect(type).toBe('success');
expect(options.actionText).toBe('toast.undo.action');
options.onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
expect(batchId).toBe('recipe-batch-1');
refreshFn();
expect(window.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/recipes/r1.json');
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
});
it('keeps the legacy success toast when the delete was not staged', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, message: 'deleted' }),
});
const card = await createCard();
card.confirmDeleteRecipe();
await flushPromises();
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
expect(showActionToastMock).not.toHaveBeenCalled();
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
});
it('shows the failure toast when the server rejects the delete', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false });
const card = await createCard();
const deleteBtn = document.querySelector('.delete-btn');
card.confirmDeleteRecipe();
await flushPromises();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.deleteFailed',
expect.objectContaining({ message: expect.any(String) }),
'error'
);
expect(deleteBtn.disabled).toBe(false);
expect(deleteBtn.textContent).toBe('Delete');
});
});
@@ -0,0 +1,160 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
RECIPE_CARD_MODULE,
UI_HELPERS_MODULE,
RECIPE_API_MODULE,
MODEL_CARD_MODULE,
MODAL_MANAGER_MODULE,
BULK_MANAGER_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
API_FACTORY_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
const showModalMock = vi.fn();
const closeModalMock = vi.fn();
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock(RECIPE_API_MODULE, () => ({
updateRecipeMetadata: vi.fn(),
}));
vi.mock(MODEL_CARD_MODULE, () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
closeModal: closeModalMock,
},
}));
vi.mock(BULK_MANAGER_MODULE, () => ({
bulkManager: {},
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(UNDO_HELPERS_MODULE, () => ({
handleUndoDelete: vi.fn(),
}));
// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives
// the delay-activate behavior under test. Its own imports are mocked below.
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
resetAndReload: vi.fn(),
}));
describe('RecipeCard delete confirmation delay-activate', () => {
let capturedOnClose;
beforeEach(async () => {
vi.useFakeTimers();
showModalMock.mockReset();
closeModalMock.mockReset();
capturedOnClose = null;
document.body.innerHTML = '<div id="deleteModal" class="modal delete-modal"></div>';
showModalMock.mockImplementation((id, content, onClose) => {
if (content) {
document.getElementById(id).innerHTML = content;
}
capturedOnClose = onClose;
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
window.recipeManager = { loadRecipes: vi.fn() };
const { state } = await import(STATE_MODULE);
state.virtualScroller = { removeItemByFilePath: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete window.recipeManager;
document.body.innerHTML = '';
});
async function createCard() {
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
const card = Object.create(RecipeCard.prototype);
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' };
return card;
}
it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => {
const card = await createCard();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(global.fetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
deleteBtn.click();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe-1',
expect.objectContaining({ method: 'DELETE' })
);
});
it('clears the pending arm timer when the modal closes during the countdown', async () => {
const card = await createCard();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(700);
capturedOnClose();
expect(deleteBtn.disabled).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('re-arms a full 1500ms countdown when the modal is reopened', async () => {
const card = await createCard();
card.showDeleteConfirmation();
vi.advanceTimersByTime(1400);
capturedOnClose();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,372 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
UNDO_HELPERS_MODULE,
} = vi.hoisted(() => ({
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const showActionToastMock = vi.fn();
const handleUndoDeleteMock = vi.fn();
const resetAndReloadMock = vi.fn();
const bulkDeleteModelsMock = vi.fn();
const recipeBulkDeleteModelsMock = vi.fn();
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
};
const stateStub = {
currentPageType: 'loras',
bulkMode: false,
selectedModels: new Set(),
loadingManager: loadingManagerStub,
virtualScroller: { removeItemByFilePath: vi.fn() },
global: { settings: {} },
};
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
getCurrentPageState: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
showActionToast: showActionToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendEmbeddingToWorkflow: vi.fn(),
buildLoraSyntax: vi.fn(),
getNSFWLevelName: vi.fn(),
}));
vi.mock(UNDO_HELPERS_MODULE, () => ({
handleUndoDelete: handleUndoDeleteMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => ({ bulkDeleteModels: bulkDeleteModelsMock })),
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
RecipeSidebarApiClient: class {
constructor() {
this.bulkDeleteModels = recipeBulkDeleteModelsMock;
}
},
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) => key),
}));
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
getPriorityTagSuggestions: vi.fn(),
}));
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
getNsfwLevelSelector: vi.fn(),
}));
describe('BulkManager.confirmBulkDelete undo flows', () => {
beforeEach(() => {
vi.clearAllMocks();
stateStub.currentPageType = 'loras';
stateStub.bulkMode = false;
stateStub.selectedModels.clear();
stateStub.selectedModels.add('/models/a.safetensors');
stateStub.selectedModels.add('/models/b.safetensors');
handleUndoDeleteMock.mockResolvedValue(true);
});
afterEach(() => {
delete window.recipeManager;
delete window.modelDuplicatesManager;
});
async function createBulkManager() {
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
return new BulkManager();
}
function lastActionToastOptions() {
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
return call[3];
}
it('shows one action toast for the merged batch id and undoes it with a model refresh', async () => {
bulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 2,
failed_count: 0,
errors: [],
batch_id: 'merged-1',
batch_ids: null,
});
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showActionToastMock).toHaveBeenCalledWith(
'toast.undo.deletedBulk',
{ count: 2 },
'success',
expect.objectContaining({
actionText: 'toast.undo.action',
onAction: expect.any(Function),
})
);
// The legacy success and cancelled toasts must NOT fire
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.models.deletedSuccessfully',
expect.anything(),
expect.anything()
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.operationCancelled',
expect.anything(),
expect.anything()
);
lastActionToastOptions().onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
expect(handleUndoDeleteMock).toHaveBeenCalledWith('merged-1', expect.any(Function));
// The undo refresh targets the model library
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
refreshFn();
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('keeps the legacy success toast when both batch fields are null', async () => {
bulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 2,
failed_count: 0,
errors: [],
batch_id: null,
batch_ids: null,
});
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showActionToastMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.models.deletedSuccessfully',
{ count: 2, type: 'model' },
'success'
);
});
it('undoes the batch_ids fallback sequentially with exactly one final refresh and restored toast', async () => {
bulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 2,
failed_count: 0,
errors: [],
batch_id: null,
batch_ids: ['id-1', 'id-2'],
});
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showActionToastMock).toHaveBeenCalledWith(
'toast.undo.deletedBulk',
{ count: 2 },
'success',
expect.objectContaining({ onAction: expect.any(Function) })
);
await lastActionToastOptions().onAction();
// Sequential suppressed undos in order
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['id-1', null, { showToast: false, refresh: false }]);
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['id-2', null, { showToast: false, refresh: false }]);
// Exactly ONE final refresh and ONE restored toast
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
});
it('stops the fallback loop on the first failure and skips the final refresh', async () => {
bulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 2,
failed_count: 0,
errors: [],
batch_id: null,
batch_ids: ['id-1', 'id-2', 'id-3'],
});
handleUndoDeleteMock
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
await lastActionToastOptions().onAction();
// The loop stops at the failing second id — the third is never attempted
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
expect(handleUndoDeleteMock.mock.calls[1][0]).toBe('id-2');
// The suppressed undo shows no error toast itself — the loop re-shows it
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: '' }, 'error');
// No final refresh, no restored toast
expect(resetAndReloadMock).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
});
it('shows the action toast for a cancelled bulk that staged a subset (batch_id)', async () => {
bulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 1,
failed_count: 0,
errors: [],
batch_id: 'partial-1',
batch_ids: null,
});
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showActionToastMock).toHaveBeenCalledWith(
'toast.undo.deletedBulk',
{ count: 1 },
'success',
expect.objectContaining({ onAction: expect.any(Function) })
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.operationCancelled',
expect.anything(),
expect.anything()
);
});
it('shows the action toast for a cancelled bulk with the batch_ids fallback', async () => {
bulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 1,
failed_count: 0,
errors: [],
batch_id: null,
batch_ids: ['partial-1'],
});
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.operationCancelled',
expect.anything(),
expect.anything()
);
});
it('keeps the cancelled toast when the user aborted and nothing was staged', async () => {
bulkDeleteModelsMock.mockResolvedValue({ success: false, cancelled: true });
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
expect(showActionToastMock).not.toHaveBeenCalled();
});
it('refreshes recipes through window.recipeManager when undoing a recipe bulk delete', async () => {
stateStub.currentPageType = 'recipes';
stateStub.selectedModels.clear();
stateStub.selectedModels.add('/recipes/a.webp');
const loadRecipesMock = vi.fn();
window.recipeManager = { loadRecipes: loadRecipesMock };
recipeBulkDeleteModelsMock.mockResolvedValue({
success: true,
deleted_count: 1,
failed_count: 0,
errors: [],
batch_id: 'recipe-batch-1',
batch_ids: null,
});
const bulk = await createBulkManager();
await bulk.confirmBulkDelete();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
lastActionToastOptions().onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
refreshFn();
expect(loadRecipesMock).toHaveBeenCalledWith(true);
expect(resetAndReloadMock).not.toHaveBeenCalled();
});
});
describe('BulkManager.showBulkDeleteModal delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
stateStub.currentPageType = 'loras';
stateStub.selectedModels.clear();
stateStub.selectedModels.add('/models/a.safetensors');
document.body.innerHTML = `
<div id="bulkDeleteModal" class="modal delete-modal">
<h2></h2>
<p class="delete-message"></p>
<div class="delete-model-info"><p></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
const bulk = new BulkManager();
bulk.showBulkDeleteModal();
const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(bulkDeleteModelsMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,103 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
function setupDom() {
document.body.innerHTML = `
<button id="triggerBtn">Trigger</button>
<div id="deleteModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
</div>
<div id="excludeModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<button class="cancel-btn">Cancel</button>
<button class="exclude-btn">Exclude</button>
</div>
</div>
<div id="plainModal" class="modal">
<div class="modal-content">
<button class="cancel-btn">Cancel</button>
</div>
</div>
`;
}
describe('ModalManager delete-modal focus handling', () => {
let ModalManager;
let manager;
beforeEach(async () => {
setupDom();
window.scrollTo = vi.fn();
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
manager = new ModalManager();
for (const id of ['deleteModal', 'excludeModal', 'plainModal']) {
manager.registerModal(id, {
element: document.getElementById(id),
onClose: () => {},
});
}
});
afterEach(() => {
document.body.innerHTML = '';
});
it('focuses the cancel button when a delete-type modal opens', () => {
const trigger = document.getElementById('triggerBtn');
trigger.focus();
manager.showModal('deleteModal');
expect(document.activeElement).toBe(
document.querySelector('#deleteModal .cancel-btn')
);
});
it('restores focus to the previously focused element on close', () => {
const trigger = document.getElementById('triggerBtn');
trigger.focus();
manager.showModal('deleteModal');
manager.closeModal('deleteModal');
expect(document.activeElement).toBe(trigger);
});
it('does not touch focus for a non-delete modal', () => {
const trigger = document.getElementById('triggerBtn');
trigger.focus();
manager.showModal('plainModal');
expect(document.activeElement).toBe(trigger);
manager.closeModal('plainModal');
expect(document.activeElement).toBe(trigger);
});
it('does not treat delete-modal-styled modals without a delete button as delete modals', () => {
const trigger = document.getElementById('triggerBtn');
trigger.focus();
manager.showModal('excludeModal');
expect(document.activeElement).toBe(trigger);
manager.closeModal('excludeModal');
expect(document.activeElement).toBe(trigger);
});
it('skips the focus restore when the previously focused element is gone', () => {
const trigger = document.getElementById('triggerBtn');
trigger.focus();
manager.showModal('deleteModal');
trigger.remove();
expect(() => manager.closeModal('deleteModal')).not.toThrow();
});
});
@@ -34,6 +34,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
@@ -26,6 +26,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
+1
View File
@@ -36,6 +36,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
+283
View File
@@ -0,0 +1,283 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
MODAL_UTILS_MODULE,
MODAL_MANAGER_MODULE,
API_FACTORY_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
const deleteModelMock = vi.fn();
const resetAndReloadMock = vi.fn();
const showActionToastMock = vi.fn();
const handleUndoDeleteMock = vi.fn();
const translateMock = vi.fn((key) => key);
const closeModalMock = vi.fn();
const showModalMock = vi.fn();
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
getModal: vi.fn((id) => ({ element: document.getElementById(id) })),
showModal: showModalMock,
closeModal: closeModalMock,
},
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({ deleteModel: deleteModelMock })),
resetAndReload: resetAndReloadMock,
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showActionToast: showActionToastMock,
}));
vi.mock(I18N_MODULE, () => ({
translate: translateMock,
}));
vi.mock(UNDO_HELPERS_MODULE, () => ({
handleUndoDelete: handleUndoDeleteMock,
}));
describe('modalUtils confirmDelete undo flow', () => {
beforeEach(() => {
deleteModelMock.mockReset();
resetAndReloadMock.mockReset();
showActionToastMock.mockReset();
handleUndoDeleteMock.mockReset();
translateMock.mockClear();
closeModalMock.mockReset();
showModalMock.mockReset();
document.body.innerHTML = `
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
<div id="deleteModal"><div class="delete-model-info"></div></div>
`;
window.modelDuplicatesManager = undefined;
});
it('shows the undo action toast and wires undo to handleUndoDelete + resetAndReload', async () => {
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-9' });
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
await confirmDelete();
expect(deleteModelMock).toHaveBeenCalledWith('/models/foo.safetensors');
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
expect(showActionToastMock).toHaveBeenCalledTimes(1);
const [key, params, type, options] = showActionToastMock.mock.calls[0];
expect(key).toBe('toast.undo.deleted');
expect(params).toEqual({ name: 'Foo Model' });
expect(type).toBe('success');
expect(options.actionText).toBe('toast.undo.action');
expect(translateMock).toHaveBeenCalledWith('toast.undo.action');
// Clicking Undo posts the batch and refreshes the model list
options.onAction();
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
expect(batchId).toBe('batch-9');
expect(typeof refreshFn).toBe('function');
refreshFn();
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('does not show the action toast when the delete was not staged', async () => {
deleteModelMock.mockResolvedValue({ success: true, batch_id: null });
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
await confirmDelete();
expect(showActionToastMock).not.toHaveBeenCalled();
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
});
it('falls back to the file name when no card is present', async () => {
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-10' });
document.querySelector('.model-card').remove();
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/bar.safetensors');
await confirmDelete();
expect(showActionToastMock).toHaveBeenCalledTimes(1);
expect(showActionToastMock.mock.calls[0][1]).toEqual({ name: 'bar.safetensors' });
});
it('refreshes the duplicates badge when the manager is available', async () => {
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-11' });
const updateBadge = vi.fn();
window.modelDuplicatesManager = { updateDuplicatesBadgeAfterRefresh: updateBadge };
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
await confirmDelete();
expect(updateBadge).toHaveBeenCalledTimes(1);
});
});
describe('modalUtils armDeleteButton delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
deleteModelMock.mockReset();
showModalMock.mockReset();
closeModalMock.mockReset();
document.body.innerHTML = `
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
<div id="deleteModal">
<div class="delete-model-info"></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(() => {
vi.useRealTimers();
});
it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
it('clicking the disabled delete button fires nothing', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
deleteBtn.click();
expect(deleteBtn.disabled).toBe(true);
expect(deleteModelMock).not.toHaveBeenCalled();
});
it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
vi.advanceTimersByTime(1400);
closeDeleteModal();
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
// Reopen — the stale timer must not enable the button early
showDeleteModal('/models/foo.safetensors');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
});
describe('modalUtils showDeleteModal warning copy and size line', () => {
beforeEach(() => {
showModalMock.mockReset();
closeModalMock.mockReset();
translateMock.mockClear();
translateMock.mockImplementation((key) => key);
document.body.innerHTML = `
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model" data-file_size="2147483648"></div>
<div id="deleteModal">
<div class="delete-model-info"></div>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
});
function modelInfoHtml() {
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
}
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.recoverableWarning');
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
});
it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = false;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
});
it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
const { state } = await import(STATE_MODULE);
delete state.global.settings.delete_undo_enabled;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
});
it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
translateMock.mockImplementation((key, params) =>
params && params.size ? `${key} ${params.size}` : key
);
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.freesSpace 2.0 GB');
});
it('omits the size line when the card has no file size dataset', async () => {
document.querySelector('.model-card').removeAttribute('data-file_size');
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).not.toContain('modals.deleteModel.freesSpace');
});
});
+136
View File
@@ -110,6 +110,142 @@ describe('UI helper DOM utilities', () => {
expect(toast.classList.contains('show')).toBe(false);
});
it('renders an action button and countdown span for action toasts', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', { name: 'Demo Model' }, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.classList.contains('toast-success')).toBe(true);
expect(translateMock).toHaveBeenCalledWith('toast.undo.deleted', { name: 'Demo Model' });
const button = toast.querySelector('.toast-action-btn');
expect(button).not.toBeNull();
expect(button.textContent).toBe('Undo');
const countdown = toast.querySelector('.toast-countdown');
expect(countdown).not.toBeNull();
expect(countdown.textContent).toBe('(30s)');
// Ticking one second updates the countdown text
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(29s)');
// Drain remaining timers so no state leaks into other tests
vi.advanceTimersByTime(30000);
});
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
toast.querySelector('.toast-action-btn').click();
expect(onAction).toHaveBeenCalledTimes(1);
expect(toast.classList.contains('show')).toBe(false);
// Dismissal removes the element after the transition ends
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
expect(document.querySelector('.toast-container')).toBeNull();
});
it('calls onAction exactly once when the button is double-clicked', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const button = document.querySelector('.toast-action-btn');
button.click();
button.click();
expect(onAction).toHaveBeenCalledTimes(1);
});
it('dismisses the toast when the countdown reaches zero', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
// Async RAF mirrors production ordering: the countdown interval is
// registered before the dismiss timeout, so the final tick displays (0s)
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
const { showActionToast } = await import(UI_HELPERS_MODULE);
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction: vi.fn(),
durationMs: 3000,
});
vi.advanceTimersByTime(0); // Flush the RAF callback
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
expect(countdown.textContent).toBe('(3s)');
vi.advanceTimersByTime(2000);
expect(countdown.textContent).toBe('(1s)');
expect(toast.classList.contains('show')).toBe(true);
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(0s)');
expect(toast.classList.contains('show')).toBe(false);
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
});
it('clears the countdown interval when dismissed via the action button', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
durationMs: 30000,
});
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
toast.querySelector('.toast-action-btn').click();
// Advancing past the full duration must not tick the countdown further,
// throw, or re-dismiss the already-dismissed toast
vi.advanceTimersByTime(60000);
expect(countdown.textContent).toBe('(30s)');
expect(onAction).toHaveBeenCalledTimes(1);
expect(toast.classList.contains('show')).toBe(false);
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container')).toBeNull();
});
it('toggles the persisted theme and updates DOM attributes', async () => {
getStorageItemMock.mockReturnValue('light');
document.body.innerHTML = '<button class="theme-toggle"></button>';
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
UI_HELPERS_MODULE,
UNDO_HELPERS_MODULE,
} = vi.hoisted(() => ({
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
describe('handleUndoDelete', () => {
beforeEach(() => {
showToastMock.mockReset();
});
afterEach(() => {
delete global.fetch;
});
it('posts the batch id, refreshes once, and shows the restored toast on 200', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, restored: ['/models/foo.safetensors'] }),
});
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
const refreshFn = vi.fn();
const result = await handleUndoDelete('batch-1', refreshFn);
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/undo-delete', expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch_id: 'batch-1' }),
}));
expect(refreshFn).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
});
it('shows the expired toast for a 404 whose error body mentions expired', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ success: false, error: 'Undo batch expired and was purged' }),
});
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
const refreshFn = vi.fn();
const result = await handleUndoDelete('batch-gone', refreshFn);
expect(result).toBe(false);
expect(refreshFn).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.expired', {}, 'error');
});
it('shows the failed toast with the server message for a 404 occupied path', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ success: false, error: 'Target path occupied' }),
});
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
const result = await handleUndoDelete('batch-occupied', vi.fn());
expect(result).toBe(false);
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Target path occupied' }, 'error');
});
it('shows the failed toast when the error body is not parseable', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => { throw new Error('invalid json'); },
});
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
const result = await handleUndoDelete('batch-malformed', vi.fn());
expect(result).toBe(false);
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Not Found' }, 'error');
});
it('shows the failed toast on network errors', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('connection reset'));
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
const refreshFn = vi.fn();
const result = await handleUndoDelete('batch-net', refreshFn);
expect(result).toBe(false);
expect(refreshFn).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'connection reset' }, 'error');
});
it('suppresses the toast and refresh when the options disable them', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true }),
});
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
const refreshFn = vi.fn();
const result = await handleUndoDelete('batch-quiet', refreshFn, { showToast: false, refresh: false });
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(refreshFn).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalled();
});
});