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
911 lines
31 KiB
TypeScript
911 lines
31 KiB
TypeScript
/**
|
|
* Integration tests for batch queue execution scenarios
|
|
*
|
|
* These tests simulate ComfyUI's execution modes to verify correct LoRA cycling behavior.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { useLoraCyclerState } from '@/composables/useLoraCyclerState'
|
|
import type { CyclerConfig } from '@/composables/types'
|
|
import {
|
|
createMockWidget,
|
|
createMockCyclerConfig,
|
|
createMockLoraList,
|
|
createMockPoolConfig
|
|
} from '../fixtures/mockConfigs'
|
|
import { setupFetchMock, resetFetchMock } from '../setup'
|
|
import { BatchQueueSimulator, IndexTracker } from '../utils/BatchQueueSimulator'
|
|
|
|
/**
|
|
* Creates a test harness that mimics the LoraCyclerWidget's behavior
|
|
*/
|
|
function createTestHarness(options: {
|
|
totalCount?: number
|
|
initialIndex?: number
|
|
repeatCount?: number
|
|
isPaused?: boolean
|
|
} = {}) {
|
|
const {
|
|
totalCount = 5,
|
|
initialIndex = 1,
|
|
repeatCount = 1,
|
|
isPaused = false
|
|
} = options
|
|
|
|
const widget = createMockWidget() as any
|
|
const state = useLoraCyclerState(widget)
|
|
|
|
// Initialize state
|
|
state.totalCount.value = totalCount
|
|
state.currentIndex.value = initialIndex
|
|
state.repeatCount.value = repeatCount
|
|
state.isPaused.value = isPaused
|
|
|
|
// Track if first execution
|
|
const HAS_EXECUTED = Symbol('HAS_EXECUTED')
|
|
widget[HAS_EXECUTED] = false
|
|
|
|
// Execution queue for batch synchronization
|
|
interface ExecutionContext {
|
|
isPaused: boolean
|
|
repeatUsed: number
|
|
repeatCount: number
|
|
shouldAdvanceDisplay: boolean
|
|
displayRepeatUsed: number // Value to show in UI after completion
|
|
}
|
|
const executionQueue: ExecutionContext[] = []
|
|
|
|
// beforeQueued hook (mirrors LoraCyclerWidget.vue logic)
|
|
widget.beforeQueued = () => {
|
|
if (state.isPaused.value) {
|
|
executionQueue.push({
|
|
isPaused: true,
|
|
repeatUsed: state.repeatUsed.value,
|
|
repeatCount: state.repeatCount.value,
|
|
shouldAdvanceDisplay: false,
|
|
displayRepeatUsed: state.displayRepeatUsed.value // Keep current display value when paused
|
|
})
|
|
// CRITICAL: Clear execution_index when paused to force backend to use current_index
|
|
const pausedConfig = state.buildConfig()
|
|
pausedConfig.execution_index = null
|
|
widget.value = pausedConfig
|
|
return
|
|
}
|
|
|
|
if (widget[HAS_EXECUTED]) {
|
|
if (state.repeatUsed.value < state.repeatCount.value) {
|
|
state.repeatUsed.value++
|
|
} else {
|
|
state.repeatUsed.value = 1
|
|
state.generateNextIndex()
|
|
}
|
|
} else {
|
|
state.repeatUsed.value = 1
|
|
state.initializeNextIndex()
|
|
widget[HAS_EXECUTED] = true
|
|
}
|
|
|
|
const shouldAdvanceDisplay = state.repeatUsed.value >= state.repeatCount.value
|
|
// Calculate the display value to show after this execution completes
|
|
// When advancing to a new LoRA: reset to 0 (fresh start for new LoRA)
|
|
// When repeating same LoRA: show current repeat step
|
|
const displayRepeatUsed = shouldAdvanceDisplay ? 0 : state.repeatUsed.value
|
|
|
|
executionQueue.push({
|
|
isPaused: false,
|
|
repeatUsed: state.repeatUsed.value,
|
|
repeatCount: state.repeatCount.value,
|
|
shouldAdvanceDisplay,
|
|
displayRepeatUsed
|
|
})
|
|
|
|
widget.value = state.buildConfig()
|
|
}
|
|
|
|
// Mock node with onExecuted
|
|
const node = {
|
|
id: 1,
|
|
onExecuted: (output: any) => {
|
|
const context = executionQueue.shift()
|
|
|
|
const shouldAdvanceDisplay = context
|
|
? context.shouldAdvanceDisplay
|
|
: (!state.isPaused.value && state.repeatUsed.value >= state.repeatCount.value)
|
|
|
|
// Update displayRepeatUsed (deferred like index updates)
|
|
if (context) {
|
|
state.displayRepeatUsed.value = context.displayRepeatUsed
|
|
}
|
|
|
|
if (shouldAdvanceDisplay && output?.next_index !== undefined) {
|
|
const val = Array.isArray(output.next_index) ? output.next_index[0] : output.next_index
|
|
state.currentIndex.value = val
|
|
}
|
|
if (output?.total_count !== undefined) {
|
|
const val = Array.isArray(output.total_count) ? output.total_count[0] : output.total_count
|
|
state.totalCount.value = val
|
|
}
|
|
if (shouldAdvanceDisplay) {
|
|
if (output?.next_lora_name !== undefined) {
|
|
const val = Array.isArray(output.next_lora_name) ? output.next_lora_name[0] : output.next_lora_name
|
|
state.currentLoraName.value = val
|
|
}
|
|
if (output?.next_lora_filename !== undefined) {
|
|
const val = Array.isArray(output.next_lora_filename) ? output.next_lora_filename[0] : output.next_lora_filename
|
|
state.currentLoraFilename.value = val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset execution state (mimics manual index change)
|
|
const resetExecutionState = () => {
|
|
widget[HAS_EXECUTED] = false
|
|
state.executionIndex.value = null
|
|
state.nextIndex.value = null
|
|
executionQueue.length = 0
|
|
}
|
|
|
|
return {
|
|
widget,
|
|
state,
|
|
node,
|
|
executionQueue,
|
|
resetExecutionState,
|
|
getConfig: () => state.buildConfig(),
|
|
HAS_EXECUTED
|
|
}
|
|
}
|
|
|
|
describe('Batch Queue Integration Tests', () => {
|
|
beforeEach(() => {
|
|
resetFetchMock()
|
|
})
|
|
|
|
describe('Basic Cycling', () => {
|
|
it('should cycle through N LoRAs in batch of N (batch queue mode)', async () => {
|
|
const harness = createTestHarness({ totalCount: 3 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
// Simulate batch queue of 3 prompts
|
|
await simulator.runBatchQueue(
|
|
3,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// After cycling through all 3, currentIndex should wrap back to 1
|
|
// First execution: index 1, next becomes 2
|
|
// Second execution: index 2, next becomes 3
|
|
// Third execution: index 3, next becomes 1
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should cycle through N LoRAs in batch of N (sequential mode)', async () => {
|
|
const harness = createTestHarness({ totalCount: 3 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
// Simulate sequential execution of 3 prompts
|
|
await simulator.runSequential(
|
|
3,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Same result as batch mode
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should handle partial cycle (batch of 2 in pool of 5)', async () => {
|
|
const harness = createTestHarness({ totalCount: 5, initialIndex: 1 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
await simulator.runBatchQueue(
|
|
2,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// After 2 executions starting from 1: 1 -> 2 -> 3
|
|
expect(harness.state.currentIndex.value).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('Repeat Functionality', () => {
|
|
it('should repeat each LoRA repeatCount times', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, repeatCount: 2 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
// With repeatCount=2, need 6 executions to cycle through 3 LoRAs
|
|
await simulator.runBatchQueue(
|
|
6,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Should have cycled back to beginning
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should track repeatUsed correctly during batch', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, repeatCount: 3 })
|
|
|
|
// First beforeQueued: repeatUsed = 1
|
|
harness.widget.beforeQueued()
|
|
expect(harness.state.repeatUsed.value).toBe(1)
|
|
|
|
// Second beforeQueued: repeatUsed = 2
|
|
harness.widget.beforeQueued()
|
|
expect(harness.state.repeatUsed.value).toBe(2)
|
|
|
|
// Third beforeQueued: repeatUsed = 3 (will advance on next)
|
|
harness.widget.beforeQueued()
|
|
expect(harness.state.repeatUsed.value).toBe(3)
|
|
|
|
// Fourth beforeQueued: repeatUsed resets to 1, index advances
|
|
harness.widget.beforeQueued()
|
|
expect(harness.state.repeatUsed.value).toBe(1)
|
|
expect(harness.state.nextIndex.value).toBe(3) // Advanced from 2 to 3
|
|
})
|
|
|
|
it('should not advance display until repeat cycle completes', async () => {
|
|
const harness = createTestHarness({ totalCount: 5, repeatCount: 2 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// First execution: repeatUsed=1 < repeatCount=2, shouldAdvanceDisplay=false
|
|
// Second execution: repeatUsed=2 >= repeatCount=2, shouldAdvanceDisplay=true
|
|
|
|
const indexHistory: number[] = []
|
|
|
|
// Override onExecuted to track index changes
|
|
const originalOnExecuted = harness.node.onExecuted
|
|
harness.node.onExecuted = (output: any) => {
|
|
originalOnExecuted(output)
|
|
indexHistory.push(harness.state.currentIndex.value)
|
|
}
|
|
|
|
await simulator.runBatchQueue(
|
|
4,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Index should only change on 2nd and 4th execution
|
|
// Starting at 1: stay 1, advance to 2, stay 2, advance to 3
|
|
expect(indexHistory).toEqual([1, 2, 2, 3])
|
|
})
|
|
|
|
it('should defer displayRepeatUsed updates until workflow completion', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, repeatCount: 3 })
|
|
|
|
// Initial state
|
|
expect(harness.state.displayRepeatUsed.value).toBe(0)
|
|
|
|
// Queue 3 executions in batch mode (all beforeQueued before any onExecuted)
|
|
harness.widget.beforeQueued() // repeatUsed = 1
|
|
harness.widget.beforeQueued() // repeatUsed = 2
|
|
harness.widget.beforeQueued() // repeatUsed = 3
|
|
|
|
// displayRepeatUsed should NOT have changed yet (still 0)
|
|
// because no onExecuted has been called
|
|
expect(harness.state.displayRepeatUsed.value).toBe(0)
|
|
|
|
// Now simulate workflow completions
|
|
harness.node.onExecuted({ next_index: 1 })
|
|
expect(harness.state.displayRepeatUsed.value).toBe(1)
|
|
|
|
harness.node.onExecuted({ next_index: 1 })
|
|
expect(harness.state.displayRepeatUsed.value).toBe(2)
|
|
|
|
harness.node.onExecuted({ next_index: 2 })
|
|
// After completing repeat cycle, displayRepeatUsed resets to 0
|
|
expect(harness.state.displayRepeatUsed.value).toBe(0)
|
|
})
|
|
|
|
it('should reset displayRepeatUsed to 0 when advancing to new LoRA', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, repeatCount: 2 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
const displayHistory: number[] = []
|
|
|
|
const originalOnExecuted = harness.node.onExecuted
|
|
harness.node.onExecuted = (output: any) => {
|
|
originalOnExecuted(output)
|
|
displayHistory.push(harness.state.displayRepeatUsed.value)
|
|
}
|
|
|
|
// Run 4 executions: 2 repeats of LoRA 1, 2 repeats of LoRA 2
|
|
await simulator.runBatchQueue(
|
|
4,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// displayRepeatUsed should show:
|
|
// 1st exec: 1 (first repeat of LoRA 1)
|
|
// 2nd exec: 0 (complete, reset for next LoRA)
|
|
// 3rd exec: 1 (first repeat of LoRA 2)
|
|
// 4th exec: 0 (complete, reset for next LoRA)
|
|
expect(displayHistory).toEqual([1, 0, 1, 0])
|
|
})
|
|
|
|
it('should show current repeat step when not advancing', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, repeatCount: 4 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
const displayHistory: number[] = []
|
|
|
|
const originalOnExecuted = harness.node.onExecuted
|
|
harness.node.onExecuted = (output: any) => {
|
|
originalOnExecuted(output)
|
|
displayHistory.push(harness.state.displayRepeatUsed.value)
|
|
}
|
|
|
|
// Run 4 executions: all 4 repeats of the same LoRA
|
|
await simulator.runBatchQueue(
|
|
4,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// displayRepeatUsed should show:
|
|
// 1st exec: 1 (repeat 1/4, not advancing)
|
|
// 2nd exec: 2 (repeat 2/4, not advancing)
|
|
// 3rd exec: 3 (repeat 3/4, not advancing)
|
|
// 4th exec: 0 (repeat 4/4, complete, reset for next LoRA)
|
|
expect(displayHistory).toEqual([1, 2, 3, 0])
|
|
})
|
|
})
|
|
|
|
describe('Pause Functionality', () => {
|
|
it('should maintain index when paused', async () => {
|
|
const harness = createTestHarness({ totalCount: 5, isPaused: true })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
await simulator.runBatchQueue(
|
|
3,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Index should not advance when paused
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should not count paused executions toward repeat limit', async () => {
|
|
const harness = createTestHarness({ totalCount: 5, repeatCount: 2 })
|
|
|
|
// Run 2 executions while paused
|
|
harness.state.isPaused.value = true
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
|
|
// repeatUsed should still be 0 (paused executions don't count)
|
|
expect(harness.state.repeatUsed.value).toBe(0)
|
|
|
|
// Unpause and run
|
|
harness.state.isPaused.value = false
|
|
harness.widget.beforeQueued()
|
|
expect(harness.state.repeatUsed.value).toBe(1)
|
|
})
|
|
|
|
it('should preserve displayRepeatUsed when paused', async () => {
|
|
const harness = createTestHarness({ totalCount: 5, repeatCount: 3 })
|
|
|
|
// Run one execution to set displayRepeatUsed
|
|
harness.widget.beforeQueued()
|
|
harness.node.onExecuted({ next_index: 1 })
|
|
expect(harness.state.displayRepeatUsed.value).toBe(1)
|
|
|
|
// Pause
|
|
harness.state.isPaused.value = true
|
|
|
|
// Queue and execute while paused
|
|
harness.widget.beforeQueued()
|
|
harness.node.onExecuted({ next_index: 1 })
|
|
|
|
// displayRepeatUsed should remain at 1 (paused executions don't change it)
|
|
expect(harness.state.displayRepeatUsed.value).toBe(1)
|
|
|
|
// Queue another paused execution
|
|
harness.widget.beforeQueued()
|
|
harness.node.onExecuted({ next_index: 1 })
|
|
|
|
// Still should be 1
|
|
expect(harness.state.displayRepeatUsed.value).toBe(1)
|
|
})
|
|
|
|
it('should use same LoRA when pause is toggled mid-batch', async () => {
|
|
// This tests the critical bug scenario:
|
|
// 1. User queues multiple prompts (not paused)
|
|
// 2. All beforeQueued calls complete, each advancing execution_index
|
|
// 3. User clicks pause
|
|
// 4. onExecuted starts firing - paused executions should use current_index, not execution_index
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
|
|
// Queue first prompt (not paused) - this sets up execution_index
|
|
harness.widget.beforeQueued()
|
|
const config1 = harness.getConfig()
|
|
expect(config1.execution_index).toBeNull() // First execution uses current_index
|
|
|
|
// User clicks pause mid-batch
|
|
harness.state.isPaused.value = true
|
|
|
|
// Queue subsequent prompts while paused
|
|
harness.widget.beforeQueued()
|
|
const config2 = harness.getConfig()
|
|
// CRITICAL: execution_index should be null when paused to force backend to use current_index
|
|
expect(config2.execution_index).toBeNull()
|
|
|
|
harness.widget.beforeQueued()
|
|
const config3 = harness.getConfig()
|
|
expect(config3.execution_index).toBeNull()
|
|
|
|
// Verify execution queue has correct context
|
|
expect(harness.executionQueue.length).toBe(3)
|
|
expect(harness.executionQueue[0].isPaused).toBe(false)
|
|
expect(harness.executionQueue[1].isPaused).toBe(true)
|
|
expect(harness.executionQueue[2].isPaused).toBe(true)
|
|
})
|
|
|
|
it('should have null execution_index in widget.value when paused even after non-paused queues', async () => {
|
|
// More detailed test for the execution_index clearing behavior
|
|
// This tests that widget.value (what backend receives) has null execution_index
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
|
|
// Queue 3 prompts while not paused
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
|
|
// Verify execution_index was set by non-paused queues in widget.value
|
|
expect(harness.widget.value.execution_index).not.toBeNull()
|
|
|
|
// User pauses
|
|
harness.state.isPaused.value = true
|
|
|
|
// Queue while paused - should clear execution_index in widget.value
|
|
// This is the value that gets sent to the backend
|
|
harness.widget.beforeQueued()
|
|
expect(harness.widget.value.execution_index).toBeNull()
|
|
|
|
// State's executionIndex may still have the old value (that's fine)
|
|
// What matters is widget.value which is what the backend uses
|
|
})
|
|
|
|
it('should clear server queue when pausing mid-batch', async () => {
|
|
// This tests the fix for the batch queue pause bug:
|
|
// When user presses pause during batch execution, pending queue items should be cleared
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// Mock fetch to track calls to /queue
|
|
const fetchCalls: { url: string; body: any }[] = []
|
|
const originalFetch = global.fetch
|
|
global.fetch = vi.fn().mockImplementation((url: string, options?: RequestInit) => {
|
|
if (url === '/queue') {
|
|
fetchCalls.push({ url, body: options?.body ? JSON.parse(options.body as string) : null })
|
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
|
|
}
|
|
// Call through for other URLs (like cycler-list API)
|
|
return originalFetch(url, options)
|
|
}) as any
|
|
|
|
try {
|
|
// Queue 4 prompts while not paused
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
|
|
// Verify 4 contexts were queued
|
|
expect(harness.executionQueue.length).toBe(4)
|
|
|
|
// Simulate pressing pause (this is what handleTogglePause does in the component)
|
|
const wasPaused = harness.state.isPaused.value
|
|
harness.state.togglePause()
|
|
|
|
// When transitioning to paused, the component should:
|
|
// 1. Reset execution state
|
|
// 2. Clear execution queue
|
|
// 3. Call fetch('/queue', { clear: true })
|
|
if (!wasPaused && harness.state.isPaused.value) {
|
|
// Reset execution state (mimics component behavior)
|
|
harness.resetExecutionState()
|
|
|
|
// Clear server queue (mimics component behavior)
|
|
await fetch('/queue', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ clear: true })
|
|
})
|
|
}
|
|
|
|
// Verify execution queue was cleared
|
|
expect(harness.executionQueue.length).toBe(0)
|
|
|
|
// Verify fetch was called with correct parameters
|
|
expect(fetchCalls.length).toBe(1)
|
|
expect(fetchCalls[0].url).toBe('/queue')
|
|
expect(fetchCalls[0].body).toEqual({ clear: true })
|
|
} finally {
|
|
global.fetch = originalFetch
|
|
}
|
|
})
|
|
|
|
it('should resume cycling after unpause', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, initialIndex: 2 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
// Execute once while not paused
|
|
await simulator.runSingle(
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Pause
|
|
harness.state.isPaused.value = true
|
|
|
|
// Execute twice while paused
|
|
await simulator.runBatchQueue(
|
|
2,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Unpause and execute
|
|
harness.state.isPaused.value = false
|
|
|
|
await simulator.runSingle(
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Should continue from where it left off (index 3 -> 1)
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Manual Index Change', () => {
|
|
it('should reset execution state on manual index change', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
|
|
// Execute a few times
|
|
harness.widget.beforeQueued()
|
|
harness.widget.beforeQueued()
|
|
|
|
expect(harness.widget[harness.HAS_EXECUTED]).toBe(true)
|
|
expect(harness.executionQueue.length).toBe(2)
|
|
|
|
// User manually changes index (mimics handleIndexUpdate)
|
|
harness.resetExecutionState()
|
|
harness.state.setIndex(4)
|
|
|
|
expect(harness.widget[harness.HAS_EXECUTED]).toBe(false)
|
|
expect(harness.state.executionIndex.value).toBeNull()
|
|
expect(harness.state.nextIndex.value).toBeNull()
|
|
expect(harness.executionQueue.length).toBe(0)
|
|
})
|
|
|
|
it('should start fresh cycle from manual index', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// Execute twice starting from 1
|
|
await simulator.runBatchQueue(
|
|
2,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
expect(harness.state.currentIndex.value).toBe(3)
|
|
|
|
// User manually sets index to 1
|
|
harness.resetExecutionState()
|
|
harness.state.setIndex(1)
|
|
|
|
// Execute again - should start fresh from 1
|
|
await simulator.runBatchQueue(
|
|
2,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
expect(harness.state.currentIndex.value).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('Execution Queue Mismatch', () => {
|
|
it('should handle interrupted execution (queue > executed)', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// Queue 5 but only execute 2 (simulates cancel)
|
|
await simulator.runInterrupted(
|
|
5, // queued
|
|
2, // executed
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// 3 contexts remain in queue
|
|
expect(harness.executionQueue.length).toBe(3)
|
|
|
|
// Index should reflect only the 2 executions that completed
|
|
expect(harness.state.currentIndex.value).toBe(3)
|
|
})
|
|
|
|
it('should recover from mismatch on next manual index change', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// Create mismatch
|
|
await simulator.runInterrupted(
|
|
5,
|
|
2,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
expect(harness.executionQueue.length).toBe(3)
|
|
|
|
// Manual index change clears queue
|
|
harness.resetExecutionState()
|
|
harness.state.setIndex(1)
|
|
|
|
expect(harness.executionQueue.length).toBe(0)
|
|
|
|
// Can execute normally again
|
|
await simulator.runSingle(
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
expect(harness.state.currentIndex.value).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle single item pool', async () => {
|
|
const harness = createTestHarness({ totalCount: 1 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 1 })
|
|
|
|
await simulator.runBatchQueue(
|
|
3,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// Should always stay at index 1
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should handle empty pool gracefully', async () => {
|
|
const harness = createTestHarness({ totalCount: 0 })
|
|
|
|
// beforeQueued should still work without errors
|
|
expect(() => harness.widget.beforeQueued()).not.toThrow()
|
|
})
|
|
|
|
it('should handle rapid sequential executions', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// Run 20 sequential executions
|
|
await simulator.runSequential(
|
|
20,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
// 20 % 5 = 0, so should wrap back to 1
|
|
// But first execution uses index 1, so after 20 executions we're at 21 % 5 = 1
|
|
expect(harness.state.currentIndex.value).toBe(1)
|
|
})
|
|
|
|
it('should preserve state consistency across many cycles', async () => {
|
|
const harness = createTestHarness({ totalCount: 3, repeatCount: 2 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
// Run 100 executions in batches
|
|
for (let batch = 0; batch < 10; batch++) {
|
|
await simulator.runBatchQueue(
|
|
10,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
}
|
|
|
|
// Verify state is still valid
|
|
expect(harness.state.currentIndex.value).toBeGreaterThanOrEqual(1)
|
|
expect(harness.state.currentIndex.value).toBeLessThanOrEqual(3)
|
|
expect(harness.state.repeatUsed.value).toBeGreaterThanOrEqual(1)
|
|
expect(harness.state.repeatUsed.value).toBeLessThanOrEqual(2)
|
|
expect(harness.executionQueue.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('Invariant Assertions', () => {
|
|
it('should always have valid index (1 <= currentIndex <= totalCount)', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
const checkInvariant = () => {
|
|
const { currentIndex, totalCount } = harness.state
|
|
if (totalCount.value > 0) {
|
|
expect(currentIndex.value).toBeGreaterThanOrEqual(1)
|
|
expect(currentIndex.value).toBeLessThanOrEqual(totalCount.value)
|
|
}
|
|
}
|
|
|
|
// Override onExecuted to check invariant after each execution
|
|
const originalOnExecuted = harness.node.onExecuted
|
|
harness.node.onExecuted = (output: any) => {
|
|
originalOnExecuted(output)
|
|
checkInvariant()
|
|
}
|
|
|
|
await simulator.runBatchQueue(
|
|
20,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
})
|
|
|
|
it('should always have repeatUsed <= repeatCount', async () => {
|
|
const harness = createTestHarness({ totalCount: 5, repeatCount: 3 })
|
|
|
|
const checkInvariant = () => {
|
|
expect(harness.state.repeatUsed.value).toBeLessThanOrEqual(harness.state.repeatCount.value)
|
|
}
|
|
|
|
// Check after each beforeQueued
|
|
for (let i = 0; i < 20; i++) {
|
|
harness.widget.beforeQueued()
|
|
checkInvariant()
|
|
}
|
|
})
|
|
|
|
it('should consume all execution contexts (queue empty after matching executions)', async () => {
|
|
const harness = createTestHarness({ totalCount: 5 })
|
|
const simulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
await simulator.runBatchQueue(
|
|
7,
|
|
{
|
|
beforeQueued: () => harness.widget.beforeQueued(),
|
|
onExecuted: (output) => harness.node.onExecuted(output)
|
|
},
|
|
() => harness.getConfig()
|
|
)
|
|
|
|
expect(harness.executionQueue.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('Batch vs Sequential Mode Equivalence', () => {
|
|
it('should produce same final state in both modes (basic cycle)', async () => {
|
|
// Create two identical harnesses
|
|
const batchHarness = createTestHarness({ totalCount: 5 })
|
|
const seqHarness = createTestHarness({ totalCount: 5 })
|
|
|
|
const batchSimulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
const seqSimulator = new BatchQueueSimulator({ totalCount: 5 })
|
|
|
|
// Run same number of executions in different modes
|
|
await batchSimulator.runBatchQueue(
|
|
7,
|
|
{
|
|
beforeQueued: () => batchHarness.widget.beforeQueued(),
|
|
onExecuted: (output) => batchHarness.node.onExecuted(output)
|
|
},
|
|
() => batchHarness.getConfig()
|
|
)
|
|
|
|
await seqSimulator.runSequential(
|
|
7,
|
|
{
|
|
beforeQueued: () => seqHarness.widget.beforeQueued(),
|
|
onExecuted: (output) => seqHarness.node.onExecuted(output)
|
|
},
|
|
() => seqHarness.getConfig()
|
|
)
|
|
|
|
// Final state should be identical
|
|
expect(batchHarness.state.currentIndex.value).toBe(seqHarness.state.currentIndex.value)
|
|
expect(batchHarness.state.repeatUsed.value).toBe(seqHarness.state.repeatUsed.value)
|
|
expect(batchHarness.state.displayRepeatUsed.value).toBe(seqHarness.state.displayRepeatUsed.value)
|
|
})
|
|
|
|
it('should produce same final state in both modes (with repeat)', async () => {
|
|
const batchHarness = createTestHarness({ totalCount: 3, repeatCount: 2 })
|
|
const seqHarness = createTestHarness({ totalCount: 3, repeatCount: 2 })
|
|
|
|
const batchSimulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
const seqSimulator = new BatchQueueSimulator({ totalCount: 3 })
|
|
|
|
await batchSimulator.runBatchQueue(
|
|
10,
|
|
{
|
|
beforeQueued: () => batchHarness.widget.beforeQueued(),
|
|
onExecuted: (output) => batchHarness.node.onExecuted(output)
|
|
},
|
|
() => batchHarness.getConfig()
|
|
)
|
|
|
|
await seqSimulator.runSequential(
|
|
10,
|
|
{
|
|
beforeQueued: () => seqHarness.widget.beforeQueued(),
|
|
onExecuted: (output) => seqHarness.node.onExecuted(output)
|
|
},
|
|
() => seqHarness.getConfig()
|
|
)
|
|
|
|
expect(batchHarness.state.currentIndex.value).toBe(seqHarness.state.currentIndex.value)
|
|
expect(batchHarness.state.repeatUsed.value).toBe(seqHarness.state.repeatUsed.value)
|
|
expect(batchHarness.state.displayRepeatUsed.value).toBe(seqHarness.state.displayRepeatUsed.value)
|
|
})
|
|
})
|
|
})
|