test(frontend): cover embeddings and recipes managers

This commit is contained in:
pixelpaws
2025-09-24 20:15:38 +08:00
parent 3ad8d8b17c
commit d7a75ea4e5
5 changed files with 343 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ 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 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 | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots |
| 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 |

View File

@@ -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 };

View File

@@ -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);
});
});

View File

@@ -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 = `
<option value="date">Date</option>
<option value="name">Name</option>
`;
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 = '<div>content</div>';
manager.exitDuplicateMode();
expect(grid.innerHTML).toBe('');
expect(duplicatesManagerInstance.exitDuplicateMode).toHaveBeenCalledTimes(1);
await manager.refreshRecipes();
expect(refreshRecipesMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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',
},
});
}