diff --git a/knip.config.ts b/knip.config.ts index a77574f97b..8b0e4a8186 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -41,7 +41,9 @@ const config: KnipConfig = { 'src/workbench/extensions/manager/types/generatedManagerTypes.ts', 'packages/registry-types/src/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) - 'src/scripts/ui/components/splitButton.ts' + 'src/scripts/ui/components/splitButton.ts', + // Linear Mode infrastructure - exports will be used by UI components in next PR + 'src/renderer/extensions/linearMode/**/*.ts' ], compilers: { // https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199 diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 13da80accf..603bb904e4 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1846,5 +1846,53 @@ "vueNodesBanner": { "message": "Nodes just got a new look and feel", "tryItOut": "Try it out" + }, + "linearMode": { + "title": "Linear Mode", + "open": "Open Linear Mode", + "close": "Close Linear Mode", + "generate": "Generate", + "generating": "Generating...", + "history": "History", + "noHistory": "No generations yet", + "noHistoryMessage": "Your generated images will appear here", + "loadTemplate": "Load Template", + "loadingTemplate": "Loading template...", + "templateLoadError": "Failed to load template", + "invalidTemplate": "Invalid template ID", + "widgetGroups": { + "content": "Content", + "dimensions": "Image Size", + "generation": "Generation Settings", + "advanced": "Advanced" + }, + "widgets": { + "prompt": "Prompt", + "promptPlaceholder": "Describe the image you want to generate...", + "promptTooltip": "Describe what you want to see in the image", + "negativePrompt": "Negative Prompt", + "negativePromptPlaceholder": "What to avoid in the image...", + "negativePromptTooltip": "Describe what you want to avoid", + "seed": "Seed", + "seedTooltip": "Random seed for generation. Use same seed for reproducible results.", + "steps": "Steps", + "stepsTooltip": "Number of denoising steps. Higher = better quality but slower.", + "cfgScale": "CFG Scale", + "cfgScaleTooltip": "How closely to follow the prompt. Higher = more literal.", + "sampler": "Sampler", + "samplerTooltip": "Sampling algorithm to use", + "scheduler": "Scheduler", + "width": "Width", + "widthTooltip": "Output image width", + "height": "Height", + "heightTooltip": "Output image height", + "batchSize": "Batch Size", + "batchSizeTooltip": "Number of images to generate at once" + }, + "errors": { + "queueFailed": "Failed to queue generation", + "noWorkflow": "No workflow loaded", + "widgetUpdateFailed": "Failed to update widget value" + } } } \ No newline at end of file diff --git a/src/renderer/extensions/linearMode/composables/useLinearModeQueue.ts b/src/renderer/extensions/linearMode/composables/useLinearModeQueue.ts new file mode 100644 index 0000000000..b57c7e5652 --- /dev/null +++ b/src/renderer/extensions/linearMode/composables/useLinearModeQueue.ts @@ -0,0 +1,47 @@ +import { ref } from 'vue' +import { app } from '@/scripts/app' +import { useLinearModeStore } from '../stores/linearModeStore' +import type { NodeExecutionId } from '@/types/nodeIdentification' + +// @knipIgnore - Will be used by Linear Mode UI components +export function useLinearModeQueue() { + const linearModeStore = useLinearModeStore() + const isQueueing = ref(false) + const lastError = ref(null) + + async function queuePrompt( + number: number = -1, + batchCount: number = 1, + queueNodeIds?: NodeExecutionId[] + ): Promise { + isQueueing.value = true + lastError.value = null + + try { + const success = await app.queuePrompt( + number, + batchCount, + queueNodeIds, + (response) => { + if (response.prompt_id) { + linearModeStore.trackGeneratedPrompt(response.prompt_id) + } + } + ) + + return success + } catch (error) { + lastError.value = + error instanceof Error ? error : new Error(String(error)) + return false + } finally { + isQueueing.value = false + } + } + + return { + queuePrompt, + isQueueing, + lastError + } +} diff --git a/src/renderer/extensions/linearMode/linearModeConfig.ts b/src/renderer/extensions/linearMode/linearModeConfig.ts new file mode 100644 index 0000000000..63eb4308b6 --- /dev/null +++ b/src/renderer/extensions/linearMode/linearModeConfig.ts @@ -0,0 +1,154 @@ +import type { LinearModeTemplate, PromotedWidget } from './linearModeTypes' + +const defaultLinearPromotedWidgets: PromotedWidget[] = [ + { + nodeId: 6, + widgetName: 'text', + displayName: 'Prompt', + type: 'text', + config: { + multiline: true, + placeholder: 'Describe the image you want to generate...', + maxLength: 5000 + }, + tooltip: 'Describe what you want to see in the image', + group: 'content' + }, + { + nodeId: 7, + widgetName: 'text', + displayName: 'Negative Prompt', + type: 'text', + config: { + multiline: true, + placeholder: 'What to avoid in the image...' + }, + tooltip: 'Describe what you want to avoid', + group: 'content' + }, + { + nodeId: 3, + widgetName: 'seed', + displayName: 'Seed', + type: 'number', + config: { + min: 0, + max: Number.MAX_SAFE_INTEGER, + randomizable: true + }, + tooltip: + 'Random seed for generation. Use same seed for reproducible results.', + group: 'generation' + }, + { + nodeId: 3, + widgetName: 'steps', + displayName: 'Steps', + type: 'slider', + config: { + min: 1, + max: 150, + step: 1, + default: 20 + }, + tooltip: 'Number of denoising steps. Higher = better quality but slower.', + group: 'generation' + }, + { + nodeId: 3, + widgetName: 'cfg', + displayName: 'CFG Scale', + type: 'slider', + config: { + min: 0, + max: 20, + step: 0.5, + default: 7.0 + }, + tooltip: 'How closely to follow the prompt. Higher = more literal.', + group: 'generation' + }, + { + nodeId: 3, + widgetName: 'sampler_name', + displayName: 'Sampler', + type: 'combo', + config: { + options: ['euler', 'euler_a', 'dpmpp_2m', 'dpmpp_sde', 'ddim'] + }, + tooltip: 'Sampling algorithm to use', + group: 'advanced' + }, + { + nodeId: 3, + widgetName: 'scheduler', + displayName: 'Scheduler', + type: 'combo', + config: { + options: ['normal', 'karras', 'exponential', 'sgm_uniform'] + }, + group: 'advanced' + }, + { + nodeId: 5, + widgetName: 'width', + displayName: 'Width', + type: 'combo', + config: { + options: [512, 768, 1024, 1280, 1536, 2048] + }, + tooltip: 'Output image width', + group: 'dimensions' + }, + { + nodeId: 5, + widgetName: 'height', + displayName: 'Height', + type: 'combo', + config: { + options: [512, 768, 1024, 1280, 1536, 2048] + }, + tooltip: 'Output image height', + group: 'dimensions' + }, + { + nodeId: 5, + widgetName: 'batch_size', + displayName: 'Batch Size', + type: 'slider', + config: { + min: 1, + max: 8, + step: 1, + default: 1 + }, + tooltip: 'Number of images to generate at once', + group: 'advanced' + } +] + +// @knipIgnore - Will be used by Linear Mode UI components +export const LINEAR_MODE_TEMPLATES: Record = { + 'template-default-linear': { + id: 'template-default-linear', + name: 'Linear Mode Template', + templatePath: '/templates/template-default-linear.json', + promotedWidgets: defaultLinearPromotedWidgets, + description: 'Default Linear Mode template for simplified image generation', + tags: ['text-to-image', 'default', 'recommended'] + } +} + +// @knipIgnore - Will be used by Linear Mode UI components +export const WIDGET_GROUPS = { + content: { label: 'Content', order: 1 }, + dimensions: { label: 'Image Size', order: 2 }, + generation: { label: 'Generation Settings', order: 3 }, + advanced: { label: 'Advanced', order: 4, collapsible: true } +} as const + +export function getTemplateConfig( + templateId: string +): LinearModeTemplate | null { + return LINEAR_MODE_TEMPLATES[templateId] ?? null +} diff --git a/src/renderer/extensions/linearMode/linearModeService.ts b/src/renderer/extensions/linearMode/linearModeService.ts new file mode 100644 index 0000000000..1554bf40b9 --- /dev/null +++ b/src/renderer/extensions/linearMode/linearModeService.ts @@ -0,0 +1,109 @@ +import { app } from '@/scripts/app' +import { api } from '@/scripts/api' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useLinearModeStore } from './stores/linearModeStore' +import type { + ComfyNode, + ComfyWorkflowJSON +} from '@/platform/workflow/validation/schemas/workflowSchema' +import type { PromotedWidget } from './linearModeTypes' + +export async function loadTemplate( + templatePath: string +): Promise { + const response = await fetch(api.fileURL(templatePath)) + if (!response.ok) { + throw new Error(`Failed to load template: ${response.statusText}`) + } + return await response.json() +} + +export function getWidgetValue( + workflow: ComfyWorkflowJSON, + nodeId: number, + widgetName: string +): unknown { + const nodeIdStr = String(nodeId) + const node = workflow.nodes?.find( + (n: ComfyNode) => String(n.id) === nodeIdStr + ) + if (!node) return undefined + + if (!node.widgets_values) return undefined + if (Array.isArray(node.widgets_values)) return undefined + + return node.widgets_values[widgetName] +} + +export function setWidgetValue( + workflow: ComfyWorkflowJSON, + nodeId: number, + widgetName: string, + value: unknown +): boolean { + const nodeIdStr = String(nodeId) + const node = workflow.nodes?.find( + (n: ComfyNode) => String(n.id) === nodeIdStr + ) + if (!node) return false + + if (!node.widgets_values) { + node.widgets_values = {} + } + + if (Array.isArray(node.widgets_values)) { + return false + } + + node.widgets_values[widgetName] = value + return true +} + +export function getAllWidgetValues(): Map { + const linearModeStore = useLinearModeStore() + const workflowStore = useWorkflowStore() + + const values = new Map() + const workflow = workflowStore.activeWorkflow?.activeState + + if (!workflow) return values + + for (const widget of linearModeStore.promotedWidgets) { + const value = getWidgetValue(workflow, widget.nodeId, widget.widgetName) + values.set(widget.displayName, value) + } + + return values +} + +export function updateWidgetValue( + widget: PromotedWidget, + value: unknown +): boolean { + const workflowStore = useWorkflowStore() + const workflow = workflowStore.activeWorkflow?.activeState + + if (!workflow) return false + + return setWidgetValue(workflow, widget.nodeId, widget.widgetName, value) +} + +export async function activateTemplate(templateId: string): Promise { + const linearModeStore = useLinearModeStore() + const template = linearModeStore.template + + if (!template || template.id !== templateId) { + throw new Error(`Template not found: ${templateId}`) + } + + const workflow = await loadTemplate(template.templatePath) + + await app.loadGraphData(workflow) +} + +export async function initializeLinearMode(templateId: string): Promise { + const linearModeStore = useLinearModeStore() + + linearModeStore.open(templateId) + await activateTemplate(templateId) +} diff --git a/src/renderer/extensions/linearMode/linearModeTypes.ts b/src/renderer/extensions/linearMode/linearModeTypes.ts new file mode 100644 index 0000000000..ef4cb58e9e --- /dev/null +++ b/src/renderer/extensions/linearMode/linearModeTypes.ts @@ -0,0 +1,50 @@ +// @knipIgnore - Will be used by Linear Mode UI components +export type WidgetType = + | 'text' + | 'number' + | 'slider' + | 'combo' + | 'toggle' + | 'image' + | 'color' + +export interface PromotedWidget { + nodeId: number + widgetName: string + displayName: string + type: WidgetType + config: WidgetConfig + tooltip?: string + group?: string +} + +// @knipIgnore - Will be used by Linear Mode UI components +export interface WidgetConfig { + multiline?: boolean + placeholder?: string + maxLength?: number + min?: number + max?: number + step?: number + default?: number + randomizable?: boolean + options?: string[] | number[] + onLabel?: string + offLabel?: string +} + +export interface LinearModeTemplate { + id: string + name: string + templatePath: string + promotedWidgets: PromotedWidget[] + description?: string + tags?: string[] +} + +export interface OutputImage { + filename: string + subfolder: string + type: string + prompt_id: string +} diff --git a/src/renderer/extensions/linearMode/stores/linearModeStore.ts b/src/renderer/extensions/linearMode/stores/linearModeStore.ts new file mode 100644 index 0000000000..1a7a9bd993 --- /dev/null +++ b/src/renderer/extensions/linearMode/stores/linearModeStore.ts @@ -0,0 +1,100 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { + LinearModeTemplate, + OutputImage, + PromotedWidget +} from '../linearModeTypes' +import { getTemplateConfig } from '../linearModeConfig' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useQueueStore } from '@/stores/queueStore' +import type { TaskItemImpl } from '@/stores/queueStore' + +export const useLinearModeStore = defineStore('linearMode', () => { + const isOpen = ref(false) + + const templateId = ref(null) + + const currentOutput = ref(null) + + const generatedPromptIds = ref>(new Set()) + + const template = computed(() => { + if (!templateId.value) return null + return getTemplateConfig(templateId.value) + }) + + const promotedWidgets = computed(() => { + return template.value?.promotedWidgets ?? [] + }) + + const currentWorkflow = computed(() => { + const workflowStore = useWorkflowStore() + return workflowStore.activeWorkflow?.activeState ?? null + }) + + const filteredHistory = computed(() => { + const queueStore = useQueueStore() + return queueStore.historyTasks.filter((item: TaskItemImpl) => + generatedPromptIds.value.has(item.promptId) + ) + }) + + const isGenerating = computed(() => { + const queueStore = useQueueStore() + return queueStore.pendingTasks.some((item: TaskItemImpl) => + generatedPromptIds.value.has(item.promptId) + ) + }) + + function open(templateIdParam: string): void { + if (!getTemplateConfig(templateIdParam)) { + throw new Error(`Invalid template ID: ${templateIdParam}`) + } + + isOpen.value = true + templateId.value = templateIdParam + } + + function close(): void { + isOpen.value = false + } + + function setOutput(output: OutputImage | null): void { + currentOutput.value = output + } + + function trackGeneratedPrompt(promptId: string): void { + generatedPromptIds.value.add(promptId) + } + + function reset(): void { + isOpen.value = false + templateId.value = null + currentOutput.value = null + generatedPromptIds.value.clear() + } + + return { + // State + isOpen, + templateId, + currentOutput, + generatedPromptIds, + + // Getters + template, + promotedWidgets, + currentWorkflow, + filteredHistory, + isGenerating, + + // Actions + open, + close, + setOutput, + trackGeneratedPrompt, + reset + } +}) diff --git a/src/scripts/app.ts b/src/scripts/app.ts index a0479b5b28..cdbdd79f20 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -34,6 +34,7 @@ import { import type { ExecutionErrorWsMessage, NodeError, + PromptResponse, ResultItem } from '@/schemas/apiSchema' import { @@ -1289,7 +1290,8 @@ export class ComfyApp { async queuePrompt( number: number, batchCount: number = 1, - queueNodeIds?: NodeExecutionId[] + queueNodeIds?: NodeExecutionId[], + onQueued?: (response: PromptResponse) => void ): Promise { this.queueItems.push({ number, batchCount, queueNodeIds }) @@ -1341,6 +1343,9 @@ export class ComfyApp { } } catch (error) {} } + + // Call onQueued callback if provided + onQueued?.(res) } catch (error: unknown) { useDialogService().showErrorDialog(error, { title: t('errorDialog.promptExecutionError'), diff --git a/tests-ui/tests/renderer/extensions/linearMode/linearModeService.test.ts b/tests-ui/tests/renderer/extensions/linearMode/linearModeService.test.ts new file mode 100644 index 0000000000..8c1b07aacd --- /dev/null +++ b/tests-ui/tests/renderer/extensions/linearMode/linearModeService.test.ts @@ -0,0 +1,420 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + activateTemplate, + getAllWidgetValues, + getWidgetValue, + initializeLinearMode, + loadTemplate, + setWidgetValue, + updateWidgetValue +} from '@/renderer/extensions/linearMode/linearModeService' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { PromotedWidget } from '@/renderer/extensions/linearMode/linearModeTypes' + +const mockTemplate = { + id: 'template-default-linear', + name: 'Linear Mode Template', + templatePath: '/templates/template-default-linear.json', + promotedWidgets: [ + { + nodeId: 6, + widgetName: 'text', + displayName: 'Prompt', + type: 'text', + config: { multiline: true }, + group: 'content' + }, + { + nodeId: 3, + widgetName: 'seed', + displayName: 'Seed', + type: 'number', + config: { min: 0 }, + group: 'generation' + } + ] +} + +let mockWorkflow: Partial = { + nodes: [ + { + id: 6, + widgets_values: { text: 'test prompt' } + } as unknown as ComfyWorkflowJSON['nodes'][0], + { + id: 3, + widgets_values: { seed: 12345, steps: 20 } + } as unknown as ComfyWorkflowJSON['nodes'][0], + { + id: 5, + widgets_values: { width: 1024 } + } as unknown as ComfyWorkflowJSON['nodes'][0] + ] +} + +vi.mock('@/scripts/api', () => ({ + api: { + fileURL: vi.fn((path: string) => `http://localhost:8188${path}`) + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { + loadGraphData: vi.fn() + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => ({ + activeWorkflow: { + get activeState() { + return mockWorkflow + } + } + })) +})) + +vi.mock('@/renderer/extensions/linearMode/stores/linearModeStore', () => ({ + useLinearModeStore: vi.fn(() => ({ + template: mockTemplate, + promotedWidgets: mockTemplate.promotedWidgets, + open: vi.fn() + })) +})) + +describe('linearModeService', () => { + beforeEach(async () => { + setActivePinia(createPinia()) + vi.clearAllMocks() + // Reset mockWorkflow for each test + mockWorkflow = { + nodes: [ + { + id: 6, + widgets_values: { text: 'test prompt' } + } as unknown as ComfyWorkflowJSON['nodes'][0], + { + id: 3, + widgets_values: { seed: 12345, steps: 20 } + } as unknown as ComfyWorkflowJSON['nodes'][0], + { + id: 5, + widgets_values: { width: 1024 } + } as unknown as ComfyWorkflowJSON['nodes'][0] + ] + } + + // Reset the mock to use the fresh mockWorkflow + const { useWorkflowStore } = await import( + '@/platform/workflow/management/stores/workflowStore' + ) + vi.mocked(useWorkflowStore).mockReturnValue({ + activeWorkflow: { + get activeState() { + return mockWorkflow + } + } + } as unknown as ReturnType) + }) + + describe('loadTemplate()', () => { + it('should load template from backend', async () => { + const mockTemplateData = { nodes: [{ id: 1 }] } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockTemplateData + }) + + const result = await loadTemplate('/templates/test.json') + + expect(result).toEqual(mockTemplateData) + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8188/templates/test.json' + ) + }) + + it('should throw error when template load fails', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Not Found' + }) + + await expect(loadTemplate('/templates/missing.json')).rejects.toThrow( + 'Failed to load template: Not Found' + ) + }) + }) + + describe('getWidgetValue()', () => { + it('should get widget value from workflow', () => { + const value = getWidgetValue( + mockWorkflow as unknown as ComfyWorkflowJSON, + 6, + 'text' + ) + + expect(value).toBe('test prompt') + }) + + it('should return undefined for non-existent node', () => { + const value = getWidgetValue( + mockWorkflow as unknown as ComfyWorkflowJSON, + 999, + 'text' + ) + + expect(value).toBeUndefined() + }) + + it('should return undefined for non-existent widget', () => { + const value = getWidgetValue( + mockWorkflow as unknown as ComfyWorkflowJSON, + 6, + 'nonexistent' + ) + + expect(value).toBeUndefined() + }) + + it('should handle numeric node IDs', () => { + const value = getWidgetValue( + mockWorkflow as unknown as ComfyWorkflowJSON, + 3, + 'seed' + ) + + expect(value).toBe(12345) + }) + }) + + describe('setWidgetValue()', () => { + it('should set widget value in workflow', () => { + const workflow = JSON.parse( + JSON.stringify(mockWorkflow) + ) as typeof mockWorkflow + + const result = setWidgetValue( + workflow as unknown as ComfyWorkflowJSON, + 6, + 'text', + 'new prompt' + ) + + expect(result).toBe(true) + const node = workflow.nodes?.[0] + if (node?.widgets_values && !Array.isArray(node.widgets_values)) { + expect(node.widgets_values.text).toBe('new prompt') + } + }) + + it('should return false for non-existent node', () => { + const workflow = JSON.parse( + JSON.stringify(mockWorkflow) + ) as typeof mockWorkflow + + const result = setWidgetValue( + workflow as unknown as ComfyWorkflowJSON, + 999, + 'text', + 'value' + ) + + expect(result).toBe(false) + }) + + it('should create widgets_values object if missing', () => { + const workflow: Partial = { + nodes: [{ id: 10 } as unknown as ComfyWorkflowJSON['nodes'][0]] + } + + const result = setWidgetValue( + workflow as unknown as ComfyWorkflowJSON, + 10, + 'newWidget', + 'value' + ) + + expect(result).toBe(true) + const node = workflow.nodes?.[0] + if (node?.widgets_values && !Array.isArray(node.widgets_values)) { + expect(node.widgets_values).toEqual({ newWidget: 'value' }) + } + }) + + it('should handle numeric values', () => { + const workflow = JSON.parse( + JSON.stringify(mockWorkflow) + ) as typeof mockWorkflow + + const result = setWidgetValue( + workflow as unknown as ComfyWorkflowJSON, + 3, + 'seed', + 99999 + ) + + expect(result).toBe(true) + const node = workflow.nodes?.[1] + if (node?.widgets_values && !Array.isArray(node.widgets_values)) { + expect(node.widgets_values.seed).toBe(99999) + } + }) + }) + + describe('getAllWidgetValues()', () => { + it('should return all promoted widget values', () => { + const values = getAllWidgetValues() + + expect(values.size).toBe(2) + expect(values.get('Prompt')).toBe('test prompt') + expect(values.get('Seed')).toBe(12345) + }) + + it('should return empty map when no workflow', async () => { + const { useWorkflowStore } = await import( + '@/platform/workflow/management/stores/workflowStore' + ) + vi.mocked(useWorkflowStore).mockReturnValue({ + activeWorkflow: null + } as unknown as ReturnType) + + const values = getAllWidgetValues() + + expect(values.size).toBe(0) + }) + + it('should handle missing widget values gracefully', async () => { + const workflowMissingValues: Partial = { + nodes: [{ id: 999 } as unknown as ComfyWorkflowJSON['nodes'][0]] + } + const { useWorkflowStore } = await import( + '@/platform/workflow/management/stores/workflowStore' + ) + vi.mocked(useWorkflowStore).mockReturnValue({ + activeWorkflow: { activeState: workflowMissingValues } + } as unknown as ReturnType) + + const values = getAllWidgetValues() + + expect(values.get('Prompt')).toBeUndefined() + expect(values.get('Seed')).toBeUndefined() + }) + }) + + describe('updateWidgetValue()', () => { + it('should update widget value in current workflow', () => { + const widget: PromotedWidget = { + nodeId: 6, + widgetName: 'text', + displayName: 'Prompt', + type: 'text', + config: {} + } + + const result = updateWidgetValue(widget, 'updated prompt') + + expect(result).toBe(true) + const node = mockWorkflow.nodes?.[0] + if (node?.widgets_values && !Array.isArray(node.widgets_values)) { + expect(node.widgets_values.text).toBe('updated prompt') + } + }) + + it('should return false when no workflow loaded', async () => { + const { useWorkflowStore } = await import( + '@/platform/workflow/management/stores/workflowStore' + ) + vi.mocked(useWorkflowStore).mockReturnValue({ + activeWorkflow: null + } as unknown as ReturnType) + + const widget: PromotedWidget = { + nodeId: 6, + widgetName: 'text', + displayName: 'Prompt', + type: 'text', + config: {} + } + + const result = updateWidgetValue(widget, 'new value') + + expect(result).toBe(false) + }) + }) + + describe('activateTemplate()', () => { + it('should load and activate template', async () => { + const mockTemplateData = { nodes: [{ id: 1 }] } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockTemplateData + }) + + const { app } = await import('@/scripts/app') + + await activateTemplate('template-default-linear') + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8188/templates/template-default-linear.json' + ) + expect(app.loadGraphData).toHaveBeenCalledWith(mockTemplateData) + }) + + it('should throw error when template not found in store', async () => { + const { useLinearModeStore } = await import( + '@/renderer/extensions/linearMode/stores/linearModeStore' + ) + vi.mocked(useLinearModeStore).mockReturnValue({ + template: null, + promotedWidgets: [], + open: vi.fn() + } as unknown as ReturnType) + + await expect(activateTemplate('template-default-linear')).rejects.toThrow( + 'Template not found: template-default-linear' + ) + }) + + it('should throw error when template ID mismatch', async () => { + const { useLinearModeStore } = await import( + '@/renderer/extensions/linearMode/stores/linearModeStore' + ) + vi.mocked(useLinearModeStore).mockReturnValue({ + template: { ...mockTemplate, id: 'different-template' }, + promotedWidgets: [], + open: vi.fn() + } as unknown as ReturnType) + + await expect(activateTemplate('template-default-linear')).rejects.toThrow( + 'Template not found: template-default-linear' + ) + }) + }) + + describe('initializeLinearMode()', () => { + it('should open Linear Mode and activate template', async () => { + const mockTemplateData = { nodes: [{ id: 1 }] } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockTemplateData + }) + + const { useLinearModeStore } = await import( + '@/renderer/extensions/linearMode/stores/linearModeStore' + ) + const { app } = await import('@/scripts/app') + const mockOpen = vi.fn() + vi.mocked(useLinearModeStore).mockReturnValue({ + template: mockTemplate, + promotedWidgets: mockTemplate.promotedWidgets, + open: mockOpen + } as unknown as ReturnType) + + await initializeLinearMode('template-default-linear') + + expect(mockOpen).toHaveBeenCalledWith('template-default-linear') + expect(app.loadGraphData).toHaveBeenCalledWith(mockTemplateData) + }) + }) +}) diff --git a/tests-ui/tests/renderer/extensions/linearMode/linearModeStore.test.ts b/tests-ui/tests/renderer/extensions/linearMode/linearModeStore.test.ts new file mode 100644 index 0000000000..62ece8d5b5 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/linearMode/linearModeStore.test.ts @@ -0,0 +1,311 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useLinearModeStore } from '@/renderer/extensions/linearMode/stores/linearModeStore' +import type { OutputImage } from '@/renderer/extensions/linearMode/linearModeTypes' + +vi.mock('@/renderer/extensions/linearMode/linearModeConfig', () => ({ + getTemplateConfig: vi.fn((id: string) => { + if (id === 'template-default-linear') { + return { + id: 'template-default-linear', + name: 'Linear Mode Template', + templatePath: '/templates/template-default-linear.json', + promotedWidgets: [ + { + nodeId: 6, + widgetName: 'text', + displayName: 'Prompt', + type: 'text', + config: { multiline: true }, + group: 'content' + }, + { + nodeId: 3, + widgetName: 'seed', + displayName: 'Seed', + type: 'number', + config: { min: 0 }, + group: 'generation' + } + ], + description: 'Default template', + tags: ['text-to-image'] + } + } + return null + }) +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: { + activeState: { + nodes: [ + { id: 6, widgets_values: { text: 'test prompt' } }, + { id: 3, widgets_values: { seed: 12345 } } + ] + } + } + }) +})) + +interface MockTaskItem { + promptId: string + status?: string +} + +vi.mock('@/stores/queueStore', () => { + return { + useQueueStore: () => ({ + pendingTasks: [ + { promptId: 'prompt-1' }, + { promptId: 'prompt-2' } + ] as MockTaskItem[], + historyTasks: [ + { promptId: 'prompt-1', status: 'completed' }, + { promptId: 'prompt-2', status: 'completed' }, + { promptId: 'prompt-3', status: 'completed' } + ] as MockTaskItem[] + }), + TaskItemImpl: class {} + } +}) + +describe('useLinearModeStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('initial state', () => { + it('should have correct default state', () => { + const store = useLinearModeStore() + + expect(store.isOpen).toBe(false) + expect(store.templateId).toBe(null) + expect(store.currentOutput).toBe(null) + expect(store.generatedPromptIds).toBeInstanceOf(Set) + expect(store.generatedPromptIds.size).toBe(0) + }) + }) + + describe('open()', () => { + it('should open Linear Mode with valid template', () => { + const store = useLinearModeStore() + + store.open('template-default-linear') + + expect(store.isOpen).toBe(true) + expect(store.templateId).toBe('template-default-linear') + }) + + it('should throw error for invalid template ID', () => { + const store = useLinearModeStore() + + expect(() => store.open('invalid-template')).toThrow( + 'Invalid template ID: invalid-template' + ) + }) + + it('should not affect other state when opening', () => { + const store = useLinearModeStore() + store.trackGeneratedPrompt('test-prompt-1') + + store.open('template-default-linear') + + expect(store.generatedPromptIds.has('test-prompt-1')).toBe(true) + }) + }) + + describe('close()', () => { + it('should close Linear Mode', () => { + const store = useLinearModeStore() + store.open('template-default-linear') + + store.close() + + expect(store.isOpen).toBe(false) + }) + + it('should preserve state when closing', () => { + const store = useLinearModeStore() + store.open('template-default-linear') + store.trackGeneratedPrompt('test-prompt') + + store.close() + + expect(store.templateId).toBe('template-default-linear') + expect(store.generatedPromptIds.has('test-prompt')).toBe(true) + }) + }) + + describe('setOutput()', () => { + it('should set current output', () => { + const store = useLinearModeStore() + const output: OutputImage = { + filename: 'test.png', + subfolder: 'output', + type: 'output', + prompt_id: 'prompt-123' + } + + store.setOutput(output) + + expect(store.currentOutput).toEqual(output) + }) + + it('should clear current output when null', () => { + const store = useLinearModeStore() + const output: OutputImage = { + filename: 'test.png', + subfolder: 'output', + type: 'output', + prompt_id: 'prompt-123' + } + store.setOutput(output) + + store.setOutput(null) + + expect(store.currentOutput).toBe(null) + }) + }) + + describe('trackGeneratedPrompt()', () => { + it('should add prompt ID to set', () => { + const store = useLinearModeStore() + + store.trackGeneratedPrompt('prompt-1') + + expect(store.generatedPromptIds.has('prompt-1')).toBe(true) + expect(store.generatedPromptIds.size).toBe(1) + }) + + it('should handle multiple prompt IDs', () => { + const store = useLinearModeStore() + + store.trackGeneratedPrompt('prompt-1') + store.trackGeneratedPrompt('prompt-2') + store.trackGeneratedPrompt('prompt-3') + + expect(store.generatedPromptIds.size).toBe(3) + expect(store.generatedPromptIds.has('prompt-1')).toBe(true) + expect(store.generatedPromptIds.has('prompt-2')).toBe(true) + expect(store.generatedPromptIds.has('prompt-3')).toBe(true) + }) + + it('should not duplicate prompt IDs', () => { + const store = useLinearModeStore() + + store.trackGeneratedPrompt('prompt-1') + store.trackGeneratedPrompt('prompt-1') + + expect(store.generatedPromptIds.size).toBe(1) + }) + }) + + describe('reset()', () => { + it('should reset all state', () => { + const store = useLinearModeStore() + store.open('template-default-linear') + store.trackGeneratedPrompt('prompt-1') + store.setOutput({ + filename: 'test.png', + subfolder: 'output', + type: 'output', + prompt_id: 'prompt-1' + }) + + store.reset() + + expect(store.isOpen).toBe(false) + expect(store.templateId).toBe(null) + expect(store.currentOutput).toBe(null) + expect(store.generatedPromptIds.size).toBe(0) + }) + }) + + describe('template getter', () => { + it('should return null when no template is selected', () => { + const store = useLinearModeStore() + + expect(store.template).toBe(null) + }) + + it('should return template config when template is selected', () => { + const store = useLinearModeStore() + store.open('template-default-linear') + + expect(store.template).not.toBe(null) + expect(store.template?.id).toBe('template-default-linear') + expect(store.template?.name).toBe('Linear Mode Template') + }) + }) + + describe('promotedWidgets getter', () => { + it('should return empty array when no template selected', () => { + const store = useLinearModeStore() + + expect(store.promotedWidgets).toEqual([]) + }) + + it('should return promoted widgets from template', () => { + const store = useLinearModeStore() + store.open('template-default-linear') + + expect(store.promotedWidgets.length).toBe(2) + expect(store.promotedWidgets[0].displayName).toBe('Prompt') + expect(store.promotedWidgets[1].displayName).toBe('Seed') + }) + }) + + describe('currentWorkflow getter', () => { + it('should return workflow from workflowStore', () => { + const store = useLinearModeStore() + + expect(store.currentWorkflow).not.toBe(null) + expect(store.currentWorkflow?.nodes).toBeDefined() + }) + }) + + describe('filteredHistory getter', () => { + it('should return empty array when no prompts tracked', () => { + const store = useLinearModeStore() + + expect(store.filteredHistory).toEqual([]) + }) + + it('should filter history by tracked prompt IDs', () => { + const store = useLinearModeStore() + store.trackGeneratedPrompt('prompt-1') + store.trackGeneratedPrompt('prompt-3') + + const filtered = store.filteredHistory as unknown as MockTaskItem[] + + expect(filtered.length).toBe(2) + expect(filtered.some((item) => item.promptId === 'prompt-1')).toBe(true) + expect(filtered.some((item) => item.promptId === 'prompt-3')).toBe(true) + expect(filtered.some((item) => item.promptId === 'prompt-2')).toBe(false) + }) + }) + + describe('isGenerating getter', () => { + it('should return false when no prompts are tracked', () => { + const store = useLinearModeStore() + + expect(store.isGenerating).toBe(false) + }) + + it('should return true when tracked prompt is in queue', () => { + const store = useLinearModeStore() + store.trackGeneratedPrompt('prompt-1') + + expect(store.isGenerating).toBe(true) + }) + + it('should return false when tracked prompt is not in queue', () => { + const store = useLinearModeStore() + store.trackGeneratedPrompt('prompt-999') + + expect(store.isGenerating).toBe(false) + }) + }) +})