diff --git a/scripts/api.js b/scripts/api.js new file mode 100644 index 00000000..15559416 --- /dev/null +++ b/scripts/api.js @@ -0,0 +1,7 @@ +export const api = { + fetchApi: (...args) => fetch(...args), + addEventListener: (eventName, handler) => document.addEventListener(eventName, handler), + removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler), +}; + +export default api; diff --git a/scripts/app.js b/scripts/app.js new file mode 100644 index 00000000..53957e20 --- /dev/null +++ b/scripts/app.js @@ -0,0 +1,12 @@ +export const app = { + canvas: { ds: { scale: 1 } }, + extensionManager: { + toast: { + add: () => {}, + }, + }, + registerExtension: () => {}, + graphToPrompt: async () => ({ workflow: { nodes: new Map() } }), +}; + +export default app; diff --git a/static/js/statistics.js b/static/js/statistics.js index 930199aa..74ce2207 100644 --- a/static/js/statistics.js +++ b/static/js/statistics.js @@ -5,7 +5,7 @@ import { showToast } from './utils/uiHelpers.js'; // Chart.js import (assuming it's available globally or via CDN) // If Chart.js isn't available, we'll need to add it to the project -class StatisticsManager { +export class StatisticsManager { constructor() { this.charts = {}; this.data = {}; diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js new file mode 100644 index 00000000..742fdf16 --- /dev/null +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -0,0 +1,139 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + API_MODULE, + APP_MODULE, + CARET_HELPER_MODULE, + PREVIEW_COMPONENT_MODULE, + AUTOCOMPLETE_MODULE, +} = vi.hoisted(() => ({ + API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname, + APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname, + CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname, + PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname, + AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname, +})); + +const fetchApiMock = vi.fn(); +const caretHelperInstance = { + getBeforeCursor: vi.fn(() => ''), + getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })), +}; + +const previewTooltipMock = { + show: vi.fn(), + hide: vi.fn(), + cleanup: vi.fn(), +}; + +vi.mock(API_MODULE, () => ({ + api: { + fetchApi: fetchApiMock, + }, +})); + +vi.mock(APP_MODULE, () => ({ + app: { + canvas: { + ds: { scale: 1 }, + }, + }, +})); + +vi.mock(CARET_HELPER_MODULE, () => ({ + TextAreaCaretHelper: vi.fn(() => caretHelperInstance), +})); + +vi.mock(PREVIEW_COMPONENT_MODULE, () => ({ + PreviewTooltip: vi.fn(() => previewTooltipMock), +})); + +describe('AutoComplete widget interactions', () => { + beforeEach(() => { + document.body.innerHTML = ''; + document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove()); + Element.prototype.scrollIntoView = vi.fn(); + fetchApiMock.mockReset(); + caretHelperInstance.getBeforeCursor.mockReset(); + caretHelperInstance.getCursorOffset.mockReset(); + caretHelperInstance.getBeforeCursor.mockReturnValue(''); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 }); + previewTooltipMock.show.mockReset(); + previewTooltipMock.hide.mockReset(); + previewTooltipMock.cleanup.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches and renders search results when input exceeds the minimum characters', async () => { + vi.useFakeTimers(); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false }); + + input.value = 'example'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=20'); + const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item'); + expect(items).toHaveLength(1); + expect(autoComplete.dropdown.style.display).toBe('block'); + expect(autoComplete.isVisible).toBe(true); + expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled(); + }); + + it('inserts the selected LoRA with usage tip strengths and restores focus', async () => { + fetchApiMock.mockImplementation((url) => { + if (url.includes('usage-tips-by-path')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + usage_tips: JSON.stringify({ strength: '1.5', clip_strength: '0.9' }), + }), + }); + } + + return Promise.resolve({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example'); + + const input = document.createElement('textarea'); + input.value = 'alpha, example'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false }); + + await autoComplete.insertSelection('models/example.safetensors'); + + expect(fetchApiMock).toHaveBeenCalledWith( + '/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors', + ); + expect(input.value).toContain(', '); + expect(autoComplete.dropdown.style.display).toBe('none'); + expect(input.focus).toHaveBeenCalled(); + expect(input.setSelectionRange).toHaveBeenCalled(); + }); +}); diff --git a/tests/frontend/components/lorasWidgetEvents.interactions.test.js b/tests/frontend/components/lorasWidgetEvents.interactions.test.js new file mode 100644 index 00000000..44d62448 --- /dev/null +++ b/tests/frontend/components/lorasWidgetEvents.interactions.test.js @@ -0,0 +1,108 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + EVENTS_MODULE, + API_MODULE, + APP_MODULE, + COMPONENTS_MODULE, +} = vi.hoisted(() => ({ + EVENTS_MODULE: new URL('../../../web/comfyui/loras_widget_events.js', import.meta.url).pathname, + API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname, + APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname, + COMPONENTS_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname, +})); + +vi.mock(API_MODULE, () => ({ + api: {}, +})); + +vi.mock(APP_MODULE, () => ({ + app: {}, +})); + +vi.mock(COMPONENTS_MODULE, () => ({ + createMenuItem: vi.fn(), + createDropIndicator: vi.fn(), +})); + +describe('LoRA widget drag interactions', () => { + beforeEach(() => { + document.body.innerHTML = ''; + const dragStyle = document.getElementById('comfy-lora-drag-style'); + if (dragStyle) { + dragStyle.remove(); + } + }); + + afterEach(() => { + document.body.classList.remove('comfy-lora-dragging'); + }); + + it('adjusts a single LoRA strength while syncing collapsed clip strength', async () => { + const { handleStrengthDrag } = await import(EVENTS_MODULE); + + const widget = { + value: [ + { name: 'Test', strength: 0.5, clipStrength: 0.25, expanded: false }, + ], + callback: vi.fn(), + }; + + handleStrengthDrag('Test', 0.5, 100, { clientX: 140 }, widget, false); + + expect(widget.value[0].strength).toBeCloseTo(0.54, 2); + expect(widget.value[0].clipStrength).toBeCloseTo(0.54, 2); + expect(widget.callback).toHaveBeenCalledWith(widget.value); + }); + + it('applies proportional drag updates to all LoRAs', async () => { + const { handleAllStrengthsDrag } = await import(EVENTS_MODULE); + + const widget = { + value: [ + { name: 'A', strength: 0.4, clipStrength: 0.4 }, + { name: 'B', strength: 0.6, clipStrength: 0.6 }, + ], + callback: vi.fn(), + }; + + const initialStrengths = [ + { modelStrength: 0.4, clipStrength: 0.4 }, + { modelStrength: 0.6, clipStrength: 0.6 }, + ]; + + handleAllStrengthsDrag(initialStrengths, 100, { clientX: 160 }, widget); + + expect(widget.value[0].strength).toBeCloseTo(0.41, 2); + expect(widget.value[1].strength).toBeCloseTo(0.62, 2); + expect(widget.callback).toHaveBeenCalledWith(widget.value); + }); + + it('initiates drag gestures, updates strength, and clears cursor state on mouseup', async () => { + const module = await import(EVENTS_MODULE); + const renderSpy = vi.fn(); + const previewSpy = { hide: vi.fn() }; + + const dragEl = document.createElement('div'); + dragEl.className = 'comfy-lora-entry'; + document.body.append(dragEl); + + const widget = { + value: [{ name: 'Test', strength: 0.5, clipStrength: 0.5 }], + callback: vi.fn(), + }; + + module.initDrag(dragEl, 'Test', widget, false, previewSpy, renderSpy); + + dragEl.dispatchEvent(new MouseEvent('mousedown', { clientX: 50, bubbles: true })); + expect(document.body.classList.contains('comfy-lora-dragging')).toBe(true); + + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 70, bubbles: true })); + expect(renderSpy).toHaveBeenCalledWith(widget.value, widget); + expect(previewSpy.hide).toHaveBeenCalled(); + expect(widget.value[0].strength).not.toBe(0.5); + + document.dispatchEvent(new MouseEvent('mouseup')); + expect(document.body.classList.contains('comfy-lora-dragging')).toBe(false); + }); +}); diff --git a/tests/frontend/pages/statistics.dashboard.test.js b/tests/frontend/pages/statistics.dashboard.test.js new file mode 100644 index 00000000..a6a94a50 --- /dev/null +++ b/tests/frontend/pages/statistics.dashboard.test.js @@ -0,0 +1,142 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import { renderTemplate, resetDom } from '../utils/domFixtures.js'; + +const { CORE_MODULE, UI_HELPERS_MODULE, STATISTICS_MODULE } = vi.hoisted(() => ({ + CORE_MODULE: new URL('../../../static/js/core.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + STATISTICS_MODULE: new URL('../../../static/js/statistics.js', import.meta.url).pathname, +})); + +const appCoreInitializeMock = vi.fn(); +const showToastMock = vi.fn(); + +vi.mock(CORE_MODULE, () => ({ + appCore: { + initialize: appCoreInitializeMock, + }, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, +})); + +describe('Statistics dashboard rendering', () => { + beforeEach(() => { + resetDom(); + appCoreInitializeMock.mockResolvedValue(); + showToastMock.mockReset(); + globalThis.Chart = undefined; + }); + + afterEach(() => { + delete window.statsManager; + }); + + it('hydrates dashboard panels with fetched data and wires tab interactions', async () => { + renderTemplate('statistics.html'); + + const dataset = { + '/api/lm/stats/collection-overview': { + data: { + total_models: 4, + total_size: 4096, + total_generations: 200, + lora_count: 2, + checkpoint_count: 1, + embedding_count: 1, + unused_loras: 1, + unused_checkpoints: 0, + unused_embeddings: 0, + lora_size: 2048, + checkpoint_size: 1024, + embedding_size: 1024, + }, + }, + '/api/lm/stats/usage-analytics': { + data: { + top_loras: [ + { name: 'Lora A', base_model: 'SDXL', folder: 'loras', usage_count: 10 }, + ], + top_checkpoints: [ + { name: 'Checkpoint A', base_model: 'SDXL', folder: 'checkpoints', usage_count: 5 }, + ], + top_embeddings: [ + { name: 'Embedding A', base_model: 'SDXL', folder: 'embeddings', usage_count: 7 }, + ], + usage_timeline: [ + { date: '2024-01-01', lora_usage: 5, checkpoint_usage: 3, embedding_usage: 2 }, + ], + }, + }, + '/api/lm/stats/base-model-distribution': { + data: { + loras: { SDXL: 2 }, + checkpoints: { SDXL: 1 }, + embeddings: { SDXL: 1 }, + }, + }, + '/api/lm/stats/tag-analytics': { + data: { + top_tags: [ + { tag: 'anime', count: 5 }, + { tag: 'photo', count: 3 }, + ], + total_unique_tags: 2, + }, + }, + '/api/lm/stats/storage-analytics': { + data: { + loras: [ + { name: 'Lora A', base_model: 'SDXL', size: 2048, usage_count: 10 }, + ], + checkpoints: [ + { name: 'Checkpoint A', base_model: 'SDXL', size: 1024, usage_count: 5 }, + ], + embeddings: [], + }, + }, + '/api/lm/stats/insights': { + data: { + insights: [ + { + type: 'info', + title: 'Balance usage', + description: 'Redistribute usage across models.', + suggestion: 'Try lesser-used checkpoints.', + }, + ], + }, + }, + }; + + const { StatisticsManager } = await import(STATISTICS_MODULE); + const manager = new StatisticsManager(); + const refreshSpy = vi.spyOn(manager, 'refreshChartsInPanel'); + vi.spyOn(manager, 'fetchData').mockImplementation((endpoint) => Promise.resolve(dataset[endpoint])); + + await manager.initialize(); + + expect(manager.initialized).toBe(true); + expect(document.querySelectorAll('.metric-card').length).toBeGreaterThan(0); + expect(document.querySelector('#topLorasList .model-item')).not.toBeNull(); + expect(document.querySelector('#tagCloud').textContent).toContain('anime'); + expect(document.querySelector('#insightsList .insight-card')).not.toBeNull(); + + const usageButton = document.querySelector('.tab-button[data-tab="usage"]'); + usageButton.click(); + + expect(refreshSpy).toHaveBeenCalledWith('usage'); + expect(document.getElementById('usage-panel').classList.contains('active')).toBe(true); + expect(document.querySelector('.tab-button.active').dataset.tab).toBe('usage'); + }); + + it('surfaces an error toast when statistics data fails to load', async () => { + const { StatisticsManager } = await import(STATISTICS_MODULE); + const manager = new StatisticsManager(); + vi.spyOn(manager, 'fetchData').mockRejectedValue(new Error('unavailable')); + + await manager.loadAllData(); + + expect(showToastMock).toHaveBeenCalledWith('toast.general.statisticsLoadFailed', {}, 'error'); + }); +}); diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js new file mode 100644 index 00000000..94739553 --- /dev/null +++ b/tests/frontend/utils/uiHelpers.dom.test.js @@ -0,0 +1,108 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + I18N_MODULE, + STATE_MODULE, + STORAGE_MODULE, + CONSTANTS_MODULE, + EVENT_MANAGER_MODULE, + UI_HELPERS_MODULE, +} = vi.hoisted(() => ({ + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, + CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname, + EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, +})); + +const translateMock = vi.fn((key, _params, fallback) => fallback || key); +const getStorageItemMock = vi.fn(); +const setStorageItemMock = vi.fn(); + +vi.mock(I18N_MODULE, () => ({ + translate: translateMock, +})); + +vi.mock(STATE_MODULE, () => ({ + state: {}, + getCurrentPageState: vi.fn(), +})); + +vi.mock(STORAGE_MODULE, () => ({ + getStorageItem: getStorageItemMock, + setStorageItem: setStorageItemMock, +})); + +vi.mock(CONSTANTS_MODULE, () => ({ + NODE_TYPE_ICONS: {}, + DEFAULT_NODE_COLOR: '#ffffff', +})); + +vi.mock(EVENT_MANAGER_MODULE, () => ({ + eventManager: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + addHandler: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +describe('UI helper DOM utilities', () => { + beforeEach(() => { + document.body.innerHTML = ''; + document.body.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-theme'); + getStorageItemMock.mockReset(); + setStorageItemMock.mockReset(); + translateMock.mockReset(); + globalThis.requestAnimationFrame = (cb) => cb(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates toast elements and cleans them up after timeout', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Toast message'); + + const { showToast } = await import(UI_HELPERS_MODULE); + + showToast('uiHelpers.clipboard.copied', {}, 'success'); + + const container = document.querySelector('.toast-container'); + expect(container).not.toBeNull(); + expect(container.querySelectorAll('.toast')).toHaveLength(1); + + await Promise.resolve(); + vi.advanceTimersByTime(2000); + + const toast = container.querySelector('.toast'); + toast.dispatchEvent(new Event('transitionend', { bubbles: true })); + await Promise.resolve(); + + expect(toast.classList.contains('show')).toBe(false); + }); + + it('toggles the persisted theme and updates DOM attributes', async () => { + getStorageItemMock.mockReturnValue('light'); + document.body.innerHTML = ''; + globalThis.matchMedia = vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + + const { toggleTheme } = await import(UI_HELPERS_MODULE); + + const nextTheme = toggleTheme(); + + expect(nextTheme).toBe('dark'); + expect(setStorageItemMock).toHaveBeenCalledWith('theme', 'dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(document.body.dataset.theme).toBe('dark'); + expect(document.querySelector('.theme-toggle').classList.contains('theme-dark')).toBe(true); + }); +});