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', () => ({ translate: translateMock, })); vi.mock('../../../static/js/components/RecipeCard.js', () => ({ RecipeCard: class { constructor() { this.element = document.createElement('div'); } }, })); vi.mock('../../../static/js/utils/modalUtils.js', () => ({})); vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ recreateVirtualScroll: recreateVirtualScrollMock, })); const { DuplicatesManager } = await import('../../../static/js/components/DuplicatesManager.js'); const { state, getCurrentPageState, setCurrentPageType } = await import('../../../static/js/state/index.js'); function setupDom() { document.body.innerHTML = `
stale
`; document.body.classList.add('duplicate-mode'); } describe('DuplicatesManager exitDuplicateMode', () => { beforeEach(() => { vi.clearAllMocks(); setCurrentPageType('recipes'); setupDom(); state.pendingLayoutRecreate = false; state.virtualScroller = { enable: vi.fn(), disable: vi.fn() }; }); afterEach(() => { state.pendingLayoutRecreate = false; state.virtualScroller = null; }); it('skips enable() on the old scroller when a layout recreate was deferred', async () => { state.pendingLayoutRecreate = true; const manager = new DuplicatesManager({}); manager.inDuplicateMode = true; await manager.exitDuplicateMode(); expect(state.virtualScroller.enable).not.toHaveBeenCalled(); expect(recreateVirtualScrollMock).toHaveBeenCalledWith('recipes'); expect(state.pendingLayoutRecreate).toBe(false); }); it('re-enables the existing scroller when no layout recreate is pending', async () => { const manager = new DuplicatesManager({}); manager.inDuplicateMode = true; await manager.exitDuplicateMode(); expect(state.virtualScroller.enable).toHaveBeenCalledTimes(1); expect(recreateVirtualScrollMock).not.toHaveBeenCalled(); }); it('tolerates a missing scroller on the plain re-enable path', async () => { state.virtualScroller = null; const manager = new DuplicatesManager({}); manager.inDuplicateMode = true; await expect(manager.exitDuplicateMode()).resolves.toBeUndefined(); expect(recreateVirtualScrollMock).not.toHaveBeenCalled(); }); it('clears duplicates-mode state and the grid regardless of path', async () => { state.pendingLayoutRecreate = true; const manager = new DuplicatesManager({}); manager.inDuplicateMode = true; await manager.exitDuplicateMode(); expect(manager.inDuplicateMode).toBe(false); expect(getCurrentPageState().duplicatesMode).toBe(false); expect(document.body.classList.contains('duplicate-mode')).toBe(false); expect(document.getElementById('recipeGrid').innerHTML).toBe(''); expect(document.getElementById('duplicatesBanner').style.display).toBe('none'); }); }); describe('DuplicatesManager prompt matching toggle', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); setCurrentPageType('recipes'); setupDom(); state.pendingLayoutRecreate = false; state.virtualScroller = { enable: vi.fn(), disable: vi.fn() }; }); afterEach(() => { state.pendingLayoutRecreate = false; state.virtualScroller = null; }); it('sends include_prompt=1 when the preference is enabled', async () => { localStorage.setItem('recipes_duplicates_include_prompt', '1'); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, duplicate_groups: [ { type: 'fingerprint', key: 'g-1', fingerprint: 'abc:0.8', count: 2, recipes: [{ id: 'r1', modified: 1 }, { id: 'r2', modified: 2 }] }, ], }), }); const manager = new DuplicatesManager({}); await manager.findDuplicates(); expect(globalThis.fetch).toHaveBeenCalledWith('/api/lm/recipes/find-duplicates?include_prompt=1'); }); it('calls the endpoint without the param when disabled', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, duplicate_groups: [] }), }); const manager = new DuplicatesManager({}); await manager.findDuplicates(); expect(globalThis.fetch).toHaveBeenCalledWith('/api/lm/recipes/find-duplicates'); }); it('stays in duplicate mode with an empty view when a re-run finds no groups', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, duplicate_groups: [] }), }); const manager = new DuplicatesManager({}); manager.inDuplicateMode = true; await manager.findDuplicates(); // The view stays open (with the empty state) so the matching-basis // toggle remains reachable — the deadlock fix expect(manager.inDuplicateMode).toBe(true); expect(manager.duplicateGroups).toEqual([]); expect(document.getElementById('duplicatesBanner').style.display).toBe('block'); expect(document.querySelector('.duplicates-empty-state')).not.toBeNull(); }); it('enters the empty duplicates view when the toggle is on but no groups match', async () => { localStorage.setItem('recipes_duplicates_include_prompt', '1'); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, duplicate_groups: [] }), }); const manager = new DuplicatesManager({}); await manager.findDuplicates(); expect(manager.inDuplicateMode).toBe(true); expect(document.getElementById('duplicatesBanner').style.display).toBe('block'); }); it('toasts and stays on the library grid when the toggle is off and no groups match', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, duplicate_groups: [] }), }); const manager = new DuplicatesManager({}); await manager.findDuplicates(); expect(manager.inDuplicateMode).toBe(false); expect(showToastMock).toHaveBeenCalledWith('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info'); }); it('renders the matching basis and checkbox from the stored preference', () => { document.body.innerHTML = ` `; localStorage.setItem('recipes_duplicates_include_prompt', '1'); const manager = new DuplicatesManager({}); manager.updateBasisDisplay(); expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.loraComboAndPrompt'); expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.hintPromptIncluded'); expect(document.getElementById('promptMatchInput').checked).toBe(true); }); it('shows the lora-combo basis when the preference is disabled', () => { document.body.innerHTML = ``; const manager = new DuplicatesManager({}); manager.updateBasisDisplay(); expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.loraCombo'); 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' ); }); });