mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
fix: preserve viewport during undo by always calling store() in deactivate
- deactivate() now skips captureCanvasState() during undo/redo but always calls store() to snapshot viewport/outputs, preventing viewport drift on undo (regression from PR #10247) - Log inactive tracker warnings unconditionally at warn level - Separate import type for ComfyWorkflow - Update docs: add appModeStore.ts call site, checkState() deprecation note, fix stale DEV-error references, update deactivate() table row - Rename checkState → captureCanvasState in useWidgetSelectActions - Fix WidgetSelectDropdown.test.ts rebase conflict resolution - Add E2E test: undo preserves viewport offset
This commit is contained in:
@@ -273,4 +273,42 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
})
|
||||
|
||||
test('Undo preserves viewport offset', async ({ comfyPage }) => {
|
||||
// Pan to a distinct offset so we can detect drift
|
||||
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
|
||||
|
||||
const viewportBefore = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
// Make a graph change so we have something to undo
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
await node.click('title')
|
||||
await node.click('collapse')
|
||||
await expect(node).toBeCollapsed()
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
|
||||
// Undo the collapse — viewport should be preserved
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(viewportBefore.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(viewportBefore.offset[0], 0),
|
||||
expect.closeTo(viewportBefore.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ history by comparing serialized graph snapshots.
|
||||
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
|
||||
|
||||
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
|
||||
workflow's tracker. Calling it on an inactive tracker logs an error in DEV and
|
||||
workflow's tracker. Calling it on an inactive tracker logs a warning and
|
||||
returns early, preventing cross-workflow data corruption.
|
||||
|
||||
## Automatic Triggers
|
||||
@@ -71,20 +71,25 @@ These locations call `captureCanvasState()` directly:
|
||||
- `useSubgraphOperations.ts` — After subgraph enter/exit
|
||||
- `useCanvasRefresh.ts` — After canvas refresh
|
||||
- `useCoreCommands.ts` — After metadata/subgraph commands
|
||||
- `appModeStore.ts` — After app mode transitions
|
||||
|
||||
`workflowService.ts` calls `captureCanvasState()` indirectly via
|
||||
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
|
||||
|
||||
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
|
||||
> for extension compatibility. Extension authors should migrate to
|
||||
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
|
||||
|
||||
## Lifecycle Methods
|
||||
|
||||
| Method | Caller | Purpose |
|
||||
| ---------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
|
||||
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` + `store()`. Freezes everything for tab switch. Must be called while this workflow is still active. |
|
||||
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
|
||||
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
|
||||
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
|
||||
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
|
||||
| Method | Caller | Purpose |
|
||||
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
|
||||
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
|
||||
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
|
||||
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
|
||||
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
|
||||
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
|
||||
|
||||
## Transaction Guards
|
||||
|
||||
@@ -102,7 +107,7 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
## Key Invariants
|
||||
|
||||
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
|
||||
inactive trackers get an early return (and a DEV error log)
|
||||
inactive trackers get an early return (and a warning log)
|
||||
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
|
||||
`isLoadingGraph`) to prevent cross-workflow corruption
|
||||
- `captureCanvasState()` is a no-op during undo/redo (guarded by
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { computed } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
@@ -27,7 +24,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
captureCanvasState: mockCaptureCanvasState
|
||||
checkState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -55,7 +52,7 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
const { mockMediaAssets } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
@@ -68,8 +65,7 @@ const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
},
|
||||
mockResolveOutputAssetItems: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -78,732 +74,187 @@ vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: (...args: unknown[]) =>
|
||||
mockResolveOutputAssetItems(...args)
|
||||
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
const mockUpdateSelectedItems = vi.hoisted(() => vi.fn())
|
||||
const mockHandleFilesUpdate = vi.hoisted(() => vi.fn())
|
||||
|
||||
const { mockItemsRef, mockSelectedSetRef, mockFilterSelectedRef } = vi.hoisted(
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
mockItemsRef: ref([]) as Ref<FormDropdownItem[]>,
|
||||
mockSelectedSetRef: ref(new Set()) as Ref<Set<string>>,
|
||||
mockFilterSelectedRef: ref('all') as Ref<string>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems',
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { computed } = require('vue')
|
||||
return {
|
||||
useWidgetSelectItems: () => ({
|
||||
dropdownItems: computed(() => mockItemsRef.value),
|
||||
displayItems: computed(() => mockItemsRef.value),
|
||||
filterSelected: mockFilterSelectedRef,
|
||||
filterOptions: computed(() => [
|
||||
{ name: 'All', value: 'all' },
|
||||
{ name: 'Inputs', value: 'inputs' }
|
||||
]),
|
||||
ownershipSelected: ref('all'),
|
||||
showOwnershipFilter: computed(() => false),
|
||||
ownershipOptions: computed(() => []),
|
||||
baseModelSelected: ref(new Set<string>()),
|
||||
showBaseModelFilter: computed(() => false),
|
||||
baseModelOptions: computed(() => []),
|
||||
selectedSet: computed(() => mockSelectedSetRef.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions',
|
||||
() => ({
|
||||
useWidgetSelectActions: () => ({
|
||||
updateSelectedItems: mockUpdateSelectedItems,
|
||||
handleFilesUpdate: mockHandleFilesUpdate
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
|
||||
inputItems: FormDropdownItem[]
|
||||
outputItems: FormDropdownItem[]
|
||||
dropdownItems: FormDropdownItem[]
|
||||
filterSelected: string
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
}
|
||||
|
||||
describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
const createSelectDropdownWidget = (
|
||||
value: string = 'img_001.png',
|
||||
options: {
|
||||
values?: string[]
|
||||
getOptionLabel?: (value?: string | null) => string
|
||||
} = {},
|
||||
spec?: ComboInputSpec
|
||||
) =>
|
||||
createMockWidget<string | undefined>({
|
||||
value,
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined,
|
||||
assetKind: 'image' | 'video' | 'audio' = 'image'
|
||||
): VueWrapper<WidgetSelectDropdownInstance> => {
|
||||
return fromAny<VueWrapper<WidgetSelectDropdownInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind,
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('when custom labels are not provided', () => {
|
||||
it('uses values as labels when no mapping provided', () => {
|
||||
const widget = createSelectDropdownWidget('img_001.png')
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(3)
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when custom labels are provided via getOptionLabel', () => {
|
||||
it('displays custom labels while preserving original values', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
const mapping: Record<string, string> = {
|
||||
'img_001.png': 'Vacation Photo',
|
||||
'photo_abc.jpg': 'Family Portrait',
|
||||
'hash789.png': 'Sunset Beach'
|
||||
}
|
||||
return mapping[value] || value
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(3)
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Vacation Photo')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('Family Portrait')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Sunset Beach')
|
||||
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('img_001.png')
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg')
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
|
||||
})
|
||||
|
||||
it('emits original values when items with custom labels are selected', async () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
return `Custom: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
// Simulate selecting an item
|
||||
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
|
||||
wrapper.vm.updateSelectedItems(selectedSet)
|
||||
|
||||
// Should emit the original value, not the custom label
|
||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
|
||||
'photo_abc.jpg'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping fails', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'photo_abc.jpg') {
|
||||
throw new Error('Mapping failed')
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Labeled: hash789.png')
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns empty string', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'photo_abc.jpg') {
|
||||
return ''
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Labeled: hash789.png')
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns undefined', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'hash789.png') {
|
||||
return fromAny<string, unknown>(undefined)
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('output items with custom label mapping', () => {
|
||||
it('applies custom label mapping to output items from queue history', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
return `Output: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const outputItems = wrapper.vm.outputItems
|
||||
expect(outputItems).toBeDefined()
|
||||
expect(Array.isArray(outputItems)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing value handling for template-loaded nodes', () => {
|
||||
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
|
||||
const widget = createSelectDropdownWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(2)
|
||||
expect(
|
||||
inputItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(false)
|
||||
|
||||
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(
|
||||
dropdownItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(true)
|
||||
expect(dropdownItems[0].name).toBe('template_image.png')
|
||||
expect(dropdownItems[0].id).toBe('missing-template_image.png')
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "inputs"', async () => {
|
||||
const widget = createSelectDropdownWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
wrapper.vm.filterSelected = 'inputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "outputs"', async () => {
|
||||
const widget = createSelectDropdownWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
wrapper.vm.filterSelected = 'outputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue exists in available items', () => {
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue is undefined', () => {
|
||||
const widget = createSelectDropdownWidget(
|
||||
fromAny<string, unknown>(undefined),
|
||||
{
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, undefined)
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
interface CloudModeInstance extends ComponentPublicInstance {
|
||||
dropdownItems: FormDropdownItem[]
|
||||
displayItems: FormDropdownItem[]
|
||||
selectedSet: Set<string>
|
||||
}
|
||||
|
||||
const createTestAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
preview_url: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
preview_url,
|
||||
tags: []
|
||||
})
|
||||
|
||||
const createCloudModeWidget = (
|
||||
value: string = 'model.safetensors'
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_model_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: [],
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
}
|
||||
})
|
||||
|
||||
const mountCloudComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<CloudModeInstance> => {
|
||||
return fromAny<VueWrapper<CloudModeInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'model',
|
||||
isAssetMode: true,
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAssetsData.items = []
|
||||
})
|
||||
|
||||
it('does not include missing items in cloud asset mode dropdown', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'existing_model.safetensors',
|
||||
'https://example.com/preview.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const widget = createCloudModeWidget('missing_model.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(1)
|
||||
expect(dropdownItems[0].name).toBe('existing_model.safetensors')
|
||||
expect(
|
||||
dropdownItems.some((item) => item.name === 'missing_model.safetensors')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows only available cloud assets in dropdown', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'model_a.safetensors',
|
||||
'https://example.com/a.jpg'
|
||||
),
|
||||
createTestAsset(
|
||||
'asset-2',
|
||||
'model_b.safetensors',
|
||||
'https://example.com/b.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const widget = createCloudModeWidget('model_a.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'model_a.safetensors')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(dropdownItems.map((item) => item.name)).toEqual([
|
||||
'model_a.safetensors',
|
||||
'model_b.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty dropdown when no cloud assets available', () => {
|
||||
mockAssetsData.items = []
|
||||
|
||||
const widget = createCloudModeWidget('missing_model.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes missing cloud asset in displayItems for input field visibility', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'existing_model.safetensors',
|
||||
'https://example.com/preview.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const widget = createCloudModeWidget('missing_model.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
|
||||
|
||||
const displayItems = wrapper.vm.displayItems
|
||||
expect(displayItems).toHaveLength(2)
|
||||
expect(displayItems[0].name).toBe('missing_model.safetensors')
|
||||
expect(displayItems[0].id).toBe('missing-missing_model.safetensors')
|
||||
expect(displayItems[1].name).toBe('existing_model.safetensors')
|
||||
|
||||
const selectedSet = wrapper.vm.selectedSet
|
||||
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown multi-output jobs', () => {
|
||||
interface MultiOutputInstance extends ComponentPublicInstance {
|
||||
outputItems: FormDropdownItem[]
|
||||
}
|
||||
|
||||
function makeMultiOutputAsset(
|
||||
jobId: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
outputCount: number
|
||||
) {
|
||||
return {
|
||||
id: jobId,
|
||||
name,
|
||||
preview_url: `/api/view?filename=${name}&type=output`,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId,
|
||||
subfolder: '',
|
||||
outputCount,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountMultiOutput(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<MultiOutputInstance> {
|
||||
return fromAny<VueWrapper<MultiOutputInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const defaultWidget = () =>
|
||||
createMockWidget<string | undefined>({
|
||||
value: 'output_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown', () => {
|
||||
beforeEach(() => {
|
||||
mockMediaAssets.media.value = []
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
mockCheckState.mockClear()
|
||||
mockAssetsData.items = []
|
||||
mockItemsRef.value = []
|
||||
mockSelectedSetRef.value = new Set()
|
||||
mockFilterSelectedRef.value = 'all'
|
||||
mockUpdateSelectedItems.mockClear()
|
||||
mockHandleFilesUpdate.mockClear()
|
||||
})
|
||||
|
||||
it('shows all outputs after resolving multi-output jobs', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1-5-output_001.png',
|
||||
name: 'output_001.png',
|
||||
preview_url: '/api/view?filename=output_001.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_002.png',
|
||||
name: 'output_002.png',
|
||||
preview_url: '/api/view?filename=output_002.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_003.png',
|
||||
name: 'output_003.png',
|
||||
preview_url: '/api/view?filename=output_003.png&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
|
||||
'output_001.png [output]',
|
||||
'output_002.png [output]',
|
||||
'output_003.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows preview output when job has only one output', () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
|
||||
]
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'single.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
const wrapper = mountMultiOutput(widget, 'single.png')
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves two multi-output jobs independently', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
|
||||
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
|
||||
if (meta.jobId === 'job-A') {
|
||||
return [
|
||||
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toContain('a1.png [output]')
|
||||
expect(names).toContain('a2.png [output]')
|
||||
expect(names).toContain('b1.png [output]')
|
||||
expect(names).toContain('b2.png [output]')
|
||||
})
|
||||
|
||||
it('resolves outputs when allOutputs already contains all items', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
name: 'preview.png',
|
||||
preview_url: '/api/view?filename=preview.png&type=output',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-complete',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 2,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'out1.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'out2.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-complete' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
|
||||
})
|
||||
|
||||
it('falls back to preview when resolver rejects', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
|
||||
]
|
||||
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve multi-output job',
|
||||
'job-fail',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
handleFilesUpdate: (files: File[]) => Promise<void>
|
||||
}
|
||||
|
||||
const mountForUndo = (
|
||||
function renderComponent(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<UndoTrackingInstance> => {
|
||||
return fromAny<VueWrapper<UndoTrackingInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
modelValue: string | undefined,
|
||||
extraProps: Record<string, unknown> = {}
|
||||
) {
|
||||
return render(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input',
|
||||
...extraProps
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCaptureCanvasState.mockClear()
|
||||
})
|
||||
|
||||
it('calls captureCanvasState after dropdown selection changes modelValue', () => {
|
||||
it('renders the dropdown component', () => {
|
||||
mockItemsRef.value = [
|
||||
{ id: 'input-0', name: 'img_001.png' },
|
||||
{ id: 'input-1', name: 'photo_abc.jpg' }
|
||||
]
|
||||
mockSelectedSetRef.value = new Set(['input-0'])
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png', 'photo_abc.jpg'] }
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
}
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
wrapper.vm.updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
renderComponent(widget, 'img_001.png')
|
||||
expect(screen.getByText('img_001.png')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls captureCanvasState after file upload completes', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
} as Response)
|
||||
|
||||
it('renders in cloud asset mode', () => {
|
||||
mockAssetsData.items = [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'model_a.safetensors',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
mockItemsRef.value = [{ id: 'asset-1', name: 'model_a.safetensors' }]
|
||||
mockSelectedSetRef.value = new Set(['asset-1'])
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
value: 'model_a.safetensors',
|
||||
name: 'test_model',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png'] }
|
||||
options: {
|
||||
values: [],
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
}
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
renderComponent(widget, 'model_a.safetensors', {
|
||||
assetKind: 'model',
|
||||
isAssetMode: true,
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
})
|
||||
expect(screen.getByText('model_a.safetensors')).toBeDefined()
|
||||
})
|
||||
|
||||
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
|
||||
await wrapper.vm.handleFilesUpdate([file])
|
||||
describe('composable wiring', () => {
|
||||
const items: FormDropdownItem[] = [
|
||||
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
|
||||
{ id: 'input-1', name: 'dog.png', label: 'dog.png' }
|
||||
]
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
function renderDefault() {
|
||||
mockItemsRef.value = items
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'cat.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['cat.png', 'dog.png'] }
|
||||
})
|
||||
return renderComponent(widget, 'cat.png')
|
||||
}
|
||||
|
||||
it('displays the item whose id is in selectedSet', async () => {
|
||||
mockSelectedSetRef.value = new Set(['input-1'])
|
||||
renderDefault()
|
||||
|
||||
expect(screen.getByText('dog.png')).toBeDefined()
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows placeholder when selectedSet is empty', () => {
|
||||
mockSelectedSetRef.value = new Set()
|
||||
renderDefault()
|
||||
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
expect(screen.queryByText('dog.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates displayed selection when selectedSet changes', async () => {
|
||||
mockSelectedSetRef.value = new Set(['input-0'])
|
||||
renderDefault()
|
||||
expect(screen.getByText('cat.png')).toBeDefined()
|
||||
|
||||
mockSelectedSetRef.value = new Set(['input-1'])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByText('dog.png')).toBeDefined()
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
checkState: mockCheckState
|
||||
captureCanvasState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,8 +23,8 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
function checkWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
function captureWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
@@ -36,7 +36,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
: dropdownItems.value.find((item) => item.id === id)?.name
|
||||
|
||||
modelValue.value = name
|
||||
checkWorkflowState()
|
||||
captureWorkflowState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
@@ -109,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
checkWorkflowState()
|
||||
captureWorkflowState()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -244,14 +244,14 @@ describe('ChangeTracker', () => {
|
||||
expect(mockSubgraphNavigationStore.exportState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op during undo/redo to avoid overwriting viewport', () => {
|
||||
it('skips captureCanvasState but still calls store during undo/redo', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
tracker._restoringState = true
|
||||
|
||||
tracker.deactivate()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
expect(mockNodeOutputStore.snapshotOutputs).not.toHaveBeenCalled()
|
||||
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a full no-op when called on inactive tracker', () => {
|
||||
|
||||
@@ -4,10 +4,8 @@ import log from 'loglevel'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -97,28 +95,25 @@ export class ChangeTracker {
|
||||
|
||||
/**
|
||||
* Freeze this tracker's state before the workflow goes inactive.
|
||||
* Captures the final canvas state + viewport/outputs.
|
||||
* Called exactly once when switching away from this workflow.
|
||||
* Always calls store() to preserve viewport/outputs. Calls
|
||||
* captureCanvasState() only when not in undo/redo (to avoid
|
||||
* corrupting undo history with intermediate graph state).
|
||||
*
|
||||
* PRECONDITION: must be called while this workflow is still the active one
|
||||
* (before the activeWorkflow pointer is moved). If called after the pointer
|
||||
* has already moved, this is a no-op to avoid freezing wrong viewport data.
|
||||
* Also skipped during undo/redo to avoid overwriting viewport with
|
||||
* intermediate canvas state.
|
||||
*
|
||||
* @internal Not part of the public extension API.
|
||||
*/
|
||||
deactivate() {
|
||||
if (!isActiveTracker(this) || this._restoringState) {
|
||||
if (import.meta.env.DEV && !this._restoringState) {
|
||||
logger.error(
|
||||
'deactivate() called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
}
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'deactivate() called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
return
|
||||
}
|
||||
this.captureCanvasState()
|
||||
if (!this._restoringState) this.captureCanvasState()
|
||||
this.store()
|
||||
}
|
||||
|
||||
@@ -195,12 +190,10 @@ export class ChangeTracker {
|
||||
return
|
||||
|
||||
if (!isActiveTracker(this)) {
|
||||
if (import.meta.env.DEV) {
|
||||
logger.error(
|
||||
'captureCanvasState called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
}
|
||||
logger.warn(
|
||||
'captureCanvasState called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user