mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 13:12:12 -03:00
- Update package.json test script to run both JS and Vue tests - Simplify LoraCyclerLM output by removing redundant lora name fallback - Extend Vitest config to include TypeScript test files - Add Vue testing dependencies and setup for component testing - Implement comprehensive test suite for BatchQueueSimulator component - Add test setup file with global mocks for ComfyUI modules
635 lines
19 KiB
TypeScript
635 lines
19 KiB
TypeScript
/**
|
|
* Unit tests for useLoraCyclerState composable
|
|
*
|
|
* Tests pure state transitions and index calculations in isolation.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { useLoraCyclerState } from '@/composables/useLoraCyclerState'
|
|
import {
|
|
createMockWidget,
|
|
createMockCyclerConfig,
|
|
createMockPoolConfig
|
|
} from '../fixtures/mockConfigs'
|
|
import { setupFetchMock, resetFetchMock } from '../setup'
|
|
|
|
describe('useLoraCyclerState', () => {
|
|
beforeEach(() => {
|
|
resetFetchMock()
|
|
})
|
|
|
|
describe('Initial State', () => {
|
|
it('should initialize with default values', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
expect(state.currentIndex.value).toBe(1)
|
|
expect(state.totalCount.value).toBe(0)
|
|
expect(state.poolConfigHash.value).toBe('')
|
|
expect(state.modelStrength.value).toBe(1.0)
|
|
expect(state.clipStrength.value).toBe(1.0)
|
|
expect(state.useCustomClipRange.value).toBe(false)
|
|
expect(state.sortBy.value).toBe('filename')
|
|
expect(state.executionIndex.value).toBeNull()
|
|
expect(state.nextIndex.value).toBeNull()
|
|
expect(state.repeatCount.value).toBe(1)
|
|
expect(state.repeatUsed.value).toBe(0)
|
|
expect(state.displayRepeatUsed.value).toBe(0)
|
|
expect(state.isPaused.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('restoreFromConfig', () => {
|
|
it('should restore state from config object', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const config = createMockCyclerConfig({
|
|
current_index: 3,
|
|
total_count: 10,
|
|
model_strength: 0.8,
|
|
clip_strength: 0.6,
|
|
use_same_clip_strength: false,
|
|
repeat_count: 2,
|
|
repeat_used: 1,
|
|
is_paused: true
|
|
})
|
|
|
|
state.restoreFromConfig(config)
|
|
|
|
expect(state.currentIndex.value).toBe(3)
|
|
expect(state.totalCount.value).toBe(10)
|
|
expect(state.modelStrength.value).toBe(0.8)
|
|
expect(state.clipStrength.value).toBe(0.6)
|
|
expect(state.useCustomClipRange.value).toBe(true) // inverted from use_same_clip_strength
|
|
expect(state.repeatCount.value).toBe(2)
|
|
expect(state.repeatUsed.value).toBe(1)
|
|
expect(state.isPaused.value).toBe(true)
|
|
})
|
|
|
|
it('should handle missing optional fields with defaults', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
// Minimal config
|
|
state.restoreFromConfig({
|
|
current_index: 5,
|
|
total_count: 10,
|
|
pool_config_hash: '',
|
|
model_strength: 1.0,
|
|
clip_strength: 1.0,
|
|
use_same_clip_strength: true,
|
|
sort_by: 'filename',
|
|
current_lora_name: '',
|
|
current_lora_filename: '',
|
|
repeat_count: 1,
|
|
repeat_used: 0,
|
|
is_paused: false
|
|
})
|
|
|
|
expect(state.currentIndex.value).toBe(5)
|
|
expect(state.repeatCount.value).toBe(1)
|
|
expect(state.isPaused.value).toBe(false)
|
|
})
|
|
|
|
it('should not restore execution_index and next_index (transient values)', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
// Set execution indices
|
|
state.executionIndex.value = 2
|
|
state.nextIndex.value = 3
|
|
|
|
// Restore from config (these fields in config should be ignored)
|
|
state.restoreFromConfig(createMockCyclerConfig({
|
|
execution_index: 5,
|
|
next_index: 6
|
|
}))
|
|
|
|
// Execution indices should remain unchanged
|
|
expect(state.executionIndex.value).toBe(2)
|
|
expect(state.nextIndex.value).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('buildConfig', () => {
|
|
it('should build config object from current state', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.currentIndex.value = 3
|
|
state.totalCount.value = 10
|
|
state.modelStrength.value = 0.8
|
|
state.repeatCount.value = 2
|
|
state.repeatUsed.value = 1
|
|
state.isPaused.value = true
|
|
|
|
const config = state.buildConfig()
|
|
|
|
expect(config.current_index).toBe(3)
|
|
expect(config.total_count).toBe(10)
|
|
expect(config.model_strength).toBe(0.8)
|
|
expect(config.repeat_count).toBe(2)
|
|
expect(config.repeat_used).toBe(1)
|
|
expect(config.is_paused).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('setIndex', () => {
|
|
it('should set index within valid range', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 10
|
|
|
|
state.setIndex(5)
|
|
expect(state.currentIndex.value).toBe(5)
|
|
|
|
state.setIndex(1)
|
|
expect(state.currentIndex.value).toBe(1)
|
|
|
|
state.setIndex(10)
|
|
expect(state.currentIndex.value).toBe(10)
|
|
})
|
|
|
|
it('should not set index outside valid range', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 10
|
|
state.currentIndex.value = 5
|
|
|
|
state.setIndex(0)
|
|
expect(state.currentIndex.value).toBe(5) // unchanged
|
|
|
|
state.setIndex(11)
|
|
expect(state.currentIndex.value).toBe(5) // unchanged
|
|
|
|
state.setIndex(-1)
|
|
expect(state.currentIndex.value).toBe(5) // unchanged
|
|
})
|
|
})
|
|
|
|
describe('resetIndex', () => {
|
|
it('should reset index to 1 and clear repeatUsed and displayRepeatUsed', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.currentIndex.value = 5
|
|
state.repeatUsed.value = 2
|
|
state.displayRepeatUsed.value = 2
|
|
state.isPaused.value = true
|
|
|
|
state.resetIndex()
|
|
|
|
expect(state.currentIndex.value).toBe(1)
|
|
expect(state.repeatUsed.value).toBe(0)
|
|
expect(state.displayRepeatUsed.value).toBe(0)
|
|
expect(state.isPaused.value).toBe(true) // isPaused should NOT be reset
|
|
})
|
|
})
|
|
|
|
describe('togglePause', () => {
|
|
it('should toggle pause state', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
expect(state.isPaused.value).toBe(false)
|
|
|
|
state.togglePause()
|
|
expect(state.isPaused.value).toBe(true)
|
|
|
|
state.togglePause()
|
|
expect(state.isPaused.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('generateNextIndex', () => {
|
|
it('should shift indices correctly', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 5
|
|
state.currentIndex.value = 1
|
|
state.nextIndex.value = 2
|
|
|
|
// First call: executionIndex becomes 2 (previous nextIndex), nextIndex becomes 3
|
|
state.generateNextIndex()
|
|
|
|
expect(state.executionIndex.value).toBe(2)
|
|
expect(state.nextIndex.value).toBe(3)
|
|
|
|
// Second call: executionIndex becomes 3, nextIndex becomes 4
|
|
state.generateNextIndex()
|
|
|
|
expect(state.executionIndex.value).toBe(3)
|
|
expect(state.nextIndex.value).toBe(4)
|
|
})
|
|
|
|
it('should wrap index from totalCount to 1', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 5
|
|
state.nextIndex.value = 5 // At the last index
|
|
|
|
state.generateNextIndex()
|
|
|
|
expect(state.executionIndex.value).toBe(5)
|
|
expect(state.nextIndex.value).toBe(1) // Wrapped to 1
|
|
})
|
|
|
|
it('should use currentIndex when nextIndex is null', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 5
|
|
state.currentIndex.value = 3
|
|
state.nextIndex.value = null
|
|
|
|
state.generateNextIndex()
|
|
|
|
// executionIndex becomes previous nextIndex (null)
|
|
expect(state.executionIndex.value).toBeNull()
|
|
// nextIndex is calculated from currentIndex (3) -> 4
|
|
expect(state.nextIndex.value).toBe(4)
|
|
})
|
|
})
|
|
|
|
describe('initializeNextIndex', () => {
|
|
it('should initialize nextIndex to currentIndex + 1 when null', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 5
|
|
state.currentIndex.value = 1
|
|
state.nextIndex.value = null
|
|
|
|
state.initializeNextIndex()
|
|
|
|
expect(state.nextIndex.value).toBe(2)
|
|
})
|
|
|
|
it('should wrap nextIndex when currentIndex is at totalCount', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 5
|
|
state.currentIndex.value = 5
|
|
state.nextIndex.value = null
|
|
|
|
state.initializeNextIndex()
|
|
|
|
expect(state.nextIndex.value).toBe(1) // Wrapped
|
|
})
|
|
|
|
it('should not change nextIndex if already set', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 5
|
|
state.currentIndex.value = 1
|
|
state.nextIndex.value = 4
|
|
|
|
state.initializeNextIndex()
|
|
|
|
expect(state.nextIndex.value).toBe(4) // Unchanged
|
|
})
|
|
})
|
|
|
|
describe('Index Wrapping Edge Cases', () => {
|
|
it('should handle single item pool', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 1
|
|
state.currentIndex.value = 1
|
|
state.nextIndex.value = null
|
|
|
|
state.initializeNextIndex()
|
|
|
|
expect(state.nextIndex.value).toBe(1) // Wraps back to 1
|
|
})
|
|
|
|
it('should handle zero total count gracefully', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.totalCount.value = 0
|
|
state.currentIndex.value = 1
|
|
state.nextIndex.value = null
|
|
|
|
state.initializeNextIndex()
|
|
|
|
// Should still calculate, even if totalCount is 0
|
|
expect(state.nextIndex.value).toBe(2) // No wrapping since totalCount <= 0
|
|
})
|
|
})
|
|
|
|
describe('hashPoolConfig', () => {
|
|
it('should generate consistent hash for same config', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const config1 = createMockPoolConfig()
|
|
const config2 = createMockPoolConfig()
|
|
|
|
const hash1 = state.hashPoolConfig(config1)
|
|
const hash2 = state.hashPoolConfig(config2)
|
|
|
|
expect(hash1).toBe(hash2)
|
|
})
|
|
|
|
it('should generate different hash for different configs', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const config1 = createMockPoolConfig({
|
|
filters: {
|
|
baseModels: ['SD 1.5'],
|
|
tags: { include: [], exclude: [] },
|
|
folders: { include: [], exclude: [] },
|
|
license: { noCreditRequired: false, allowSelling: false }
|
|
}
|
|
})
|
|
|
|
const config2 = createMockPoolConfig({
|
|
filters: {
|
|
baseModels: ['SDXL'],
|
|
tags: { include: [], exclude: [] },
|
|
folders: { include: [], exclude: [] },
|
|
license: { noCreditRequired: false, allowSelling: false }
|
|
}
|
|
})
|
|
|
|
const hash1 = state.hashPoolConfig(config1)
|
|
const hash2 = state.hashPoolConfig(config2)
|
|
|
|
expect(hash1).not.toBe(hash2)
|
|
})
|
|
|
|
it('should return empty string for null config', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
expect(state.hashPoolConfig(null)).toBe('')
|
|
})
|
|
|
|
it('should return empty string for config without filters', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const config = { version: 1, preview: { matchCount: 0, lastUpdated: 0 } } as any
|
|
|
|
expect(state.hashPoolConfig(config)).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('Clip Strength Synchronization', () => {
|
|
it('should sync clipStrength with modelStrength when useCustomClipRange is false', async () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.useCustomClipRange.value = false
|
|
state.modelStrength.value = 0.5
|
|
|
|
// Wait for Vue reactivity
|
|
await vi.waitFor(() => {
|
|
expect(state.clipStrength.value).toBe(0.5)
|
|
})
|
|
})
|
|
|
|
it('should not sync clipStrength when useCustomClipRange is true', async () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.useCustomClipRange.value = true
|
|
state.clipStrength.value = 0.7
|
|
state.modelStrength.value = 0.5
|
|
|
|
// clipStrength should remain unchanged
|
|
await vi.waitFor(() => {
|
|
expect(state.clipStrength.value).toBe(0.7)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Widget Value Synchronization', () => {
|
|
it('should update widget.value when state changes', async () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.currentIndex.value = 3
|
|
state.repeatCount.value = 2
|
|
|
|
// Wait for Vue reactivity
|
|
await vi.waitFor(() => {
|
|
expect(widget.value?.current_index).toBe(3)
|
|
expect(widget.value?.repeat_count).toBe(2)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Repeat Logic State', () => {
|
|
it('should track repeatUsed correctly', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.repeatCount.value = 3
|
|
expect(state.repeatUsed.value).toBe(0)
|
|
|
|
state.repeatUsed.value = 1
|
|
expect(state.repeatUsed.value).toBe(1)
|
|
|
|
state.repeatUsed.value = 3
|
|
expect(state.repeatUsed.value).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('fetchCyclerList', () => {
|
|
it('should call API and return lora list', async () => {
|
|
const mockLoras = [
|
|
{ file_name: 'lora1.safetensors', model_name: 'LoRA 1' },
|
|
{ file_name: 'lora2.safetensors', model_name: 'LoRA 2' }
|
|
]
|
|
|
|
setupFetchMock({ success: true, loras: mockLoras })
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const result = await state.fetchCyclerList(null)
|
|
|
|
expect(result).toEqual(mockLoras)
|
|
expect(state.isLoading.value).toBe(false)
|
|
})
|
|
|
|
it('should include pool config filters in request', async () => {
|
|
const mockFetch = setupFetchMock({ success: true, loras: [] })
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const poolConfig = createMockPoolConfig()
|
|
await state.fetchCyclerList(poolConfig)
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/lm/loras/cycler-list',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: expect.stringContaining('pool_config')
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should set isLoading during fetch', async () => {
|
|
let resolvePromise: (value: unknown) => void
|
|
const pendingPromise = new Promise(resolve => {
|
|
resolvePromise = resolve
|
|
})
|
|
|
|
// Use mockFetch from setup instead of overriding global
|
|
const { mockFetch } = await import('../setup')
|
|
mockFetch.mockReset()
|
|
mockFetch.mockReturnValue(pendingPromise)
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
const fetchPromise = state.fetchCyclerList(null)
|
|
|
|
expect(state.isLoading.value).toBe(true)
|
|
|
|
// Resolve the fetch
|
|
resolvePromise!({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, loras: [] })
|
|
})
|
|
|
|
await fetchPromise
|
|
|
|
expect(state.isLoading.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('refreshList', () => {
|
|
it('should update totalCount from API response', async () => {
|
|
const mockLoras = [
|
|
{ file_name: 'lora1.safetensors', model_name: 'LoRA 1' },
|
|
{ file_name: 'lora2.safetensors', model_name: 'LoRA 2' },
|
|
{ file_name: 'lora3.safetensors', model_name: 'LoRA 3' }
|
|
]
|
|
|
|
// Reset and setup fresh mock
|
|
resetFetchMock()
|
|
setupFetchMock({ success: true, loras: mockLoras })
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
await state.refreshList(null)
|
|
|
|
expect(state.totalCount.value).toBe(3)
|
|
})
|
|
|
|
it('should reset index to 1 when pool config hash changes', async () => {
|
|
resetFetchMock()
|
|
setupFetchMock({ success: true, loras: [{ file_name: 'lora1.safetensors', model_name: 'LoRA 1' }] })
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
// Set initial state
|
|
state.currentIndex.value = 5
|
|
state.poolConfigHash.value = 'old-hash'
|
|
|
|
// Refresh with new config (different hash)
|
|
const newConfig = createMockPoolConfig({
|
|
filters: {
|
|
baseModels: ['SDXL'],
|
|
tags: { include: [], exclude: [] },
|
|
folders: { include: [], exclude: [] },
|
|
license: { noCreditRequired: false, allowSelling: false }
|
|
}
|
|
})
|
|
|
|
await state.refreshList(newConfig)
|
|
|
|
expect(state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should clamp index when totalCount decreases', async () => {
|
|
// Setup mock first, then create state
|
|
resetFetchMock()
|
|
setupFetchMock({
|
|
success: true,
|
|
loras: [
|
|
{ file_name: 'lora1.safetensors', model_name: 'LoRA 1' },
|
|
{ file_name: 'lora2.safetensors', model_name: 'LoRA 2' },
|
|
{ file_name: 'lora3.safetensors', model_name: 'LoRA 3' }
|
|
]
|
|
})
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
// Set initial state with high index
|
|
state.currentIndex.value = 10
|
|
state.totalCount.value = 10
|
|
|
|
await state.refreshList(null)
|
|
|
|
expect(state.totalCount.value).toBe(3)
|
|
expect(state.currentIndex.value).toBe(3) // Clamped to max
|
|
})
|
|
|
|
it('should update currentLoraName and currentLoraFilename', async () => {
|
|
resetFetchMock()
|
|
setupFetchMock({
|
|
success: true,
|
|
loras: [
|
|
{ file_name: 'lora1.safetensors', model_name: 'LoRA 1' },
|
|
{ file_name: 'lora2.safetensors', model_name: 'LoRA 2' }
|
|
]
|
|
})
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
// Set totalCount first so setIndex works, then set index
|
|
state.totalCount.value = 2
|
|
state.currentIndex.value = 2
|
|
|
|
await state.refreshList(null)
|
|
|
|
expect(state.currentLoraFilename.value).toBe('lora2.safetensors')
|
|
})
|
|
|
|
it('should handle empty list gracefully', async () => {
|
|
resetFetchMock()
|
|
setupFetchMock({ success: true, loras: [] })
|
|
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
state.currentIndex.value = 5
|
|
state.totalCount.value = 5
|
|
|
|
await state.refreshList(null)
|
|
|
|
expect(state.totalCount.value).toBe(0)
|
|
// When totalCount is 0, Math.max(1, 0) = 1, but if currentIndex > totalCount it gets clamped to max(1, totalCount)
|
|
// Looking at the actual code: Math.max(1, totalCount) where totalCount=0 gives 1
|
|
expect(state.currentIndex.value).toBe(1)
|
|
expect(state.currentLoraName.value).toBe('')
|
|
expect(state.currentLoraFilename.value).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('isClipStrengthDisabled computed', () => {
|
|
it('should return true when useCustomClipRange is false', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.useCustomClipRange.value = false
|
|
expect(state.isClipStrengthDisabled.value).toBe(true)
|
|
})
|
|
|
|
it('should return false when useCustomClipRange is true', () => {
|
|
const widget = createMockWidget()
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
state.useCustomClipRange.value = true
|
|
expect(state.isClipStrengthDisabled.value).toBe(false)
|
|
})
|
|
})
|
|
})
|