import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; const showToastMock = vi.fn(); const translateMock = vi.fn((key, params, fallback) => { if (typeof fallback !== 'string') { return key; } return fallback.replace(/\{(\w+)\}/g, (match, name) => params && params[name] != null ? String(params[name]) : match ); }); const loadingManagerStub = { showSimpleLoading: vi.fn(), hide: vi.fn(), show: vi.fn(), restoreProgressBar: vi.fn(), }; const virtualScrollerStub = { updateSingleItem: vi.fn(), getNavigationState: vi.fn(() => ({ index: 0, hasPrev: false, hasNext: false, loadedItems: 1, totalItems: 1, })), getAdjacentItemByFilePath: vi.fn(async () => null), }; const stateStub = { global: { settings: {}, loadingManager: loadingManagerStub }, loadingManager: loadingManagerStub, virtualScroller: virtualScrollerStub, }; const modalManagerMock = { showModal: vi.fn(), closeModal: vi.fn(), }; const downloadVersionWithDefaultsMock = vi.fn(() => Promise.resolve()); vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: showToastMock, copyToClipboard: vi.fn(), sendLoraToWorkflow: vi.fn(), sendModelPathToWorkflow: vi.fn(), openCivitaiByMetadata: vi.fn(), stripLoraTags: vi.fn((text) => text), sendPromptToWorkflow: vi.fn(), sendGenParamsToWorkflow: vi.fn(), })); vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ translate: translateMock, })); vi.mock('../../../static/js/state/index.js', () => ({ state: stateStub, })); vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ setSessionItem: vi.fn(), removeSessionItem: vi.fn(), getSessionItem: vi.fn(() => null), getStorageItem: vi.fn(() => null), setStorageItem: vi.fn(), removeStorageItem: vi.fn(), })); const fetchRecipeDetailsMock = vi.fn(() => Promise.resolve({})); vi.mock('../../../static/js/api/recipeApi.js', () => ({ fetchRecipeDetails: fetchRecipeDetailsMock, updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })), sendRecipeWorkflow: vi.fn(), extractRecipeId: (filePath) => { if (!filePath) return null; const basename = filePath.split('/').pop().split('\\').pop(); const dotIndex = basename.lastIndexOf('.'); return dotIndex > 0 ? basename.substring(0, dotIndex) : basename; }, })); vi.mock('../../../static/js/api/apiConfig.js', () => ({ MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', }, })); vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ downloadManager: { downloadVersionWithDefaults: downloadVersionWithDefaultsMock, }, })); function recipeModalFixture() { return ` `; } const missingLora = { name: 'gone-lora', modelName: 'Gone LoRA', inLibrary: false, modelId: 123, id: 456, }; const hashOnlyLora = { name: 'hash-lora', modelName: 'Hash LoRA', inLibrary: false, hash: 'deadbeefcafe', }; const recipeWithResources = { id: 'recipe-resources', file_path: '/recipes/resources.json', title: 'Resource Recipe', tags: [], checkpoint: { name: 'gone-checkpoint', inLibrary: false, modelId: 900, id: 901, }, loras: [ { name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' }, missingLora, { name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true }, { name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false }, hashOnlyLora, ], }; const createdModals = []; async function createRecipeModal() { const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); const recipeModal = new RecipeModal(); createdModals.push(recipeModal); return recipeModal; } function flushWiring() { // Item action/navigation wiring happens in a setTimeout after rendering return new Promise(resolve => setTimeout(resolve, 150)); } describe('RecipeModal resource item interactions', () => { beforeEach(() => { vi.clearAllMocks(); // clearAllMocks keeps one-off mockResolvedValue implementations; reset // the shared mocks to their defaults explicitly. downloadVersionWithDefaultsMock.mockReset(); downloadVersionWithDefaultsMock.mockResolvedValue(undefined); fetchRecipeDetailsMock.mockReset(); // Hydration re-fetches the recipe right after render; resolving an empty // object would delete currentRecipe.loras and wipe the list, so resolve // the full recipe by default (individual tests can override afterwards). fetchRecipeDetailsMock.mockResolvedValue(recipeWithResources); document.body.innerHTML = recipeModalFixture(); global.modalManager = modalManagerMock; global.fetch = vi.fn(async () => ({ ok: true, json: async () => ({}), })); }); afterEach(() => { createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts()); createdModals.length = 0; document.body.innerHTML = ''; delete global.modalManager; delete global.fetch; }); it('renders the missing badge as a pure status indicator with a tooltip', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); const badge = document.querySelector('.recipe-lora-item.missing-locally .missing-badge'); expect(badge).not.toBeNull(); expect(badge.tagName).toBe('DIV'); expect(badge.title).toBe('This model is not in your library'); expect(badge.classList.contains('reconnectable')).toBe(false); expect(badge.textContent).toContain('Not in Library'); }); it('renders an explicit download action and an inline title link for a missing LoRA', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); const item = document.querySelector('.recipe-lora-item.missing-locally:not(.checkpoint-item)'); // The Civitai link belongs to the title, not the action row const link = item.querySelector('.recipe-lora-title a.recipe-civitai-link'); expect(link).not.toBeNull(); expect(link.target).toBe('_blank'); expect(link.rel).toContain('noopener'); expect(link.href).toBe('https://civitai.com/models/123?modelVersionId=456'); expect(link.title).toBe('View on Civitai'); const actions = item.querySelector('.recipe-lora-actions'); expect(actions).not.toBeNull(); const downloadButton = actions.querySelector('.lora-download'); expect(downloadButton).not.toBeNull(); expect(downloadButton.tagName).toBe('BUTTON'); expect(downloadButton.type).toBe('button'); expect(downloadButton.classList.contains('resource-action')).toBe(true); expect(downloadButton.classList.contains('primary')).toBe(true); expect(downloadButton.title).toBe('Download this LoRA'); // The action row holds only real actions; no link icon in it expect(actions.querySelector('.recipe-civitai-link')).toBeNull(); }); it('downloads only the clicked LoRA via the download manager', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const downloadButton = document.querySelector('.lora-download'); downloadButton.click(); await vi.waitFor(() => { expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); }); expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith( 'loras', 123, 456, expect.objectContaining({ source: 'recipe-modal' }) ); }); it('does not navigate when a missing LoRA row is clicked', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi .spyOn(recipeModal, 'navigateToLorasPage') .mockImplementation(() => {}); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const missingItem = document.querySelector('.recipe-lora-item.missing-locally'); expect(missingItem.getAttribute('role')).toBeNull(); expect(missingItem.getAttribute('tabindex')).toBeNull(); missingItem.click(); expect(navigateSpy).not.toHaveBeenCalled(); }); it('keeps in-library rows navigable and keyboard accessible', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi .spyOn(recipeModal, 'navigateToLorasPage') .mockImplementation(() => {}); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const localItem = document.querySelector('.recipe-lora-item.exists-locally:not(.checkpoint-item)'); expect(localItem.getAttribute('role')).toBe('button'); expect(localItem.getAttribute('tabindex')).toBe('0'); localItem.click(); expect(navigateSpy).toHaveBeenCalledWith(0); navigateSpy.mockClear(); localItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(navigateSpy).toHaveBeenCalledWith(0); }); it('renders deleted LoRAs with a status badge and an explicit reconnect button', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const deletedItem = document.querySelector('.recipe-lora-item.is-deleted'); const badge = deletedItem.querySelector('.deleted-badge'); expect(badge).not.toBeNull(); expect(badge.classList.contains('reconnectable')).toBe(false); expect(badge.title).toContain('deleted from the source'); const reconnectButton = deletedItem.querySelector('.lora-reconnect'); expect(reconnectButton).not.toBeNull(); expect(reconnectButton.tagName).toBe('BUTTON'); expect(reconnectButton.classList.contains('ghost')).toBe(true); reconnectButton.click(); const container = deletedItem.querySelector('.lora-reconnect-container'); expect(container.classList.contains('active')).toBe(true); }); it('renders no action row when neither identifiers nor hash are available', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); const mysteryItem = document.querySelector('[data-lora-index="3"]'); expect(mysteryItem.querySelector('.lora-download')).toBeNull(); // No actions at all -> no empty action row taking vertical space expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull(); // The name-fallback search link still sits inline in the title const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link'); expect(link).not.toBeNull(); expect(link.href).toContain('query=Mystery%20LoRA'); }); it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => { const recipeModal = await createRecipeModal(); global.fetch = vi.fn(async (url) => ({ ok: true, json: async () => typeof url === 'string' && url.includes('/civitai/model/hash/') ? { id: 789, modelId: 777, name: 'Hash LoRA v1' } : {}, })); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const hashItem = document.querySelector('[data-lora-index="4"]'); const downloadButton = hashItem.querySelector('.lora-download'); expect(downloadButton).not.toBeNull(); downloadButton.click(); await vi.waitFor(() => { expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); }); // Hash resolution needs a network round trip; immediate feedback must // appear while the user waits for the progress UI expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Preparing download...'); expect(loadingManagerStub.hide).toHaveBeenCalled(); expect(global.fetch).toHaveBeenCalledWith( '/api/lm/loras/civitai/model/hash/deadbeefcafe' ); expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith( 'loras', 777, 789, expect.objectContaining({ source: 'recipe-modal', versionName: 'Hash LoRA v1' }) ); }); it('refreshes the resources section and the recipe card after a successful download', async () => { const recipeModal = await createRecipeModal(); downloadVersionWithDefaultsMock.mockResolvedValue(true); const updatedRecipe = { ...recipeWithResources, loras: recipeWithResources.loras.map((lora, index) => index === 1 ? { ...lora, inLibrary: true } : lora ), }; recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); fetchRecipeDetailsMock.mockResolvedValue(updatedRecipe); const downloadButton = document.querySelector('.lora-download'); downloadButton.click(); await vi.waitFor(() => { expect(fetchRecipeDetailsMock).toHaveBeenCalledWith('recipe-resources'); }); // The recipe card on the listing page receives the fresh recipe data await vi.waitFor(() => { expect(virtualScrollerStub.updateSingleItem).toHaveBeenCalledWith( '/recipes/resources.json', updatedRecipe ); }); // The modal's resources section re-renders with the flipped status await vi.waitFor(() => { const item = document.querySelector('[data-lora-index="1"]'); expect(item.classList.contains('exists-locally')).toBe(true); expect(item.querySelector('.local-badge')).not.toBeNull(); expect(item.querySelector('.missing-badge')).toBeNull(); }); }); it('shows a status badge, download action and Civitai link for a missing checkpoint without row navigation', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi .spyOn(recipeModal, 'navigateToCheckpointPage') .mockImplementation(() => {}); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const checkpointItem = document.querySelector('.checkpoint-item'); expect(checkpointItem.classList.contains('missing-locally')).toBe(true); expect(checkpointItem.getAttribute('role')).toBeNull(); expect(checkpointItem.querySelector('.missing-badge')).not.toBeNull(); expect(checkpointItem.querySelector('.checkpoint-download')).not.toBeNull(); const link = checkpointItem.querySelector('.recipe-lora-title a.recipe-civitai-link'); expect(link).not.toBeNull(); expect(link.href).toBe('https://civitai.com/models/900?modelVersionId=901'); checkpointItem.click(); expect(navigateSpy).not.toHaveBeenCalled(); }); });