diff --git a/docs/frontend-testing-roadmap.md b/docs/frontend-testing-roadmap.md
index 50025cb7..952b5af8 100644
--- a/docs/frontend-testing-roadmap.md
+++ b/docs/frontend-testing-roadmap.md
@@ -9,8 +9,8 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
| Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed |
| Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | ✅ Complete | Storage helpers and state selectors now exercised via deterministic suites |
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating |
-| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | 🚧 In Progress | LoRA + checkpoints smoke suites landed; filter/sort scenario matrix drafted to guide upcoming specs |
-| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots |
+| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ✅ Complete | LoRA/checkpoint suites expanded; embeddings + recipes managers now covered with initialization, filtering, and duplicate workflows |
+| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ✅ Complete | Vitest DOM suites cover NSFW selector, recipe modal editing, and global context menus |
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting |
## Next Steps Checklist
@@ -20,6 +20,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
- [x] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies.
- [x] Add AppCore page feature suite exercising context menu creation and infinite scroll registration via DOM fixtures.
- [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios.
+- [x] Add interaction regression suites for context menus and recipe modals to complete Phase 4.
- [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
- [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added.
- [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3.
diff --git a/static/js/embeddings.js b/static/js/embeddings.js
index 1352471c..654d250c 100644
--- a/static/js/embeddings.js
+++ b/static/js/embeddings.js
@@ -36,12 +36,18 @@ class EmbeddingsPageManager {
}
}
-// Initialize everything when DOM is ready
-document.addEventListener('DOMContentLoaded', async () => {
+async function initializeEmbeddingsPage() {
// Initialize core application
await appCore.initialize();
-
+
// Initialize embeddings page
const embeddingsPage = new EmbeddingsPageManager();
await embeddingsPage.initialize();
-});
+
+ return embeddingsPage;
+}
+
+// Initialize everything when DOM is ready
+document.addEventListener('DOMContentLoaded', initializeEmbeddingsPage);
+
+export { EmbeddingsPageManager, initializeEmbeddingsPage };
diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js
new file mode 100644
index 00000000..7dd18ad5
--- /dev/null
+++ b/tests/frontend/components/contextMenu.interactions.test.js
@@ -0,0 +1,319 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const showToastMock = vi.fn();
+const copyToClipboardMock = vi.fn();
+const getNSFWLevelNameMock = vi.fn((level) => {
+ if (level >= 16) return 'XXX';
+ if (level >= 8) return 'X';
+ if (level >= 4) return 'R';
+ if (level >= 2) return 'PG13';
+ if (level >= 1) return 'PG';
+ return 'Unknown';
+});
+const copyLoraSyntaxMock = vi.fn();
+const sendLoraToWorkflowMock = vi.fn();
+const buildLoraSyntaxMock = vi.fn((fileName) => `lora:${fileName}`);
+const openExampleImagesFolderMock = vi.fn();
+
+const modalManagerMock = {
+ showModal: vi.fn(),
+ closeModal: vi.fn(),
+ registerModal: vi.fn(),
+ getModal: vi.fn(() => ({ element: { style: { display: 'none' } }, isOpen: false })),
+ isAnyModalOpen: vi.fn(),
+};
+
+const loadingManagerStub = {
+ showSimpleLoading: vi.fn(),
+ hide: vi.fn(),
+ show: vi.fn(),
+};
+
+const stateStub = {
+ global: { settings: {}, loadingManager: loadingManagerStub },
+ loadingManager: loadingManagerStub,
+ virtualScroller: { updateSingleItem: vi.fn() },
+};
+
+const saveModelMetadataMock = vi.fn();
+const downloadExampleImagesApiMock = vi.fn();
+const replaceModelPreviewMock = vi.fn();
+const refreshSingleModelMetadataMock = vi.fn();
+const resetAndReloadMock = vi.fn();
+
+const getModelApiClientMock = vi.fn(() => ({
+ saveModelMetadata: saveModelMetadataMock,
+ downloadExampleImages: downloadExampleImagesApiMock,
+ replaceModelPreview: replaceModelPreviewMock,
+ refreshSingleModelMetadata: refreshSingleModelMetadataMock,
+}));
+
+const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
+
+vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
+ showToast: showToastMock,
+ copyToClipboard: copyToClipboardMock,
+ getNSFWLevelName: getNSFWLevelNameMock,
+ copyLoraSyntax: copyLoraSyntaxMock,
+ sendLoraToWorkflow: sendLoraToWorkflowMock,
+ buildLoraSyntax: buildLoraSyntaxMock,
+ openExampleImagesFolder: openExampleImagesFolderMock,
+}));
+
+vi.mock('../../../static/js/managers/ModalManager.js', () => ({
+ modalManager: modalManagerMock,
+}));
+
+vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
+ setSessionItem: vi.fn(),
+ removeSessionItem: vi.fn(),
+ getSessionItem: vi.fn(),
+}));
+
+vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
+ getModelApiClient: getModelApiClientMock,
+ resetAndReload: resetAndReloadMock,
+}));
+
+vi.mock('../../../static/js/state/index.js', () => ({
+ state: stateStub,
+}));
+
+vi.mock('../../../static/js/utils/modalUtils.js', () => ({
+ showExcludeModal: vi.fn(),
+ showDeleteModal: vi.fn(),
+}));
+
+vi.mock('../../../static/js/managers/MoveManager.js', () => ({
+ moveManager: { showMoveModal: vi.fn() },
+}));
+
+vi.mock('../../../static/js/api/recipeApi.js', () => ({
+ updateRecipeMetadata: updateRecipeMetadataMock,
+}));
+
+async function flushAsyncTasks() {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe('Interaction-level regression coverage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ stateStub.global.settings = {};
+ saveModelMetadataMock.mockResolvedValue(undefined);
+ downloadExampleImagesApiMock.mockResolvedValue(undefined);
+ updateRecipeMetadataMock.mockResolvedValue({ success: true });
+ global.modalManager = modalManagerMock;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ delete window.exampleImagesManager;
+ delete global.fetch;
+ delete global.modalManager;
+ });
+
+ it('opens the NSFW selector from the LoRA context menu and persists the new rating', async () => {
+ document.body.innerHTML = `
+
+
+ `;
+
+ const card = document.createElement('div');
+ card.className = 'model-card';
+ card.dataset.filepath = '/models/test.safetensors';
+ card.dataset.meta = JSON.stringify({ preview_nsfw_level: 1 });
+ document.body.appendChild(card);
+
+ const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
+ const helpers = await import('../../../static/js/utils/uiHelpers.js');
+ expect(helpers.showToast).toBe(showToastMock);
+ const contextMenu = new LoraContextMenu();
+
+ contextMenu.showMenu(120, 140, card);
+
+ const nsfwMenuItem = document.querySelector('#loraContextMenu .context-menu-item[data-action="set-nsfw"]');
+ nsfwMenuItem.dispatchEvent(new Event('click', { bubbles: true }));
+
+ const selector = document.getElementById('nsfwLevelSelector');
+ expect(selector.style.display).toBe('block');
+ expect(selector.dataset.cardPath).toBe('/models/test.safetensors');
+ expect(document.getElementById('currentNSFWLevel').textContent).toBe('PG');
+
+ const levelButton = selector.querySelector('.nsfw-level-btn[data-level="4"]');
+ levelButton.dispatchEvent(new Event('click', { bubbles: true }));
+
+ expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/test.safetensors', { preview_nsfw_level: 4 });
+ expect(saveModelMetadataMock).toHaveBeenCalledTimes(1);
+ await saveModelMetadataMock.mock.results[0].value;
+ await flushAsyncTasks();
+ expect(selector.style.display).toBe('none');
+ expect(document.getElementById('loraContextMenu').style.display).toBe('none');
+ });
+
+ it('wires recipe modal title editing to update metadata and UI state', async () => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
![Recipe Preview]()
+
+
+
+
+
+
+
+ `;
+
+ const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
+ const recipeModal = new RecipeModal();
+
+ const recipe = {
+ id: 'recipe-1',
+ file_path: '/recipes/test.json',
+ title: 'Original Title',
+ tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6'],
+ file_url: '',
+ preview_url: '',
+ source_path: '',
+ gen_params: {
+ prompt: 'Prompt text',
+ negative_prompt: 'Negative prompt',
+ steps: '30',
+ },
+ loras: [],
+ };
+
+ recipeModal.showRecipeDetails(recipe);
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ await flushAsyncTasks();
+
+ expect(modalManagerMock.showModal).toHaveBeenCalledWith('recipeModal');
+
+ const editIcon = document.querySelector('#recipeModalTitle .edit-icon');
+ editIcon.dispatchEvent(new Event('click', { bubbles: true }));
+
+ const titleInput = document.querySelector('#recipeTitleEditor .title-input');
+ titleInput.value = 'Updated Title';
+
+ recipeModal.saveTitleEdit();
+
+ expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/test.json', { title: 'Updated Title' });
+ expect(updateRecipeMetadataMock).toHaveBeenCalledTimes(1);
+ await updateRecipeMetadataMock.mock.results[0].value;
+ await flushAsyncTasks();
+
+ const titleContainer = document.getElementById('recipeModalTitle');
+ expect(titleContainer.querySelector('.content-text').textContent).toBe('Updated Title');
+ expect(titleContainer.querySelector('#recipeTitleEditor').classList.contains('active')).toBe(false);
+ expect(recipeModal.currentRecipe.title).toBe('Updated Title');
+ });
+
+ it('processes global context menu actions for downloads and cleanup', async () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
+ const menu = new GlobalContextMenu();
+
+ stateStub.global.settings.example_images_path = '/tmp/examples';
+ window.exampleImagesManager = {
+ handleDownloadButton: vi.fn().mockResolvedValue(undefined),
+ };
+
+ menu.showMenu(100, 200);
+ const downloadItem = document.querySelector('[data-action="download-example-images"]');
+ downloadItem.dispatchEvent(new Event('click', { bubbles: true }));
+ expect(downloadItem.classList.contains('disabled')).toBe(true);
+
+ expect(window.exampleImagesManager.handleDownloadButton).toHaveBeenCalledTimes(1);
+ await window.exampleImagesManager.handleDownloadButton.mock.results[0].value;
+ await flushAsyncTasks();
+ expect(downloadItem.classList.contains('disabled')).toBe(false);
+ expect(document.getElementById('globalContextMenu').style.display).toBe('none');
+
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, moved_total: 2 }),
+ });
+
+ menu.showMenu(240, 320);
+ const cleanupItem = document.querySelector('[data-action="cleanup-example-images-folders"]');
+ cleanupItem.dispatchEvent(new Event('click', { bubbles: true }));
+ expect(cleanupItem.classList.contains('disabled')).toBe(true);
+
+ expect(global.fetch).toHaveBeenCalledWith('/api/lm/cleanup-example-image-folders', { method: 'POST' });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ const responsePromise = global.fetch.mock.results[0].value;
+ const response = await responsePromise;
+ await response.json();
+ await flushAsyncTasks();
+ expect(cleanupItem.classList.contains('disabled')).toBe(false);
+ expect(menu._cleanupInProgress).toBe(false);
+ });
+});
diff --git a/tests/frontend/pages/embeddingsPage.test.js b/tests/frontend/pages/embeddingsPage.test.js
new file mode 100644
index 00000000..4d0f754f
--- /dev/null
+++ b/tests/frontend/pages/embeddingsPage.test.js
@@ -0,0 +1,99 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderEmbeddingsPage } from '../utils/pageFixtures.js';
+
+const initializeAppMock = vi.fn();
+const initializePageFeaturesMock = vi.fn();
+const createPageControlsMock = vi.fn();
+const confirmDeleteMock = vi.fn();
+const closeDeleteModalMock = vi.fn();
+const confirmExcludeMock = vi.fn();
+const closeExcludeModalMock = vi.fn();
+const duplicatesManagerMock = vi.fn();
+
+vi.mock('../../../static/js/core.js', () => ({
+ appCore: {
+ initialize: initializeAppMock,
+ initializePageFeatures: initializePageFeaturesMock,
+ },
+}));
+
+vi.mock('../../../static/js/components/controls/index.js', () => ({
+ createPageControls: createPageControlsMock,
+}));
+
+vi.mock('../../../static/js/utils/modalUtils.js', () => ({
+ confirmDelete: confirmDeleteMock,
+ closeDeleteModal: closeDeleteModalMock,
+ confirmExclude: confirmExcludeMock,
+ closeExcludeModal: closeExcludeModalMock,
+}));
+
+vi.mock('../../../static/js/api/apiConfig.js', () => ({
+ MODEL_TYPES: {
+ EMBEDDING: 'embeddings',
+ },
+}));
+
+vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
+ ModelDuplicatesManager: duplicatesManagerMock,
+}));
+
+describe('EmbeddingsPageManager', () => {
+ let EmbeddingsPageManager;
+ let initializeEmbeddingsPage;
+ let duplicatesManagerInstance;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ duplicatesManagerInstance = {
+ checkDuplicatesCount: vi.fn(),
+ };
+
+ duplicatesManagerMock.mockReturnValue(duplicatesManagerInstance);
+ createPageControlsMock.mockReturnValue({ destroy: vi.fn() });
+ initializeAppMock.mockResolvedValue(undefined);
+
+ renderEmbeddingsPage();
+
+ ({ EmbeddingsPageManager, initializeEmbeddingsPage } = await import('../../../static/js/embeddings.js'));
+ });
+
+ afterEach(() => {
+ delete window.confirmDelete;
+ delete window.closeDeleteModal;
+ delete window.confirmExclude;
+ delete window.closeExcludeModal;
+ delete window.modelDuplicatesManager;
+ });
+
+ it('wires page controls and exposes modal helpers during construction', () => {
+ const manager = new EmbeddingsPageManager();
+
+ expect(createPageControlsMock).toHaveBeenCalledWith('embeddings');
+ expect(duplicatesManagerMock).toHaveBeenCalledWith(manager, 'embeddings');
+
+ expect(window.confirmDelete).toBe(confirmDeleteMock);
+ expect(window.closeDeleteModal).toBe(closeDeleteModalMock);
+ expect(window.confirmExclude).toBe(confirmExcludeMock);
+ expect(window.closeExcludeModal).toBe(closeExcludeModalMock);
+ expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
+ });
+
+ it('initializes shared page features', async () => {
+ const manager = new EmbeddingsPageManager();
+
+ await manager.initialize();
+
+ expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('boots the embeddings page through the initializer', async () => {
+ const manager = await initializeEmbeddingsPage();
+
+ expect(initializeAppMock).toHaveBeenCalledTimes(1);
+ expect(manager).toBeInstanceOf(EmbeddingsPageManager);
+ expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
+ });
+});
diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js
new file mode 100644
index 00000000..74add4e0
--- /dev/null
+++ b/tests/frontend/pages/recipesPage.test.js
@@ -0,0 +1,209 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderRecipesPage } from '../utils/pageFixtures.js';
+
+const initializeAppMock = vi.fn();
+const initializePageFeaturesMock = vi.fn();
+const getCurrentPageStateMock = vi.fn();
+const getSessionItemMock = vi.fn();
+const removeSessionItemMock = vi.fn();
+const RecipeContextMenuMock = vi.fn();
+const refreshVirtualScrollMock = vi.fn();
+const refreshRecipesMock = vi.fn();
+
+let importManagerInstance;
+let recipeModalInstance;
+let duplicatesManagerInstance;
+
+const ImportManagerMock = vi.fn(() => importManagerInstance);
+const RecipeModalMock = vi.fn(() => recipeModalInstance);
+const DuplicatesManagerMock = vi.fn(() => duplicatesManagerInstance);
+
+vi.mock('../../../static/js/core.js', () => ({
+ appCore: {
+ initialize: initializeAppMock,
+ initializePageFeatures: initializePageFeaturesMock,
+ },
+}));
+
+vi.mock('../../../static/js/managers/ImportManager.js', () => ({
+ ImportManager: ImportManagerMock,
+}));
+
+vi.mock('../../../static/js/components/RecipeModal.js', () => ({
+ RecipeModal: RecipeModalMock,
+}));
+
+vi.mock('../../../static/js/state/index.js', () => ({
+ getCurrentPageState: getCurrentPageStateMock,
+}));
+
+vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
+ getSessionItem: getSessionItemMock,
+ removeSessionItem: removeSessionItemMock,
+}));
+
+vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
+ RecipeContextMenu: RecipeContextMenuMock,
+}));
+
+vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
+ DuplicatesManager: DuplicatesManagerMock,
+}));
+
+vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
+ refreshVirtualScroll: refreshVirtualScrollMock,
+}));
+
+vi.mock('../../../static/js/api/recipeApi.js', () => ({
+ refreshRecipes: refreshRecipesMock,
+}));
+
+describe('RecipeManager', () => {
+ let RecipeManager;
+ let pageState;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ importManagerInstance = {
+ showImportModal: vi.fn(),
+ };
+ recipeModalInstance = {
+ showRecipeDetails: vi.fn(),
+ };
+ duplicatesManagerInstance = {
+ findDuplicates: vi.fn(),
+ selectLatestDuplicates: vi.fn(),
+ deleteSelectedDuplicates: vi.fn(),
+ confirmDeleteDuplicates: vi.fn(),
+ exitDuplicateMode: vi.fn(),
+ };
+
+ pageState = {
+ sortBy: 'date',
+ searchOptions: undefined,
+ customFilter: undefined,
+ duplicatesMode: false,
+ };
+
+ getCurrentPageStateMock.mockImplementation(() => pageState);
+ initializeAppMock.mockResolvedValue(undefined);
+ initializePageFeaturesMock.mockResolvedValue(undefined);
+ refreshVirtualScrollMock.mockReset();
+ refreshVirtualScrollMock.mockImplementation(() => {});
+ refreshRecipesMock.mockResolvedValue('refreshed');
+
+ getSessionItemMock.mockImplementation((key) => {
+ const map = {
+ lora_to_recipe_filterLoraName: 'Flux Dream',
+ lora_to_recipe_filterLoraHash: 'abc123',
+ viewRecipeId: '42',
+ };
+ return map[key] ?? null;
+ });
+ removeSessionItemMock.mockImplementation(() => {});
+
+ renderRecipesPage();
+
+ ({ RecipeManager } = await import('../../../static/js/recipes.js'));
+ });
+
+ afterEach(() => {
+ delete window.recipeManager;
+ delete window.importManager;
+ });
+
+ it('initializes page controls, restores filters, and wires sort interactions', async () => {
+ const sortSelectElement = document.createElement('select');
+ sortSelectElement.id = 'sortSelect';
+ sortSelectElement.innerHTML = `
+
+
+ `;
+ document.body.appendChild(sortSelectElement);
+
+ const manager = new RecipeManager();
+ await manager.initialize();
+
+ expect(ImportManagerMock).toHaveBeenCalledTimes(1);
+ expect(RecipeModalMock).toHaveBeenCalledTimes(1);
+ expect(DuplicatesManagerMock).toHaveBeenCalledWith(manager);
+ expect(RecipeContextMenuMock).toHaveBeenCalledTimes(1);
+
+ expect(window.recipeManager).toBe(manager);
+ expect(window.importManager).toBe(importManagerInstance);
+
+ expect(pageState.searchOptions).toEqual({
+ title: true,
+ tags: true,
+ loraName: true,
+ loraModel: true,
+ });
+
+ expect(pageState.customFilter).toEqual({
+ active: true,
+ loraName: 'Flux Dream',
+ loraHash: 'abc123',
+ recipeId: '42',
+ });
+
+ const indicator = document.getElementById('customFilterIndicator');
+ expect(indicator.classList.contains('hidden')).toBe(false);
+
+ const clearButton = indicator.querySelector('.clear-filter');
+ clearButton.dispatchEvent(new Event('click', { bubbles: true }));
+
+ expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName');
+ expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash');
+ expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId');
+ expect(pageState.customFilter.active).toBe(false);
+ expect(indicator.classList.contains('hidden')).toBe(true);
+ expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
+
+ const sortSelect = document.getElementById('sortSelect');
+ sortSelect.value = 'name';
+ sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
+
+ expect(pageState.sortBy).toBe('name');
+ expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
+ expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips loading when duplicates mode is active and refreshes otherwise', async () => {
+ const manager = new RecipeManager();
+
+ pageState.duplicatesMode = true;
+ await manager.loadRecipes();
+ expect(refreshVirtualScrollMock).not.toHaveBeenCalled();
+
+ pageState.duplicatesMode = false;
+ await manager.loadRecipes();
+ expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('proxies duplicate management and refresh helpers', async () => {
+ const manager = new RecipeManager();
+
+ await manager.findDuplicateRecipes();
+ expect(duplicatesManagerInstance.findDuplicates).toHaveBeenCalledTimes(1);
+
+ manager.selectLatestDuplicates();
+ expect(duplicatesManagerInstance.selectLatestDuplicates).toHaveBeenCalledTimes(1);
+
+ manager.deleteSelectedDuplicates();
+ expect(duplicatesManagerInstance.deleteSelectedDuplicates).toHaveBeenCalledTimes(1);
+
+ manager.confirmDeleteDuplicates();
+ expect(duplicatesManagerInstance.confirmDeleteDuplicates).toHaveBeenCalledTimes(1);
+
+ const grid = document.getElementById('recipeGrid');
+ grid.innerHTML = 'content
';
+ manager.exitDuplicateMode();
+ expect(grid.innerHTML).toBe('');
+ expect(duplicatesManagerInstance.exitDuplicateMode).toHaveBeenCalledTimes(1);
+
+ await manager.refreshRecipes();
+ expect(refreshRecipesMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/frontend/utils/pageFixtures.js b/tests/frontend/utils/pageFixtures.js
index 7b4ed1b4..465ea8b3 100644
--- a/tests/frontend/utils/pageFixtures.js
+++ b/tests/frontend/utils/pageFixtures.js
@@ -23,3 +23,27 @@ export function renderCheckpointsPage() {
},
});
}
+
+/**
+ * Renders the Embeddings page template with expected dataset attributes.
+ * @returns {Element}
+ */
+export function renderEmbeddingsPage() {
+ return renderTemplate('embeddings.html', {
+ dataset: {
+ page: 'embeddings',
+ },
+ });
+}
+
+/**
+ * Renders the Recipes page template with expected dataset attributes.
+ * @returns {Element}
+ */
+export function renderRecipesPage() {
+ return renderTemplate('recipes.html', {
+ dataset: {
+ page: 'recipes',
+ },
+ });
+}