mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
1 Commits
test/compo
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff893d2408 |
File diff suppressed because one or more lines are too long
16388
packages/ingest-types/src/types.gen.ts
generated
16388
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
4968
packages/ingest-types/src/zod.gen.ts
generated
4968
packages/ingest-types/src/zod.gen.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
|
||||
import { defineComponent, h, markRaw, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
@@ -12,35 +12,19 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: null
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
shouldRenderVueNodes: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldRenderVueNodes = ref(false)
|
||||
mockFeatureFlags.refs = {
|
||||
shouldRenderVueNodes
|
||||
}
|
||||
|
||||
return {
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionToolboxPosition', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
@@ -48,39 +32,28 @@ describe('useSelectionToolboxPosition', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
if (mockFeatureFlags.refs) {
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {},
|
||||
ds: Partial<LGraphCanvas['ds']> = {}
|
||||
) {
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: ds.offset ?? [0, 0],
|
||||
scale: ds.scale ?? 1
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
selectedItems: new Set([item]),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
selectionChanged: true
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
let toolbox: HTMLElement | undefined
|
||||
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
|
||||
toolbox = toolboxRef.value
|
||||
;({ visible } = useSelectionToolboxPosition(toolboxRef))
|
||||
useSelectionToolboxPosition(toolboxRef)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
@@ -88,28 +61,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
const wrapper = render(TestHarness)
|
||||
if (!toolbox) throw new Error('Toolbox element was not initialized')
|
||||
|
||||
if (!visible) throw new Error('Visible state was not initialized')
|
||||
|
||||
return { toolbox, unmount: wrapper.unmount, visible }
|
||||
}
|
||||
|
||||
function setCanvasSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {}
|
||||
) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
return { toolbox, unmount: wrapper.unmount }
|
||||
}
|
||||
|
||||
it('positions groups from their unchanged bounds', () => {
|
||||
@@ -117,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
const { toolbox, unmount } = renderToolboxForSelection(group)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
@@ -129,223 +81,11 @@ describe('useSelectionToolboxPosition', () => {
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
const { toolbox, unmount } = renderToolboxForSelection(node)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates when selection is empty', () => {
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([])
|
||||
|
||||
expect(visible.value).toBe(false)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not update when selection state is unchanged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
|
||||
selectionChanged: false
|
||||
})
|
||||
|
||||
expect(visible.value).toBe(false)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates while selected items are being dragged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
|
||||
draggingItems: true
|
||||
})
|
||||
|
||||
expect(visible.value).toBe(false)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('positions multiple selected items from their union bounds', () => {
|
||||
const first = new LGraphGroup('First', 1)
|
||||
first.pos = [100, 200]
|
||||
first.size = [100, 40]
|
||||
const second = new LGraphGroup('Second', 2)
|
||||
second.pos = [300, 260]
|
||||
second.size = [50, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([first, second])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('applies canvas scale and offset to screen coordinates', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [100, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(
|
||||
[group],
|
||||
{},
|
||||
{ offset: [10, 20], scale: 2 }
|
||||
)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('uses Vue layout bounds when Vue node rendering is enabled', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(12)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{
|
||||
id: node.id,
|
||||
pos: [300, 400],
|
||||
size: [200, 120]
|
||||
}
|
||||
])
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('400px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${390 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('falls back to LiteGraph node bounds when Vue layout is missing', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(13)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides the toolbox while Vue nodes are being dragged', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores selected items that are not nodes or groups', () => {
|
||||
const item = createMockPositionable({
|
||||
id: toNodeId(52),
|
||||
pos: [100, 200],
|
||||
size: [160, 80],
|
||||
boundingRect: [100, 200, 160, 80]
|
||||
})
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores selected items without valid ids', () => {
|
||||
const item = {
|
||||
id: null,
|
||||
pos: [100, 200],
|
||||
size: [160, 80],
|
||||
boundingRect: [100, 200, 160, 80]
|
||||
} as unknown as Positionable
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stays visible without mutating style when the toolbox ref is empty', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
setCanvasSelection([group])
|
||||
|
||||
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
;({ visible } = useSelectionToolboxPosition(ref()))
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = render(TestHarness)
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides and restores around Vue node drag state changes', async () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(0), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
|
||||
clearTimeout(handle)
|
||||
})
|
||||
|
||||
const { visible, unmount } = renderToolboxForSelection([group])
|
||||
expect(visible.value).toBe(true)
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await nextTick()
|
||||
expect(visible.value).toBe(false)
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(visible.value).toBe(true)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { actions, customization } = vi.hoisted(() => ({
|
||||
actions: {
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
},
|
||||
customization: {
|
||||
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
|
||||
colorOptions: [] as Array<{
|
||||
name: string
|
||||
localizedName: string
|
||||
value: { dark: string; light: string }
|
||||
}>,
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
isLightTheme: { value: false }
|
||||
}
|
||||
}))
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: null } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: customization.shapeOptions,
|
||||
applyShape: customization.applyShape,
|
||||
applyColor: customization.applyColor,
|
||||
colorOptions: customization.colorOptions,
|
||||
isLightTheme: customization.isLightTheme
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => actions
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
|
||||
return label
|
||||
}
|
||||
|
||||
function readNodeMenuOptions<T>(
|
||||
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
|
||||
): T {
|
||||
const unread = Symbol('unread')
|
||||
const result: { value: T | typeof unread } = { value: unread }
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
result.value = read(useNodeMenuOptions())
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
if (result.value === unread) throw new Error('Composable was not read')
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions', () => {
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
customization.shapeOptions = []
|
||||
customization.colorOptions = []
|
||||
customization.isLightTheme.value = false
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
|
||||
])
|
||||
).toBe('contextMenu.Bypass')
|
||||
})
|
||||
|
||||
it('labels visual node options from the collapsed state and bumps after action', () => {
|
||||
const expandBump = vi.fn()
|
||||
const expand = readNodeMenuOptions(
|
||||
({ getNodeVisualOptions }) =>
|
||||
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
|
||||
)
|
||||
expect(expand).toMatchObject({
|
||||
label: 'contextMenu.Expand Node',
|
||||
icon: 'icon-[lucide--maximize-2]'
|
||||
})
|
||||
expand.action?.()
|
||||
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
|
||||
expect(expandBump).toHaveBeenCalledTimes(1)
|
||||
|
||||
const minimize = readNodeMenuOptions(
|
||||
({ getNodeVisualOptions }) =>
|
||||
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
|
||||
)
|
||||
expect(minimize).toMatchObject({
|
||||
label: 'contextMenu.Minimize Node',
|
||||
icon: 'icon-[lucide--minimize-2]'
|
||||
})
|
||||
})
|
||||
|
||||
it('labels pin options from the pinned state and bumps after action', () => {
|
||||
const bump = vi.fn()
|
||||
const unpin = readNodeMenuOptions(({ getPinOption }) =>
|
||||
getPinOption({ collapsed: false, pinned: true }, bump)
|
||||
)
|
||||
expect(unpin).toMatchObject({
|
||||
label: 'contextMenu.Unpin',
|
||||
icon: 'icon-[lucide--pin-off]'
|
||||
})
|
||||
unpin.action?.()
|
||||
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
|
||||
expect(bump).toHaveBeenCalledTimes(1)
|
||||
|
||||
const pin = readNodeMenuOptions(({ getPinOption }) =>
|
||||
getPinOption({ collapsed: false, pinned: false }, vi.fn())
|
||||
)
|
||||
expect(pin).toMatchObject({
|
||||
label: 'contextMenu.Pin',
|
||||
icon: 'icon-[lucide--pin]'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds shape and color submenus and applies selected values', () => {
|
||||
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
|
||||
customization.colorOptions = [
|
||||
{
|
||||
name: 'noColor',
|
||||
localizedName: 'No Color',
|
||||
value: { dark: '#000', light: '#fff' }
|
||||
},
|
||||
{
|
||||
name: 'red',
|
||||
localizedName: 'Red',
|
||||
value: { dark: '#111', light: '#eee' }
|
||||
}
|
||||
]
|
||||
|
||||
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
|
||||
visualOptions: options.getNodeVisualOptions(
|
||||
{ collapsed: false, pinned: false },
|
||||
vi.fn()
|
||||
),
|
||||
colorSubmenu: options.colorSubmenu.value
|
||||
}))
|
||||
|
||||
expect(visualOptions[1].submenu).toEqual([
|
||||
expect.objectContaining({ label: 'Box' })
|
||||
])
|
||||
visualOptions[1].submenu?.[0].action()
|
||||
expect(customization.applyShape).toHaveBeenCalledWith(
|
||||
customization.shapeOptions[0]
|
||||
)
|
||||
|
||||
expect(colorSubmenu).toEqual([
|
||||
expect.objectContaining({ label: 'No Color', color: '#000' }),
|
||||
expect.objectContaining({ label: 'Red', color: '#111' })
|
||||
])
|
||||
colorSubmenu[0].action()
|
||||
colorSubmenu[1].action()
|
||||
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
|
||||
expect(customization.applyColor).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
customization.colorOptions[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('uses light-theme colors for the color submenu', () => {
|
||||
customization.isLightTheme.value = true
|
||||
customization.colorOptions = [
|
||||
{
|
||||
name: 'red',
|
||||
localizedName: 'Red',
|
||||
value: { dark: '#111', light: '#eee' }
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
|
||||
).toBe('#eee')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,45 +4,34 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
selectedItems: [] as unknown[],
|
||||
getSelectedNodes: vi.fn((): unknown[] => []),
|
||||
getCanvas: vi.fn(),
|
||||
updateSelectedItems: vi.fn(),
|
||||
revokeSubgraphPreviews: vi.fn(),
|
||||
activeWorkflow: null as null | {
|
||||
changeTracker?: {
|
||||
captureCanvasState: () => void
|
||||
}
|
||||
}
|
||||
selectedItems: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: () => ({
|
||||
getSelectedNodes: mocks.getSelectedNodes
|
||||
getSelectedNodes: vi.fn(() => [])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas,
|
||||
getCanvas: vi.fn(),
|
||||
get selectedItems() {
|
||||
return mocks.selectedItems
|
||||
},
|
||||
updateSelectedItems: mocks.updateSelectedItems
|
||||
updateSelectedItems: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mocks.activeWorkflow
|
||||
}
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
|
||||
revokeSubgraphPreviews: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -61,36 +50,10 @@ function createRegularNode(): LGraphNode {
|
||||
return new LGraphNode('testnode')
|
||||
}
|
||||
|
||||
function createCanvas({
|
||||
graph,
|
||||
subgraph,
|
||||
selectedItems = []
|
||||
}: {
|
||||
graph?: {
|
||||
convertToSubgraph?: ReturnType<typeof vi.fn>
|
||||
unpackSubgraph?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
subgraph?: {
|
||||
convertToSubgraph?: ReturnType<typeof vi.fn>
|
||||
unpackSubgraph?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
selectedItems?: unknown[]
|
||||
} = {}) {
|
||||
return {
|
||||
graph,
|
||||
subgraph,
|
||||
selectedItems: new Set(selectedItems),
|
||||
select: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedItems = []
|
||||
mocks.getSelectedNodes.mockReturnValue([])
|
||||
mocks.getCanvas.mockReturnValue(createCanvas())
|
||||
mocks.activeWorkflow = null
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
|
||||
@@ -140,126 +103,4 @@ describe('useSubgraphOperations', () => {
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports selected subgraph and selectable node state', async () => {
|
||||
mocks.selectedItems = [createRegularNode()]
|
||||
mocks.getSelectedNodes.mockReturnValue([])
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { isSubgraphSelected, hasSelectableNodes } = useSubgraphOperations()
|
||||
|
||||
expect(isSubgraphSelected()).toBe(false)
|
||||
expect(hasSelectableNodes()).toBe(false)
|
||||
|
||||
mocks.selectedItems = [createSubgraphNode()]
|
||||
mocks.getSelectedNodes.mockReturnValue([createRegularNode()])
|
||||
|
||||
expect(isSubgraphSelected()).toBe(true)
|
||||
expect(hasSelectableNodes()).toBe(true)
|
||||
})
|
||||
|
||||
it('converts selected items to a subgraph and captures workflow state', async () => {
|
||||
const captureCanvasState = vi.fn()
|
||||
const node = createSubgraphNode()
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(() => ({ node })),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
const canvas = createCanvas({
|
||||
graph,
|
||||
selectedItems: [createRegularNode()]
|
||||
})
|
||||
mocks.getCanvas.mockReturnValue(canvas)
|
||||
mocks.activeWorkflow = {
|
||||
changeTracker: {
|
||||
captureCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { convertToSubgraph } = useSubgraphOperations()
|
||||
|
||||
convertToSubgraph()
|
||||
|
||||
expect(graph.convertToSubgraph).toHaveBeenCalledWith(canvas.selectedItems)
|
||||
expect(canvas.select).toHaveBeenCalledWith(node)
|
||||
expect(mocks.updateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not select or capture when conversion has no graph or no result', async () => {
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(() => null),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
const canvas = createCanvas({ graph })
|
||||
mocks.getCanvas
|
||||
.mockReturnValueOnce(createCanvas())
|
||||
.mockReturnValueOnce(canvas)
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { convertToSubgraph } = useSubgraphOperations()
|
||||
|
||||
expect(convertToSubgraph()).toBeNull()
|
||||
expect(convertToSubgraph()).toBeUndefined()
|
||||
expect(canvas.select).not.toHaveBeenCalled()
|
||||
expect(mocks.updateSelectedItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unpacks selected subgraph nodes from the active graph and revokes previews', async () => {
|
||||
const captureCanvasState = vi.fn()
|
||||
const subgraphNode = createSubgraphNode()
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
mocks.getCanvas.mockReturnValue(
|
||||
createCanvas({
|
||||
subgraph: graph,
|
||||
selectedItems: [subgraphNode, createRegularNode()]
|
||||
})
|
||||
)
|
||||
mocks.activeWorkflow = {
|
||||
changeTracker: {
|
||||
captureCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { unpackSubgraph } = useSubgraphOperations()
|
||||
|
||||
unpackSubgraph()
|
||||
|
||||
expect(mocks.revokeSubgraphPreviews).toHaveBeenCalledWith(subgraphNode)
|
||||
expect(graph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode, {
|
||||
skipMissingNodes: true
|
||||
})
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not unpack when no graph or no subgraph nodes are selected', async () => {
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
mocks.getCanvas
|
||||
.mockReturnValueOnce(createCanvas())
|
||||
.mockReturnValueOnce(
|
||||
createCanvas({ graph, selectedItems: [createRegularNode()] })
|
||||
)
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { unpackSubgraph } = useSubgraphOperations()
|
||||
|
||||
unpackSubgraph()
|
||||
unpackSubgraph()
|
||||
|
||||
expect(graph.unpackSubgraph).not.toHaveBeenCalled()
|
||||
expect(mocks.revokeSubgraphPreviews).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
|
||||
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
||||
mockFetchApi: vi.fn(),
|
||||
@@ -11,41 +11,22 @@ const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
|
||||
let capturedResultItemDrop: (item: ResultItem) => void
|
||||
let capturedPasteOnPaste: (files: File[]) => Promise<string[]>
|
||||
let capturedFileInputOnSelect: (files: File[]) => Promise<string[]>
|
||||
const mockOpenFileSelection = vi.fn()
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
|
||||
useNodeDragAndDrop: (
|
||||
_node: LGraphNode,
|
||||
opts: {
|
||||
onDrop: typeof capturedDragOnDrop
|
||||
onResultItemDrop: typeof capturedResultItemDrop
|
||||
}
|
||||
opts: { onDrop: typeof capturedDragOnDrop }
|
||||
) => {
|
||||
capturedDragOnDrop = opts.onDrop
|
||||
capturedResultItemDrop = opts.onResultItemDrop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeFileInput', () => ({
|
||||
useNodeFileInput: (
|
||||
_node: LGraphNode,
|
||||
opts: { onSelect: typeof capturedFileInputOnSelect }
|
||||
) => {
|
||||
capturedFileInputOnSelect = opts.onSelect
|
||||
return { openFileSelection: mockOpenFileSelection }
|
||||
}
|
||||
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePaste', () => ({
|
||||
useNodePaste: (
|
||||
_node: LGraphNode,
|
||||
opts: { onPaste: typeof capturedPasteOnPaste }
|
||||
) => {
|
||||
capturedPasteOnPaste = opts.onPaste
|
||||
}
|
||||
useNodePaste: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -97,26 +78,6 @@ describe('useNodeImageUpload', () => {
|
||||
let onUploadStart: (files: File[]) => void
|
||||
let onUploadError: () => void
|
||||
|
||||
async function mountImageUpload(
|
||||
options: { folder?: ResultItemType } = { folder: 'input' }
|
||||
) {
|
||||
const { useNodeImageUpload } = await import('./useNodeImageUpload')
|
||||
return useNodeImageUpload(node, {
|
||||
onUploadComplete,
|
||||
onUploadStart,
|
||||
onUploadError,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
function lastUploadBody() {
|
||||
const body = mockFetchApi.mock.calls.at(-1)?.[1]?.body
|
||||
if (!(body instanceof FormData)) {
|
||||
throw new Error('Expected upload body to be FormData')
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
@@ -125,7 +86,13 @@ describe('useNodeImageUpload', () => {
|
||||
onUploadStart = vi.fn()
|
||||
onUploadError = vi.fn()
|
||||
|
||||
await mountImageUpload()
|
||||
const { useNodeImageUpload } = await import('./useNodeImageUpload')
|
||||
useNodeImageUpload(node, {
|
||||
onUploadComplete,
|
||||
onUploadStart,
|
||||
onUploadError,
|
||||
folder: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
@@ -213,60 +180,4 @@ describe('useNodeImageUpload', () => {
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('passes dropped result items through without uploading', () => {
|
||||
const resultItem = fromAny<ResultItem, unknown>({
|
||||
filename: 'existing.png',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
capturedResultItemDrop(resultItem)
|
||||
|
||||
expect(onUploadComplete).toHaveBeenCalledWith([resultItem])
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads pasted images to the pasted subfolder', async () => {
|
||||
const { handleUpload } = await mountImageUpload({})
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('image.png'))
|
||||
|
||||
await handleUpload(createFile('image.png'))
|
||||
|
||||
const body = lastUploadBody()
|
||||
expect(body.get('subfolder')).toBe('pasted')
|
||||
expect(body.get('type')).toBeNull()
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes input assets for default non-pasted uploads', async () => {
|
||||
const { handleUpload } = await mountImageUpload({})
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('upload.png'))
|
||||
|
||||
await handleUpload(createFile('upload.png'))
|
||||
|
||||
const body = lastUploadBody()
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
expect(body.get('type')).toBeNull()
|
||||
expect(mockUpdateInputs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not refresh input assets for explicit output uploads', async () => {
|
||||
await mountImageUpload({ folder: 'output' })
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('output.png'))
|
||||
|
||||
await capturedFileInputOnSelect([createFile('output.png')])
|
||||
|
||||
const body = lastUploadBody()
|
||||
expect(body.get('type')).toBe('output')
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a specific alert for upload timeouts', async () => {
|
||||
mockFetchApi.mockRejectedValueOnce(new DOMException('', 'TimeoutError'))
|
||||
|
||||
await capturedPasteOnPaste([createFile()])
|
||||
|
||||
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadTimedOut')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -30,12 +27,10 @@ vi.mock('@vueuse/core', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
|
||||
StrokeProcessor: vi.fn(function StrokeProcessor() {
|
||||
return {
|
||||
addPoint: vi.fn(() => []),
|
||||
endStroke: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
StrokeProcessor: vi.fn(() => ({
|
||||
addPoint: vi.fn(() => []),
|
||||
endStroke: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -47,15 +42,14 @@ vi.mock('@/platform/updates/common/toastStore', () => {
|
||||
return { useToastStore: () => store }
|
||||
})
|
||||
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
getNodeImageUrls: vi.fn(() => undefined as string[] | undefined),
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => mockNodeOutputStore
|
||||
}))
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
const store = {
|
||||
getNodeImageUrls: vi.fn(() => undefined),
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
}
|
||||
return { useNodeOutputStore: () => store }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -67,7 +61,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
const mockWidgets: IBaseWidget[] = []
|
||||
const mockProperties: Record<string, unknown> = {}
|
||||
const mockIsInputConnected = vi.fn(() => false)
|
||||
const mockGetInputNode = vi.fn((): LGraphNode | null => null)
|
||||
const mockGetInputNode = vi.fn(() => null)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -99,6 +93,9 @@ function makeWidget(name: string, value: unknown = null): IBaseWidget {
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
|
||||
*/
|
||||
function mountPainter(
|
||||
nodeId: NodeId = toNodeId('test-node'),
|
||||
initialModelValue = ''
|
||||
@@ -122,140 +119,11 @@ function mountPainter(
|
||||
}
|
||||
})
|
||||
|
||||
const rendered = render(Wrapper)
|
||||
return { painter, canvasEl, cursorEl, modelValue, unmount: rendered.unmount }
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
const gradient = { addColorStop: vi.fn() }
|
||||
return {
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
globalCompositeOperation: '',
|
||||
globalAlpha: 1,
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt',
|
||||
lineJoin: 'miter'
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createCanvasElement(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width = 100,
|
||||
height = 100
|
||||
) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
vi.spyOn(canvas, 'getContext').mockReturnValue(fromAny(ctx))
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
right: width,
|
||||
bottom: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn()
|
||||
})
|
||||
vi.spyOn(canvas, 'toBlob').mockImplementation((cb) => cb(new Blob(['x'])))
|
||||
return canvas
|
||||
}
|
||||
|
||||
async function mountPainterWithMaskCanvas({
|
||||
modelValue = '',
|
||||
toBlob = (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
}: {
|
||||
modelValue?: string
|
||||
toBlob?: (cb: BlobCallback) => void
|
||||
} = {}) {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
getContext: vi.fn(() => ({ clearRect: vi.fn() })),
|
||||
toBlob
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const mounted = mountPainter(toNodeId('test-node'), modelValue)
|
||||
mounted.canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
return { maskWidget, ...mounted }
|
||||
}
|
||||
|
||||
function stubFakeImage() {
|
||||
const images: Array<{
|
||||
onload: (() => void) | null
|
||||
onerror: (() => void) | null
|
||||
}> = []
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 64
|
||||
naturalHeight = 32
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
images.push(this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
return images
|
||||
}
|
||||
|
||||
function createPointerEvent(
|
||||
type: string,
|
||||
values: {
|
||||
clientX?: number
|
||||
clientY?: number
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
button?: number
|
||||
pointerId?: number
|
||||
target?: Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>
|
||||
} = {}
|
||||
) {
|
||||
const event = new PointerEvent(type, {
|
||||
button: values.button ?? 0,
|
||||
clientX: values.clientX ?? 0,
|
||||
clientY: values.clientY ?? 0,
|
||||
pointerId: values.pointerId ?? 1
|
||||
})
|
||||
Object.defineProperty(event, 'offsetX', { value: values.offsetX ?? 0 })
|
||||
Object.defineProperty(event, 'offsetY', { value: values.offsetY ?? 0 })
|
||||
Object.defineProperty(event, 'target', {
|
||||
value:
|
||||
values.target ??
|
||||
({
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn()
|
||||
} as Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>)
|
||||
})
|
||||
return event
|
||||
render(Wrapper)
|
||||
return { painter, canvasEl, cursorEl, modelValue }
|
||||
}
|
||||
|
||||
describe('usePainter', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
@@ -265,7 +133,6 @@ describe('usePainter', () => {
|
||||
}
|
||||
mockIsInputConnected.mockReturnValue(false)
|
||||
mockGetInputNode.mockReturnValue(null)
|
||||
mockNodeOutputStore.getNodeImageUrls.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('syncCanvasSizeFromWidgets', () => {
|
||||
@@ -284,25 +151,6 @@ describe('usePainter', () => {
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
})
|
||||
|
||||
it('keeps defaults when the node id is empty', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const { painter } = mountPainter(toNodeId(''))
|
||||
|
||||
expect(app.canvas.graph!.getNodeById).not.toHaveBeenCalled()
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
expect(painter.inputImageUrl.value).toBeNull()
|
||||
expect(painter.isImageInputConnected.value).toBe(false)
|
||||
expect(maskWidget.serializeValue).toBeUndefined()
|
||||
|
||||
painter.brushSize.value = 36
|
||||
await nextTick()
|
||||
|
||||
expect(mockProperties.painterBrushSize).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreSettingsFromProperties', () => {
|
||||
@@ -378,18 +226,6 @@ describe('usePainter', () => {
|
||||
expect(widthWidget.callback).toHaveBeenCalledWith(800)
|
||||
expect(heightWidget.callback).toHaveBeenCalledWith(600)
|
||||
})
|
||||
|
||||
it('skips widget callbacks when dimensions are unchanged', async () => {
|
||||
const widthWidget = makeWidget('width', 512)
|
||||
const heightWidget = makeWidget('height', 512)
|
||||
mockWidgets.push(widthWidget, heightWidget)
|
||||
|
||||
mountPainter()
|
||||
await nextTick()
|
||||
|
||||
expect(widthWidget.callback).not.toHaveBeenCalled()
|
||||
expect(heightWidget.callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncBackgroundColorToWidget', () => {
|
||||
@@ -405,16 +241,6 @@ describe('usePainter', () => {
|
||||
expect(bgWidget.value).toBe('#ff00ff')
|
||||
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
|
||||
})
|
||||
|
||||
it('skips widget callbacks when the background color is unchanged', async () => {
|
||||
const bgWidget = makeWidget('bg_color', '#000000')
|
||||
mockWidgets.push(bgWidget)
|
||||
|
||||
mountPainter()
|
||||
await nextTick()
|
||||
|
||||
expect(bgWidget.callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInputImageUrl', () => {
|
||||
@@ -432,34 +258,6 @@ describe('usePainter', () => {
|
||||
|
||||
expect(painter.isImageInputConnected.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sets inputImageUrl from the connected input node output', () => {
|
||||
const inputNode = {} as LGraphNode
|
||||
mockIsInputConnected.mockReturnValue(true)
|
||||
mockGetInputNode.mockReturnValue(inputNode)
|
||||
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([
|
||||
'http://localhost:8188/view?filename=input.png'
|
||||
])
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(mockNodeOutputStore.getNodeImageUrls).toHaveBeenCalledWith(
|
||||
inputNode
|
||||
)
|
||||
expect(painter.inputImageUrl.value).toBe(
|
||||
'http://localhost:8188/view?filename=input.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps inputImageUrl null when a connected input has no images', () => {
|
||||
mockIsInputConnected.mockReturnValue(true)
|
||||
mockGetInputNode.mockReturnValue({} as LGraphNode)
|
||||
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([])
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.inputImageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputImageLoad', () => {
|
||||
@@ -484,20 +282,6 @@ describe('usePainter', () => {
|
||||
expect(widthWidget.value).toBe(1920)
|
||||
expect(heightWidget.value).toBe(1080)
|
||||
})
|
||||
|
||||
it('updates canvas size when dimension widgets are absent', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.handleInputImageLoad({
|
||||
target: {
|
||||
naturalWidth: 320,
|
||||
naturalHeight: 240
|
||||
}
|
||||
} as unknown as Event)
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(320)
|
||||
expect(painter.canvasHeight.value).toBe(240)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor visibility', () => {
|
||||
@@ -515,17 +299,6 @@ describe('usePainter', () => {
|
||||
painter.handlePointerLeave()
|
||||
expect(painter.cursorVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('positions the custom cursor on pointer movement', () => {
|
||||
const { painter, cursorEl } = mountPainter()
|
||||
cursorEl.value = document.createElement('div')
|
||||
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { offsetX: 25, offsetY: 30 })
|
||||
)
|
||||
|
||||
expect(cursorEl.value.style.transform).toBe('translate(15px, 20px)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayBrushSize', () => {
|
||||
@@ -592,13 +365,24 @@ describe('usePainter', () => {
|
||||
})
|
||||
|
||||
it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
fetchApiMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({ name: 'uploaded.png' })
|
||||
} as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
@@ -615,81 +399,33 @@ describe('usePainter', () => {
|
||||
})
|
||||
|
||||
it('throws when the upload response is missing a name', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
} as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/missing 'name'/)
|
||||
})
|
||||
|
||||
it('throws when the upload request fails', async () => {
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('reports non-error upload rejections', async () => {
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce('offline')
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/offline/)
|
||||
})
|
||||
|
||||
it('throws when the upload response is not successful', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => 'upload failed'
|
||||
} as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/upload failed/)
|
||||
})
|
||||
|
||||
it('uses statusText when an unsuccessful upload response has no body', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
text: async () => ''
|
||||
} as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/Bad Gateway/)
|
||||
})
|
||||
|
||||
it('uses unknown error when an unsuccessful upload response has no detail', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 500,
|
||||
statusText: '',
|
||||
text: async () => ''
|
||||
} as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/unknown error/)
|
||||
})
|
||||
|
||||
it('throws when the upload response body is not valid JSON', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
@@ -697,62 +433,21 @@ describe('usePainter', () => {
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('reports non-error JSON parse failures', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw 'bad json'
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
const { maskWidget } = await mountPainterWithMaskCanvas()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/bad json/)
|
||||
})
|
||||
|
||||
it('returns modelValue when dirty canvas serialization produces no blob', async () => {
|
||||
const { painter, maskWidget } = await mountPainterWithMaskCanvas({
|
||||
toBlob: (cb) => cb(null)
|
||||
})
|
||||
|
||||
painter.handleClear()
|
||||
await nextTick()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
|
||||
expect(result).toBe('')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas serialization produces no blob', async () => {
|
||||
const toBlob = vi.fn((cb: BlobCallback) => cb(null))
|
||||
const { painter, maskWidget, modelValue } =
|
||||
await mountPainterWithMaskCanvas({
|
||||
modelValue: 'painter/cached.png [temp]',
|
||||
toBlob
|
||||
})
|
||||
|
||||
// handleClear marks the canvas dirty; restore the cached value it wipes
|
||||
painter.handleClear()
|
||||
modelValue.value = 'painter/cached.png [temp]'
|
||||
await nextTick()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
|
||||
expect(toBlob).toHaveBeenCalled()
|
||||
expect(result).toBe('painter/cached.png [temp]')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
@@ -803,83 +498,6 @@ describe('usePainter', () => {
|
||||
expect.stringContaining('type=temp')
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults restored mask type to input when no type suffix exists', () => {
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
|
||||
mountPainter(toNodeId('test-node'), 'plain.png')
|
||||
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('filename=plain.png')
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('type=input')
|
||||
)
|
||||
expect(api.apiURL).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('subfolder=')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore a canvas when the mask value is blank', () => {
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
|
||||
mountPainter(toNodeId('test-node'), ' ')
|
||||
|
||||
expect(api.apiURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a restored mask after the image loads', () => {
|
||||
const images = stubFakeImage()
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
|
||||
const { painter, canvasEl } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/mask.png [temp]'
|
||||
)
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
images[0].onload?.()
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(64)
|
||||
expect(painter.canvasHeight.value).toBe(32)
|
||||
expect(ctx.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores restored image loads after the canvas unmounts', () => {
|
||||
const images = stubFakeImage()
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
|
||||
const { painter, canvasEl, unmount } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/mask.png [temp]'
|
||||
)
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
unmount()
|
||||
// Vue clears template refs on unmount
|
||||
canvasEl.value = null
|
||||
images[0].onload?.()
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
expect(ctx.drawImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale modelValue when restored image loading fails', () => {
|
||||
const images = stubFakeImage()
|
||||
|
||||
const { modelValue } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/mask.png [temp]'
|
||||
)
|
||||
images[0].onerror?.()
|
||||
|
||||
expect(modelValue.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClear', () => {
|
||||
@@ -888,36 +506,6 @@ describe('usePainter', () => {
|
||||
|
||||
expect(() => painter.handleClear()).not.toThrow()
|
||||
})
|
||||
|
||||
it('clears the canvas and marks the current mask dirty', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
const ctx = createCanvasContext()
|
||||
mockWidgets.push(maskWidget)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
|
||||
const { painter, canvasEl, modelValue } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/cached.png [temp]'
|
||||
)
|
||||
canvasEl.value = createCanvasElement(ctx, 50, 40)
|
||||
|
||||
painter.handleClear()
|
||||
await nextTick()
|
||||
|
||||
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 50, 40)
|
||||
expect(modelValue.value).toBe('')
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({ name: 'cleared.png' })
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).resolves.toBe('cleared.png [input]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerDown', () => {
|
||||
@@ -959,184 +547,6 @@ describe('usePainter', () => {
|
||||
|
||||
expect(() => painter.handlePointerDown(event)).not.toThrow()
|
||||
})
|
||||
|
||||
it('draws a hard brush stroke across pointer events', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 10,
|
||||
offsetX: 60,
|
||||
offsetY: 10
|
||||
})
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalled()
|
||||
expect(ctx.moveTo).toHaveBeenCalled()
|
||||
expect(ctx.lineTo).toHaveBeenCalled()
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a soft brush stroke with radial dabs', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
painter.brushHardness.value = 0.5
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 70, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(ctx.createRadialGradient).toHaveBeenCalled()
|
||||
expect(ctx.arc).toHaveBeenCalled()
|
||||
expect(ctx.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses destination-out composition for eraser strokes', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
painter.tool.value = 'eraser'
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
|
||||
expect(ctx.globalCompositeOperation).toBe('destination-out')
|
||||
})
|
||||
|
||||
it('does not start drawing when a canvas context is unavailable', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn()
|
||||
})
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = canvas
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
expect(() => {
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
}).not.toThrow()
|
||||
|
||||
expect(canvas.getContext).toHaveBeenCalledWith('2d')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses one animation frame for pending pointer movement', () => {
|
||||
const ctx = createCanvasContext()
|
||||
let frameCallback: FrameRequestCallback | undefined
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback) => {
|
||||
frameCallback = callback
|
||||
return 7
|
||||
}
|
||||
)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 30, clientY: 30 })
|
||||
)
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
|
||||
frameCallback?.(0)
|
||||
|
||||
expect(ctx.lineTo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes a pending pointer movement when leaving the canvas', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
painter.handlePointerLeave()
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
|
||||
expect(ctx.lineTo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pending pointer movement when unmounted', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl, unmount } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
unmount()
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerUp', () => {
|
||||
@@ -1171,32 +581,5 @@ describe('usePainter', () => {
|
||||
|
||||
expect(() => painter.handlePointerUp(event)).not.toThrow()
|
||||
})
|
||||
|
||||
it('draws final stroke processor points on release', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
vi.mocked(StrokeProcessor).mockImplementationOnce(
|
||||
class MockStrokeProcessor {
|
||||
addPoint = vi.fn(() => [])
|
||||
endStroke = vi.fn(() => [
|
||||
{ x: 40, y: 10 },
|
||||
{ x: 80, y: 10 }
|
||||
])
|
||||
} as unknown as typeof StrokeProcessor
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(10, 10)
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(40, 10)
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(80, 10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +1,24 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
|
||||
|
||||
type MediaControls = {
|
||||
playing: Ref<boolean>
|
||||
currentTime: Ref<number>
|
||||
duration: Ref<number>
|
||||
volume: Ref<number>
|
||||
muted: Ref<boolean>
|
||||
}
|
||||
|
||||
const mockMediaControls = vi.hoisted(() => ({
|
||||
values: [] as MediaControls[]
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
useMediaControls: () =>
|
||||
mockMediaControls.values.shift() ?? {
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0),
|
||||
volume: ref(1),
|
||||
muted: ref(false)
|
||||
}
|
||||
useMediaControls: () => ({
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
|
||||
function queueMediaControls(overrides: Partial<MediaControls> = {}) {
|
||||
const controls: MediaControls = {
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0),
|
||||
volume: ref(1),
|
||||
muted: ref(false),
|
||||
...overrides
|
||||
}
|
||||
mockMediaControls.values.push(controls)
|
||||
return controls
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockMediaControls.values = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext
|
||||
mockFetchApi.mockReset()
|
||||
@@ -83,21 +50,6 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(playedBarIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('computes progress and played bar when duration is known', () => {
|
||||
queueMediaControls({
|
||||
currentTime: ref(30),
|
||||
duration: ref(120)
|
||||
})
|
||||
const src = ref('')
|
||||
const { playedBarIndex, progressRatio } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 40
|
||||
})
|
||||
|
||||
expect(playedBarIndex.value).toBe(9)
|
||||
expect(progressRatio.value).toBe(25)
|
||||
})
|
||||
|
||||
it('generates bars with heights between 10 and 70', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
@@ -113,56 +65,6 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates playback and seek controls', () => {
|
||||
const controls = queueMediaControls({
|
||||
currentTime: ref(10),
|
||||
duration: ref(100)
|
||||
})
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
|
||||
player.togglePlayPause()
|
||||
expect(player.isPlaying.value).toBe(true)
|
||||
|
||||
player.seekToStart()
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.seekToRatio(0.25)
|
||||
expect(controls.currentTime.value).toBe(25)
|
||||
|
||||
player.seekToRatio(-1)
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.seekToRatio(2)
|
||||
expect(controls.currentTime.value).toBe(100)
|
||||
|
||||
player.seekToEnd()
|
||||
expect(controls.currentTime.value).toBe(100)
|
||||
expect(player.isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates mute state and volume icon', () => {
|
||||
const controls = queueMediaControls({
|
||||
volume: ref(1),
|
||||
muted: ref(false)
|
||||
})
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-2]')
|
||||
|
||||
controls.volume.value = 0.25
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-1]')
|
||||
|
||||
controls.volume.value = 0
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
|
||||
|
||||
controls.volume.value = 1
|
||||
player.toggleMute()
|
||||
expect(controls.muted.value).toBe(true)
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
|
||||
})
|
||||
|
||||
it('shows 0:00 for formatted times initially', () => {
|
||||
const src = ref('')
|
||||
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
|
||||
@@ -206,91 +108,6 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('uses placeholder bars when decoded audio has no channel data', async () => {
|
||||
const mockAudioBuffer = {
|
||||
getChannelData: vi.fn(() => new Float32Array())
|
||||
}
|
||||
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.AudioContext = fromAny<typeof AudioContext, unknown>(
|
||||
class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
}
|
||||
)
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))
|
||||
})
|
||||
const src = ref('/api/view?filename=empty.wav&type=output')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 6 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(6)
|
||||
for (const bar of bars.value) {
|
||||
expect(bar.height).toBeGreaterThanOrEqual(10)
|
||||
expect(bar.height).toBeLessThanOrEqual(70)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses placeholder bars when fetching audio fails', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500
|
||||
})
|
||||
const src = ref('https://example.com/audio.wav')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 5 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('https://example.com/audio.wav')
|
||||
expect(bars.value).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('seeks from waveform clicks and starts playback', () => {
|
||||
const controls = queueMediaControls({
|
||||
duration: ref(100)
|
||||
})
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
|
||||
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 50 }))
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.waveformRef.value = fromPartial<HTMLElement>({
|
||||
getBoundingClientRect: () => ({ left: 10, width: 100 })
|
||||
})
|
||||
|
||||
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 60 }))
|
||||
expect(controls.currentTime.value).toBe(50)
|
||||
expect(player.isPlaying.value).toBe(true)
|
||||
|
||||
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: -100 }))
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 999 }))
|
||||
expect(controls.currentTime.value).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores waveform clicks when duration is zero', () => {
|
||||
const controls = queueMediaControls()
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
player.waveformRef.value = fromPartial<HTMLElement>({
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 })
|
||||
})
|
||||
|
||||
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 50 }))
|
||||
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
expect(player.isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
|
||||
@@ -3605,7 +3605,6 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"publishButton": "Publish to ComfyHub",
|
||||
"updateButton": "Update workflow",
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
@@ -3620,8 +3619,6 @@
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again.",
|
||||
"renameFailedTitle": "Rename failed",
|
||||
"renameFailedDescription": "Your workflow was published successfully, but renaming the local file failed. Rename it again to match.",
|
||||
"publishSuccessTitle": "Published successfully",
|
||||
"publishSuccessDescription": "Your workflow is now live on ComfyHub."
|
||||
},
|
||||
@@ -3630,12 +3627,9 @@
|
||||
"profileCreationNav": "Profile creation",
|
||||
"introTitle": "Publish to the ComfyHub",
|
||||
"introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
|
||||
"updateIntroTitle": "Update your ComfyHub workflow",
|
||||
"updateIntroDescription": "Push your latest changes to ComfyHub. Your share link and stats stay the same.",
|
||||
"introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
|
||||
"createProfileButton": "Create my profile",
|
||||
"startPublishingButton": "Start publishing",
|
||||
"startUpdatingButton": "Update workflow",
|
||||
"modalTitle": "Create your profile on ComfyHub",
|
||||
"createProfileTitle": "Create your Comfy Hub profile",
|
||||
"uploadCover": "+ Upload a cover",
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
<ComfyHubPublishIntroPanel
|
||||
v-else
|
||||
data-testid="publish-intro"
|
||||
:is-update="!!publishResult"
|
||||
:on-create-profile="handleOpenPublishDialog"
|
||||
:on-close="onClose"
|
||||
:show-close-button="false"
|
||||
|
||||
@@ -15,18 +15,10 @@
|
||||
<!-- Content -->
|
||||
<section class="flex flex-col items-center gap-4 px-4 pt-4 pb-6">
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubProfile.updateIntroTitle')
|
||||
: $t('comfyHubProfile.introTitle')
|
||||
}}
|
||||
{{ $t('comfyHubProfile.introTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 text-center text-sm text-muted-foreground">
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubProfile.updateIntroDescription')
|
||||
: $t('comfyHubProfile.introDescription')
|
||||
}}
|
||||
{{ $t('comfyHubProfile.introDescription') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -34,11 +26,7 @@
|
||||
class="mt-2 w-full"
|
||||
@click="onCreateProfile"
|
||||
>
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubProfile.startUpdatingButton')
|
||||
: $t('comfyHubProfile.startPublishingButton')
|
||||
}}
|
||||
{{ $t('comfyHubProfile.startPublishingButton') }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -50,12 +38,10 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
const {
|
||||
onCreateProfile,
|
||||
onClose,
|
||||
showCloseButton = true,
|
||||
isUpdate = false
|
||||
showCloseButton = true
|
||||
} = defineProps<{
|
||||
onCreateProfile: () => void
|
||||
onClose: () => void
|
||||
showCloseButton?: boolean
|
||||
isUpdate?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
@@ -30,10 +30,6 @@ const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
|
||||
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
|
||||
const mockRenameWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockFormDataHolder = vi.hoisted(
|
||||
() => ({ value: null }) as { value: Record<string, unknown> | null }
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
@@ -46,41 +42,35 @@ vi.mock(
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
|
||||
() => {
|
||||
mockFormDataHolder.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
existingThumbnailType: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
return {
|
||||
useComfyHubPublishWizard: () => ({
|
||||
currentStep: ref('finish'),
|
||||
formData: ref(mockFormDataHolder.value),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
goToStep: mockGoToStep,
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
() => ({
|
||||
useComfyHubPublishWizard: () => ({
|
||||
currentStep: ref('finish'),
|
||||
formData: ref({
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}),
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
}
|
||||
}
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
goToStep: mockGoToStep,
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
}),
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
@@ -100,44 +90,23 @@ vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
renameWorkflow: mockRenameWorkflow,
|
||||
renameWorkflow: vi.fn(),
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => {
|
||||
return {
|
||||
instance: null as { activeWorkflow: Record<string, unknown> | null } | null
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
mockWorkflowStore.instance = reactive({
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
} as Record<string, unknown> | null
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
return {
|
||||
useWorkflowStore: () => ({
|
||||
...mockWorkflowStore.instance,
|
||||
get activeWorkflow() {
|
||||
return mockWorkflowStore.instance?.activeWorkflow ?? null
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function setActiveWorkflow(workflow: Record<string, unknown> | null) {
|
||||
if (mockWorkflowStore.instance) {
|
||||
mockWorkflowStore.instance.activeWorkflow = workflow
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
@@ -148,17 +117,8 @@ describe('ComfyHubPublishDialog', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
mockFetchProfile.mockResolvedValue(null)
|
||||
mockSubmitToComfyHub.mockResolvedValue(undefined)
|
||||
mockRenameWorkflow.mockResolvedValue(undefined)
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = ''
|
||||
mockGetCachedPrefill.mockReturnValue(null)
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: false,
|
||||
@@ -266,119 +226,6 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renames the local workflow when the published name differs', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRenameWorkflow).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'workflows/renamed.json'
|
||||
)
|
||||
expect(mockSubmitToComfyHub.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRenameWorkflow.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rename when the published name matches the file name', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'test'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRenameWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports success but warns when the post-publish rename fails', async () => {
|
||||
mockRenameWorkflow.mockRejectedValueOnce(new Error('rename failed'))
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not rename or close when publish submission fails', async () => {
|
||||
mockSubmitToComfyHub.mockRejectedValueOnce(new Error('submit failed'))
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(mockRenameWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not refetch publish status when the rename changes the path mid-publish', async () => {
|
||||
mockRenameWorkflow.mockImplementationOnce(async () => {
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
})
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
mockGetPublishStatus.mockClear()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetPublishStatus).not.toHaveBeenCalledWith(
|
||||
'workflows/renamed.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('caches the prefill under the renamed path after publish', async () => {
|
||||
mockRenameWorkflow.mockImplementationOnce(async () => {
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
})
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCachePublishPrefill).toHaveBeenCalledWith(
|
||||
'workflows/renamed.json',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('applies prefill when workflow is already published with metadata', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
@@ -435,95 +282,4 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to cached prefill when the status fetch fails', async () => {
|
||||
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
|
||||
const cached = { description: 'cached' }
|
||||
mockGetCachedPrefill.mockReturnValue(cached)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).toHaveBeenCalledWith(cached)
|
||||
})
|
||||
|
||||
it('refetches prefill when the active workflow path changes (e.g. rename)', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
expect(mockGetPublishStatus).toHaveBeenLastCalledWith('workflows/test.json')
|
||||
|
||||
mockGetPublishStatus.mockClear()
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetPublishStatus).toHaveBeenCalledWith('workflows/renamed.json')
|
||||
})
|
||||
|
||||
it('does not refetch prefill when the active workflow path is unchanged', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
mockGetPublishStatus.mockClear()
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: true
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetPublishStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a stale prefill response after the workflow path changes', async () => {
|
||||
const stalePrefill = { description: 'stale' }
|
||||
let resolveStale: (value: unknown) => void = () => {}
|
||||
mockGetPublishStatus.mockImplementation((path: string) => {
|
||||
if (path === 'workflows/test.json') {
|
||||
return new Promise((resolve) => {
|
||||
resolveStale = resolve
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
isPublished: true,
|
||||
shareId: 'fresh',
|
||||
shareUrl: null,
|
||||
publishedAt: new Date(),
|
||||
prefill: { description: 'fresh' }
|
||||
})
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
resolveStale({
|
||||
isPublished: true,
|
||||
shareId: 'stale',
|
||||
shareUrl: null,
|
||||
publishedAt: new Date(),
|
||||
prefill: stalePrefill
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalledWith(stalePrefill)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publishing
|
||||
:is-update="isAlreadyPublished"
|
||||
:on-update-form-data="updateFormData"
|
||||
:on-go-next="goNext"
|
||||
:on-go-back="goBack"
|
||||
@@ -130,7 +129,6 @@ const {
|
||||
applyPrefill
|
||||
} = useComfyHubPublishWizard()
|
||||
const isPublishing = ref(false)
|
||||
const isAlreadyPublished = ref(false)
|
||||
const needsSave = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
@@ -207,27 +205,6 @@ function handleRequireProfile() {
|
||||
openProfileCreationStep()
|
||||
}
|
||||
|
||||
async function syncWorkflowName(): Promise<void> {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow || workflow.isTemporary) return
|
||||
|
||||
const desiredName = formData.value.name.trim().replace(/\.json$/i, '')
|
||||
const currentName = workflow.filename.replace(/\.json$/i, '')
|
||||
if (!desiredName || desiredName === currentName) return
|
||||
|
||||
const newPath = buildWorkflowPath(workflow.directory, desiredName)
|
||||
try {
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to rename workflow after publish:', error)
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('comfyHubPublish.renameFailedTitle'),
|
||||
detail: t('comfyHubPublish.renameFailedDescription')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish(): Promise<void> {
|
||||
if (isPublishing.value) {
|
||||
return
|
||||
@@ -236,7 +213,6 @@ async function handlePublish(): Promise<void> {
|
||||
isPublishing.value = true
|
||||
try {
|
||||
await submitToComfyHub(formData.value)
|
||||
await syncWorkflowName()
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path) {
|
||||
cachePublishPrefill(path, formData.value)
|
||||
@@ -266,15 +242,10 @@ function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
|
||||
|
||||
async function fetchPublishPrefill() {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) {
|
||||
isAlreadyPublished.value = false
|
||||
return
|
||||
}
|
||||
if (!path) return
|
||||
|
||||
try {
|
||||
const status = await shareService.getPublishStatus(path)
|
||||
if (workflowStore.activeWorkflow?.path !== path) return
|
||||
isAlreadyPublished.value = status.isPublished
|
||||
const prefill = status.isPublished
|
||||
? (status.prefill ?? getCachedPrefill(path))
|
||||
: getCachedPrefill(path)
|
||||
@@ -282,8 +253,6 @@ async function fetchPublishPrefill() {
|
||||
applyPrefill(prefill)
|
||||
}
|
||||
} catch (error) {
|
||||
if (workflowStore.activeWorkflow?.path !== path) return
|
||||
isAlreadyPublished.value = false
|
||||
console.warn('Failed to fetch publish prefill:', error)
|
||||
const cached = getCachedPrefill(path)
|
||||
if (cached) {
|
||||
@@ -298,15 +267,6 @@ onMounted(() => {
|
||||
void fetchPublishPrefill()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
(newPath, oldPath) => {
|
||||
if (isPublishing.value) return
|
||||
if (!newPath || newPath === oldPath) return
|
||||
void fetchPublishPrefill()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const image of formData.value.exampleImages) {
|
||||
if (image.file) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
|
||||
|
||||
function renderFooter(props: Record<string, unknown> = {}) {
|
||||
return render(ComfyHubPublishFooter, {
|
||||
props: { isFirstStep: false, isLastStep: true, ...props },
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubPublishFooter', () => {
|
||||
it('shows the publish label for a new workflow', () => {
|
||||
renderFooter({ isUpdate: false })
|
||||
expect(screen.getByText('comfyHubPublish.publishButton')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the update label when the workflow is already published', () => {
|
||||
renderFooter({ isUpdate: true })
|
||||
expect(screen.getByText('comfyHubPublish.updateButton')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -23,26 +23,13 @@
|
||||
:loading="isPublishing"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isUpdate ? 'icon-[lucide--refresh-cw]' : 'icon-[lucide--upload]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubPublish.updateButton')
|
||||
: $t('comfyHubPublish.publishButton')
|
||||
}}
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -50,7 +37,6 @@ defineProps<{
|
||||
isLastStep: boolean
|
||||
isPublishDisabled?: boolean
|
||||
isPublishing?: boolean
|
||||
isUpdate?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -52,11 +52,8 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
existingThumbnailType: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
|
||||
@@ -24,24 +24,14 @@
|
||||
>
|
||||
<ComfyHubThumbnailStep
|
||||
:thumbnail-type="formData.thumbnailType"
|
||||
:thumbnail-file="formData.thumbnailFile"
|
||||
:thumbnail-url="formData.thumbnailUrl"
|
||||
:existing-thumbnail-type="formData.existingThumbnailType"
|
||||
:comparison-before-file="formData.comparisonBeforeFile"
|
||||
:comparison-after-file="formData.comparisonAfterFile"
|
||||
:comparison-after-url="formData.comparisonAfterUrl"
|
||||
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
|
||||
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
|
||||
@update:thumbnail-url="onUpdateFormData({ thumbnailUrl: $event })"
|
||||
@update:comparison-before-file="
|
||||
onUpdateFormData({ comparisonBeforeFile: $event })
|
||||
"
|
||||
@update:comparison-after-file="
|
||||
onUpdateFormData({ comparisonAfterFile: $event })
|
||||
"
|
||||
@update:comparison-after-url="
|
||||
onUpdateFormData({ comparisonAfterUrl: $event })
|
||||
"
|
||||
/>
|
||||
<ComfyHubExamplesStep
|
||||
:example-images="formData.exampleImages"
|
||||
@@ -71,7 +61,6 @@
|
||||
:is-last-step
|
||||
:is-publish-disabled
|
||||
:is-publishing="isPublishInFlight"
|
||||
:is-update
|
||||
@back="onGoBack"
|
||||
@next="onGoNext"
|
||||
@publish="handlePublish"
|
||||
@@ -103,7 +92,6 @@ const {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isPublishing = false,
|
||||
isUpdate = false,
|
||||
onGoNext,
|
||||
onGoBack,
|
||||
onUpdateFormData,
|
||||
@@ -117,7 +105,6 @@ const {
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishing?: boolean
|
||||
isUpdate?: boolean
|
||||
onGoNext: () => void
|
||||
onGoBack: () => void
|
||||
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
|
||||
|
||||
function renderStep(
|
||||
props: Record<string, unknown> = {},
|
||||
callbacks: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
) {
|
||||
return render(ComfyHubThumbnailStep, {
|
||||
props: { thumbnailType: 'image' as ThumbnailType, ...props, ...callbacks },
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
ToggleGroup: {
|
||||
template:
|
||||
'<div><button data-testid="type-image" @click="$emit(\'update:modelValue\', \'image\')" /><button data-testid="type-video" @click="$emit(\'update:modelValue\', \'video\')" /><button data-testid="type-comparison" @click="$emit(\'update:modelValue\', \'imageComparison\')" /><slot /></div>'
|
||||
},
|
||||
ToggleGroupItem: { template: '<div><slot /></div>', props: ['value'] },
|
||||
Button: {
|
||||
template:
|
||||
'<button data-testid="clear-button" @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubThumbnailStep', () => {
|
||||
it('shows the existing image thumbnail on the image tab', () => {
|
||||
renderStep({
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
existingThumbnailType: 'image'
|
||||
})
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example.com/thumb.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show an existing image thumbnail on the video tab', () => {
|
||||
renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
existingThumbnailType: 'image'
|
||||
})
|
||||
|
||||
// The image must not leak into the video tab as a preview; the upload
|
||||
// prompt stays visible instead.
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(
|
||||
screen.getByText('comfyHubPublish.uploadPromptClickToBrowse')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the existing thumbnail URL when the type changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateThumbnailUrl = vi.fn()
|
||||
const onUpdateThumbnailFile = vi.fn()
|
||||
const onUpdateThumbnailType = vi.fn()
|
||||
renderStep(
|
||||
{
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
existingThumbnailType: 'image'
|
||||
},
|
||||
{
|
||||
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
|
||||
'onUpdate:thumbnailFile': onUpdateThumbnailFile,
|
||||
'onUpdate:thumbnailType': onUpdateThumbnailType
|
||||
}
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('type-video'))
|
||||
|
||||
expect(onUpdateThumbnailType).toHaveBeenCalledWith('video')
|
||||
// The uploaded file is cleared, but the existing URL is preserved so
|
||||
// toggling back restores the preview.
|
||||
expect(onUpdateThumbnailFile).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateThumbnailUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps restored comparison URLs when switching away from the comparison type', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateThumbnailUrl = vi.fn()
|
||||
const onUpdateComparisonAfterUrl = vi.fn()
|
||||
const onUpdateComparisonBeforeFile = vi.fn()
|
||||
const onUpdateComparisonAfterFile = vi.fn()
|
||||
const onUpdateThumbnailType = vi.fn()
|
||||
renderStep(
|
||||
{
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png',
|
||||
existingThumbnailType: 'imageComparison'
|
||||
},
|
||||
{
|
||||
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
|
||||
'onUpdate:comparisonAfterUrl': onUpdateComparisonAfterUrl,
|
||||
'onUpdate:comparisonBeforeFile': onUpdateComparisonBeforeFile,
|
||||
'onUpdate:comparisonAfterFile': onUpdateComparisonAfterFile,
|
||||
'onUpdate:thumbnailType': onUpdateThumbnailType
|
||||
}
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('type-image'))
|
||||
|
||||
expect(onUpdateThumbnailType).toHaveBeenCalledWith('image')
|
||||
// Comparison file inputs reset, but the restored before/after URLs stay
|
||||
// inert so switching back restores the previews.
|
||||
expect(onUpdateComparisonBeforeFile).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateComparisonAfterFile).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateThumbnailUrl).not.toHaveBeenCalled()
|
||||
expect(onUpdateComparisonAfterUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a restored GIF thumbnail as an image, not a video', () => {
|
||||
const { container } = renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/anim.gif',
|
||||
existingThumbnailType: 'video'
|
||||
})
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example.com/anim.gif'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
|
||||
expect(container.querySelector('video')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a restored mp4 thumbnail as a video', () => {
|
||||
const { container } = renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/clip.mp4',
|
||||
existingThumbnailType: 'video'
|
||||
})
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
|
||||
const video = container.querySelector('video')
|
||||
expect(video?.getAttribute('src')).toBe('https://cdn.example.com/clip.mp4')
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a restored extensionless video-mode thumbnail as a video', () => {
|
||||
const { container } = renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/assets/object-key',
|
||||
existingThumbnailType: 'video'
|
||||
})
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
|
||||
expect(container.querySelector('video')).not.toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a restored video-mode thumbnail with a query string as a video', () => {
|
||||
const { container } = renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/clip?token=abc123',
|
||||
existingThumbnailType: 'video'
|
||||
})
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
|
||||
expect(container.querySelector('video')).not.toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('restores both comparison images on the comparison tab', () => {
|
||||
const { container } = renderStep({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png',
|
||||
existingThumbnailType: 'imageComparison'
|
||||
})
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
|
||||
const srcs = Array.from(container.querySelectorAll('img')).map((el) =>
|
||||
el.getAttribute('src')
|
||||
)
|
||||
expect(srcs).toContain('https://cdn.example.com/before.png')
|
||||
expect(srcs).toContain('https://cdn.example.com/after.png')
|
||||
})
|
||||
|
||||
it('clears a restored image thumbnail when removed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateThumbnailFile = vi.fn()
|
||||
const onUpdateThumbnailUrl = vi.fn()
|
||||
renderStep(
|
||||
{
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
existingThumbnailType: 'image'
|
||||
},
|
||||
{
|
||||
'onUpdate:thumbnailFile': onUpdateThumbnailFile,
|
||||
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl
|
||||
}
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('clear-button'))
|
||||
|
||||
expect(onUpdateThumbnailFile).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateThumbnailUrl).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('clears both restored comparison images when removed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateThumbnailUrl = vi.fn()
|
||||
const onUpdateComparisonAfterUrl = vi.fn()
|
||||
const onUpdateComparisonBeforeFile = vi.fn()
|
||||
const onUpdateComparisonAfterFile = vi.fn()
|
||||
renderStep(
|
||||
{
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png',
|
||||
existingThumbnailType: 'imageComparison'
|
||||
},
|
||||
{
|
||||
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
|
||||
'onUpdate:comparisonAfterUrl': onUpdateComparisonAfterUrl,
|
||||
'onUpdate:comparisonBeforeFile': onUpdateComparisonBeforeFile,
|
||||
'onUpdate:comparisonAfterFile': onUpdateComparisonAfterFile
|
||||
}
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('clear-button'))
|
||||
|
||||
expect(onUpdateThumbnailUrl).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateComparisonAfterUrl).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateComparisonBeforeFile).toHaveBeenCalledWith(null)
|
||||
expect(onUpdateComparisonAfterFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
@@ -150,7 +150,7 @@
|
||||
/>
|
||||
<template v-if="thumbnailPreviewUrl">
|
||||
<video
|
||||
v-if="showVideoPreview"
|
||||
v-if="isVideoFile"
|
||||
:src="thumbnailPreviewUrl"
|
||||
:aria-label="$t('comfyHubPublish.videoPreview')"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
@@ -194,34 +194,18 @@ import {
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useDropZone, useObjectUrl } from '@vueuse/core'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
thumbnailType = 'image',
|
||||
thumbnailFile = null,
|
||||
thumbnailUrl = null,
|
||||
existingThumbnailType = null,
|
||||
comparisonBeforeFile = null,
|
||||
comparisonAfterFile = null,
|
||||
comparisonAfterUrl = null
|
||||
} = defineProps<{
|
||||
const { thumbnailType = 'image' } = defineProps<{
|
||||
thumbnailType?: ThumbnailType
|
||||
thumbnailFile?: File | null
|
||||
thumbnailUrl?: string | null
|
||||
existingThumbnailType?: ThumbnailType | null
|
||||
comparisonBeforeFile?: File | null
|
||||
comparisonAfterFile?: File | null
|
||||
comparisonAfterUrl?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:thumbnailType': [value: ThumbnailType]
|
||||
'update:thumbnailFile': [value: File | null]
|
||||
'update:thumbnailUrl': [value: string | null]
|
||||
'update:comparisonBeforeFile': [value: File | null]
|
||||
'update:comparisonAfterFile': [value: File | null]
|
||||
'update:comparisonAfterUrl': [value: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -232,7 +216,8 @@ function isThumbnailType(value: string): value is ThumbnailType {
|
||||
|
||||
function handleThumbnailTypeChange(value: unknown) {
|
||||
if (typeof value === 'string' && isThumbnailType(value)) {
|
||||
// Keep existing URLs; they stay inert until the type matches again (see existingSingleUrl).
|
||||
comparisonBeforeFile.value = null
|
||||
comparisonAfterFile.value = null
|
||||
emit('update:thumbnailFile', null)
|
||||
emit('update:comparisonBeforeFile', null)
|
||||
emit('update:comparisonAfterFile', null)
|
||||
@@ -271,59 +256,30 @@ const thumbnailOptions = [
|
||||
icon: 'icon-[lucide--diff]'
|
||||
}
|
||||
]
|
||||
const existingSingleUrl = computed(() =>
|
||||
existingThumbnailType === thumbnailType
|
||||
? (thumbnailUrl ?? undefined)
|
||||
: undefined
|
||||
)
|
||||
// For imageComparison, thumbnailUrl is the "before" image (the backend's primary thumbnail_url).
|
||||
const existingComparisonUrls = computed(() =>
|
||||
existingThumbnailType === 'imageComparison'
|
||||
? {
|
||||
before: thumbnailUrl ?? undefined,
|
||||
after: comparisonAfterUrl ?? undefined
|
||||
}
|
||||
: { before: undefined, after: undefined }
|
||||
)
|
||||
|
||||
const thumbnailFileUrl = useObjectUrl(() => thumbnailFile ?? undefined)
|
||||
const thumbnailPreviewUrl = computed(
|
||||
() => thumbnailFileUrl.value ?? existingSingleUrl.value
|
||||
)
|
||||
|
||||
function isAnimatedImageUrl(url: string): boolean {
|
||||
return /\.(gif|webp)(\?|#|$)/i.test(url)
|
||||
}
|
||||
const showVideoPreview = computed(() => {
|
||||
if (thumbnailFile) return thumbnailFile.type.startsWith('video/')
|
||||
return thumbnailType === 'video' && !!existingSingleUrl.value
|
||||
? !isAnimatedImageUrl(existingSingleUrl.value)
|
||||
: false
|
||||
})
|
||||
const thumbnailFile = shallowRef<File | null>(null)
|
||||
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
|
||||
const isVideoFile = ref(false)
|
||||
|
||||
function setThumbnailPreview(file: File) {
|
||||
const maxSize = file.type.startsWith('video/')
|
||||
? MAX_VIDEO_SIZE_MB
|
||||
: MAX_IMAGE_SIZE_MB
|
||||
if (isFileTooLarge(file, maxSize)) return
|
||||
thumbnailFile.value = file
|
||||
isVideoFile.value = file.type.startsWith('video/')
|
||||
emit('update:thumbnailFile', file)
|
||||
emit('update:thumbnailUrl', null)
|
||||
}
|
||||
|
||||
const comparisonBeforeFileUrl = useObjectUrl(
|
||||
() => comparisonBeforeFile ?? undefined
|
||||
)
|
||||
const comparisonAfterFileUrl = useObjectUrl(
|
||||
() => comparisonAfterFile ?? undefined
|
||||
)
|
||||
const comparisonPreviewUrls = computed(() => ({
|
||||
before: comparisonBeforeFileUrl.value ?? existingComparisonUrls.value.before,
|
||||
after: comparisonAfterFileUrl.value ?? existingComparisonUrls.value.after
|
||||
}))
|
||||
const comparisonBeforeFile = shallowRef<File | null>(null)
|
||||
const comparisonAfterFile = shallowRef<File | null>(null)
|
||||
const comparisonPreviewUrls = reactive({
|
||||
before: useObjectUrl(comparisonBeforeFile),
|
||||
after: useObjectUrl(comparisonAfterFile)
|
||||
})
|
||||
|
||||
const hasBothComparisonImages = computed(
|
||||
() =>
|
||||
!!(comparisonPreviewUrls.value.before && comparisonPreviewUrls.value.after)
|
||||
() => !!(comparisonPreviewUrls.before && comparisonPreviewUrls.after)
|
||||
)
|
||||
|
||||
const comparisonPreviewRef = ref<HTMLElement | null>(null)
|
||||
@@ -331,24 +287,22 @@ const previewSliderPosition = useSliderFromMouse(comparisonPreviewRef)
|
||||
|
||||
const hasThumbnailContent = computed(() => {
|
||||
if (thumbnailType === 'imageComparison') {
|
||||
return !!(
|
||||
comparisonPreviewUrls.value.before || comparisonPreviewUrls.value.after
|
||||
)
|
||||
return !!(comparisonPreviewUrls.before || comparisonPreviewUrls.after)
|
||||
}
|
||||
return !!thumbnailPreviewUrl.value
|
||||
})
|
||||
|
||||
function clearAllPreviews() {
|
||||
if (thumbnailType === 'imageComparison') {
|
||||
comparisonBeforeFile.value = null
|
||||
comparisonAfterFile.value = null
|
||||
emit('update:comparisonBeforeFile', null)
|
||||
emit('update:comparisonAfterFile', null)
|
||||
emit('update:thumbnailUrl', null)
|
||||
emit('update:comparisonAfterUrl', null)
|
||||
return
|
||||
}
|
||||
|
||||
thumbnailFile.value = null
|
||||
emit('update:thumbnailFile', null)
|
||||
emit('update:thumbnailUrl', null)
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
@@ -395,15 +349,19 @@ const comparisonSlots = [
|
||||
}
|
||||
]
|
||||
|
||||
const comparisonFiles: Record<ComparisonSlot, typeof comparisonBeforeFile> = {
|
||||
before: comparisonBeforeFile,
|
||||
after: comparisonAfterFile
|
||||
}
|
||||
|
||||
function setComparisonPreview(file: File, slot: ComparisonSlot) {
|
||||
if (isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
|
||||
comparisonFiles[slot].value = file
|
||||
if (slot === 'before') {
|
||||
emit('update:comparisonBeforeFile', file)
|
||||
emit('update:thumbnailUrl', null)
|
||||
return
|
||||
}
|
||||
emit('update:comparisonAfterFile', file)
|
||||
emit('update:comparisonAfterUrl', null)
|
||||
}
|
||||
|
||||
function handleComparisonSelect(event: Event, slot: ComparisonSlot) {
|
||||
|
||||
@@ -58,11 +58,8 @@ function createFormData(
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
existingThumbnailType: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {},
|
||||
@@ -150,110 +147,6 @@ describe('useComfyHubPublishSubmission', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the existing thumbnail URL when no new file is attached', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png',
|
||||
existingThumbnailType: 'image'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'https://cdn.example.com/existing-thumb.png'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the existing comparison URLs when no new files are attached', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png',
|
||||
existingThumbnailType: 'imageComparison'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'https://cdn.example.com/before.png',
|
||||
thumbnailComparisonTokenOrUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not submit an existing thumbnail URL after the type is switched away', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'video',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: 'https://cdn.example.com/existing-image.png',
|
||||
existingThumbnailType: 'image'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailType: 'video',
|
||||
thumbnailTokenOrUrl: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers a newly uploaded thumbnail file over the existing URL', async () => {
|
||||
const thumbnailFile = new File(['thumbnail'], 'new-thumb.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile,
|
||||
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png',
|
||||
existingThumbnailType: 'image'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'token-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers a newly uploaded comparison-after file over the existing URL', async () => {
|
||||
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'imageComparison',
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: afterFile,
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png',
|
||||
existingThumbnailType: 'imageComparison'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailComparisonTokenOrUrl: 'token-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads all example images', async () => {
|
||||
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
|
||||
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })
|
||||
|
||||
@@ -74,21 +74,14 @@ export function useComfyHubPublishSubmission() {
|
||||
await workflowShareService.getShareableAssets()
|
||||
)
|
||||
|
||||
const keepsExistingThumbnail =
|
||||
formData.existingThumbnailType === formData.thumbnailType
|
||||
const thumbnailFile = resolveThumbnailFile(formData)
|
||||
const thumbnailTokenOrUrl = thumbnailFile
|
||||
? await uploadFileAndGetToken(thumbnailFile)
|
||||
: keepsExistingThumbnail
|
||||
? (formData.thumbnailUrl ?? undefined)
|
||||
: undefined
|
||||
: undefined
|
||||
const thumbnailComparisonTokenOrUrl =
|
||||
formData.thumbnailType === 'imageComparison'
|
||||
? formData.comparisonAfterFile
|
||||
? await uploadFileAndGetToken(formData.comparisonAfterFile)
|
||||
: keepsExistingThumbnail
|
||||
? (formData.comparisonAfterUrl ?? undefined)
|
||||
: undefined
|
||||
formData.thumbnailType === 'imageComparison' &&
|
||||
formData.comparisonAfterFile
|
||||
? await uploadFileAndGetToken(formData.comparisonAfterFile)
|
||||
: undefined
|
||||
|
||||
const sampleImageTokensOrUrls =
|
||||
|
||||
@@ -142,60 +142,4 @@ describe('useComfyHubPublishWizard', () => {
|
||||
expect(currentStep.value).toBe('finish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPrefill', () => {
|
||||
it('restores the existing thumbnail URL into the form', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
|
||||
expect(formData.value.thumbnailUrl).toBe(
|
||||
'https://cdn.example.com/thumb.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the comparison-after URL into the form', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
applyPrefill({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
expect(formData.value.thumbnailUrl).toBe(
|
||||
'https://cdn.example.com/before.png'
|
||||
)
|
||||
expect(formData.value.comparisonAfterUrl).toBe(
|
||||
'https://cdn.example.com/after.png'
|
||||
)
|
||||
expect(formData.value.existingThumbnailType).toBe('imageComparison')
|
||||
})
|
||||
|
||||
it('does not overwrite a freshly attached thumbnail file with the prefill URL', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
const file = new File(['x'], 'thumb.png', { type: 'image/png' })
|
||||
formData.value = { ...formData.value, thumbnailFile: file }
|
||||
|
||||
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
|
||||
|
||||
expect(formData.value.thumbnailFile?.name).toBe('thumb.png')
|
||||
expect(formData.value.thumbnailUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('restores description, tags, and sample images alongside the thumbnail', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
applyPrefill({
|
||||
description: 'Restored description',
|
||||
tags: ['art'],
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
sampleImageUrls: ['https://cdn.example.com/sample.png']
|
||||
})
|
||||
expect(formData.value.description).toBe('Restored description')
|
||||
expect(formData.value.tags).toEqual(['art'])
|
||||
expect(formData.value.thumbnailUrl).toBe(
|
||||
'https://cdn.example.com/thumb.png'
|
||||
)
|
||||
expect(formData.value.exampleImages).toHaveLength(1)
|
||||
expect(formData.value.exampleImages[0].url).toBe(
|
||||
'https://cdn.example.com/sample.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,11 +32,8 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
existingThumbnailType: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
@@ -54,8 +51,6 @@ function extractPrefillFromFormData(
|
||||
description: formData.description || undefined,
|
||||
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
|
||||
thumbnailType: formData.thumbnailType,
|
||||
thumbnailUrl: formData.thumbnailUrl ?? undefined,
|
||||
thumbnailComparisonUrl: formData.comparisonAfterUrl ?? undefined,
|
||||
sampleImageUrls: formData.exampleImages
|
||||
.map((img) => img.url)
|
||||
.filter((url) => !url.startsWith('blob:'))
|
||||
@@ -100,13 +95,6 @@ export function useComfyHubPublishWizard() {
|
||||
function applyPrefill(prefill: PublishPrefill) {
|
||||
const defaults = createDefaultFormData()
|
||||
const current = formData.value
|
||||
const hasThumbnail = !!(current.thumbnailFile || current.thumbnailUrl)
|
||||
const hasComparisonAfter = !!(
|
||||
current.comparisonAfterFile || current.comparisonAfterUrl
|
||||
)
|
||||
const restoredThumbnailUrl = hasThumbnail
|
||||
? current.thumbnailUrl
|
||||
: (prefill.thumbnailUrl ?? current.thumbnailUrl)
|
||||
formData.value = {
|
||||
...current,
|
||||
description:
|
||||
@@ -121,14 +109,6 @@ export function useComfyHubPublishWizard() {
|
||||
current.thumbnailType === defaults.thumbnailType
|
||||
? (prefill.thumbnailType ?? current.thumbnailType)
|
||||
: current.thumbnailType,
|
||||
thumbnailUrl: restoredThumbnailUrl,
|
||||
existingThumbnailType:
|
||||
restoredThumbnailUrl && !current.thumbnailFile
|
||||
? (prefill.thumbnailType ?? current.existingThumbnailType)
|
||||
: current.existingThumbnailType,
|
||||
comparisonAfterUrl: hasComparisonAfter
|
||||
? current.comparisonAfterUrl
|
||||
: (prefill.thumbnailComparisonUrl ?? current.comparisonAfterUrl),
|
||||
exampleImages:
|
||||
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
|
||||
? createExampleImagesFromUrls(prefill.sampleImageUrls)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
zHubWorkflowPrefillResponse,
|
||||
zSharedWorkflowResponse
|
||||
} from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
|
||||
function makePayload(name: string) {
|
||||
return {
|
||||
@@ -55,18 +52,3 @@ describe('zSharedWorkflowResponse name sanitization', () => {
|
||||
expect(result.name).toBe('spaced name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zHubWorkflowPrefillResponse tag tolerance', () => {
|
||||
it('drops a malformed tag without discarding the rest of the prefill', () => {
|
||||
const result = zHubWorkflowPrefillResponse.safeParse({
|
||||
description: 'A cool workflow',
|
||||
thumbnail_url: 'https://cdn.example.com/thumb.png',
|
||||
tags: [{ name: 'art', display_name: 'Art' }, 'rawtag', { name: 'broken' }]
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.tags).toEqual(['Art', 'rawtag'])
|
||||
expect(result.data?.description).toBe('A cool workflow')
|
||||
expect(result.data?.thumbnail_url).toBe('https://cdn.example.com/thumb.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,18 +10,9 @@ export const zPublishRecordResponse = z.object({
|
||||
assets: z.array(zAssetInfo).optional()
|
||||
})
|
||||
|
||||
const zPrefillTag = z
|
||||
.object({ name: z.string(), display_name: z.string() })
|
||||
.transform((label) => label.display_name)
|
||||
.or(z.string())
|
||||
|
||||
const zPrefillTagList = z
|
||||
.array(zPrefillTag.optional().catch(undefined))
|
||||
.transform((tags) => tags.filter((tag): tag is string => tag !== undefined))
|
||||
|
||||
export const zHubWorkflowPrefillResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
tags: zPrefillTagList.nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
sample_image_urls: z.array(z.string()).nullish(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
|
||||
thumbnail_url: z.string().nullish(),
|
||||
|
||||
@@ -177,10 +177,7 @@ describe(useWorkflowShareService, () => {
|
||||
if (path === '/hub/workflows/wf-prefill') {
|
||||
return mockJsonResponse({
|
||||
description: 'A cool workflow',
|
||||
tags: [
|
||||
{ name: 'art', display_name: 'Art' },
|
||||
{ name: 'upscale', display_name: 'Upscale' }
|
||||
],
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnail_type: 'image_comparison',
|
||||
sample_image_urls: ['https://example.com/img1.png']
|
||||
})
|
||||
@@ -195,7 +192,7 @@ describe(useWorkflowShareService, () => {
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toEqual({
|
||||
description: 'A cool workflow',
|
||||
tags: ['Art', 'Upscale'],
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'imageComparison',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
})
|
||||
|
||||
@@ -45,8 +45,6 @@ interface PrefillMetadataFields {
|
||||
description?: string | null
|
||||
tags?: string[] | null
|
||||
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
|
||||
thumbnail_url?: string | null
|
||||
thumbnail_comparison_url?: string | null
|
||||
sample_image_urls?: string[] | null
|
||||
}
|
||||
|
||||
@@ -54,29 +52,18 @@ function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
|
||||
const description = fields.description ?? undefined
|
||||
const tags = fields.tags ?? undefined
|
||||
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
|
||||
const thumbnailUrl = fields.thumbnail_url ?? undefined
|
||||
const thumbnailComparisonUrl = fields.thumbnail_comparison_url ?? undefined
|
||||
const sampleImageUrls = fields.sample_image_urls ?? undefined
|
||||
|
||||
if (
|
||||
!description &&
|
||||
!tags?.length &&
|
||||
!thumbnailType &&
|
||||
!thumbnailUrl &&
|
||||
!thumbnailComparisonUrl &&
|
||||
!sampleImageUrls?.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
tags,
|
||||
thumbnailType,
|
||||
thumbnailUrl,
|
||||
thumbnailComparisonUrl,
|
||||
sampleImageUrls
|
||||
}
|
||||
return { description, tags, thumbnailType, sampleImageUrls }
|
||||
}
|
||||
|
||||
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {
|
||||
|
||||
@@ -14,11 +14,8 @@ export interface ComfyHubPublishFormData {
|
||||
customNodes: string[]
|
||||
thumbnailType: ThumbnailType
|
||||
thumbnailFile: File | null
|
||||
thumbnailUrl: string | null
|
||||
existingThumbnailType: ThumbnailType | null
|
||||
comparisonBeforeFile: File | null
|
||||
comparisonAfterFile: File | null
|
||||
comparisonAfterUrl: string | null
|
||||
exampleImages: ExampleImage[]
|
||||
tutorialUrl: string
|
||||
metadata: Record<string, unknown>
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface PublishPrefill {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
thumbnailType?: ThumbnailType
|
||||
thumbnailUrl?: string
|
||||
thumbnailComparisonUrl?: string
|
||||
sampleImageUrls?: string[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user