Compare commits

...

5 Commits

Author SHA1 Message Date
huang47
51ccc2d581 test: verify drawing state reset after missing-context error 2026-07-02 18:26:43 -07:00
huang47
3926c60ac4 test: strengthen null-context painter assertion and reset root graph nodes between tests 2026-07-02 18:16:51 -07:00
huang47
fae7f005db test: address second-round review findings
Assert toolbox visibility alongside cleared coordinates, fix
tautological dead-zone assertion, exercise the real no-blob branch in
painter serialization, stub toBlob in the canvas helper, restore the
shared ds mock after mutation.
2026-07-02 18:06:11 -07:00
huang47
aaa861694e test: address review findings on composable mock refactor
Derive brush-drawing MockStore from the real store type, restore
console/performance spies via afterEach, make dominant-axis suppression
and size-clamp tests assert the real behavior, exercise the unmount
guard in the painter restore test, dedupe FakeImage and error-path
setup, split combined rename negative scenarios, use fromPartial for
partial DOM mocks.
2026-07-02 17:36:52 -07:00
huang47
e30d6cfbbc test: rework composable test mocks to hoisted mutable fixtures
Split out of #13364: these files needed their existing vi.mock
scaffolding rewritten (static literals -> hoisted mutable mocks,
some whole describe blocks replaced) to support the new coverage,
rather than just appending tests. Isolated here so #13364 stays
purely additive.
2026-07-02 14:35:25 -07:00
8 changed files with 3591 additions and 617 deletions

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h, markRaw, ref } from 'vue'
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
@@ -12,19 +12,35 @@ 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', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: { value: false }
})
}))
vi.mock('@/composables/useVueFeatureFlags', async () => {
const { ref } = await import('vue')
const shouldRenderVueNodes = ref(false)
mockFeatureFlags.refs = {
shouldRenderVueNodes
}
return {
useVueFeatureFlags: () => ({
shouldRenderVueNodes
})
}
})
describe('useSelectionToolboxPosition', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
@@ -32,28 +48,39 @@ describe('useSelectionToolboxPosition', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
layoutStore.initializeFromLiteGraph([])
layoutStore.isDraggingVueNodes.value = false
if (mockFeatureFlags.refs) {
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
}
})
function renderToolboxForSelection(item: Positionable) {
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
},
selectedItems: new Set([item]),
selectedItems: new Set(items),
state: {
draggingItems: false,
selectionChanged: true
selectionChanged: true,
...state
}
} 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
useSelectionToolboxPosition(toolboxRef)
;({ visible } = useSelectionToolboxPosition(toolboxRef))
return () => h('div')
}
})
@@ -61,7 +88,28 @@ describe('useSelectionToolboxPosition', () => {
const wrapper = render(TestHarness)
if (!toolbox) throw new Error('Toolbox element was not initialized')
return { toolbox, unmount: wrapper.unmount }
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)
}
it('positions groups from their unchanged bounds', () => {
@@ -69,7 +117,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()
@@ -81,11 +129,223 @@ 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()
})
})

View File

@@ -10,30 +10,43 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
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 }
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
useSelectedNodeActions: () => actions
}))
const i18n = createI18n({
@@ -69,9 +82,29 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
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', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -97,4 +130,109 @@ describe('useNodeMenuOptions.getBypassOption', () => {
])
).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')
})
})

View File

@@ -4,34 +4,45 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
selectedItems: [] as unknown[]
selectedItems: [] as unknown[],
getSelectedNodes: vi.fn((): unknown[] => []),
getCanvas: vi.fn(),
updateSelectedItems: vi.fn(),
revokeSubgraphPreviews: vi.fn(),
activeWorkflow: null as null | {
changeTracker?: {
captureCanvasState: () => void
}
}
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
getSelectedNodes: vi.fn(() => [])
getSelectedNodes: mocks.getSelectedNodes
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: vi.fn(),
getCanvas: mocks.getCanvas,
get selectedItems() {
return mocks.selectedItems
},
updateSelectedItems: vi.fn()
updateSelectedItems: mocks.updateSelectedItems
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
get activeWorkflow() {
return mocks.activeWorkflow
}
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokeSubgraphPreviews: vi.fn()
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
})
}))
@@ -50,10 +61,36 @@ 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 () => {
@@ -103,4 +140,126 @@ 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

View File

@@ -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 } from '@/schemas/apiSchema'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
mockFetchApi: vi.fn(),
@@ -11,22 +11,41 @@ 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 }
opts: {
onDrop: typeof capturedDragOnDrop
onResultItemDrop: typeof capturedResultItemDrop
}
) => {
capturedDragOnDrop = opts.onDrop
capturedResultItemDrop = opts.onResultItemDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
useNodeFileInput: (
_node: LGraphNode,
opts: { onSelect: typeof capturedFileInputOnSelect }
) => {
capturedFileInputOnSelect = opts.onSelect
return { openFileSelection: mockOpenFileSelection }
}
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: vi.fn()
useNodePaste: (
_node: LGraphNode,
opts: { onPaste: typeof capturedPasteOnPaste }
) => {
capturedPasteOnPaste = opts.onPaste
}
}))
vi.mock('@/i18n', () => ({
@@ -78,6 +97,26 @@ 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()
@@ -86,13 +125,7 @@ describe('useNodeImageUpload', () => {
onUploadStart = vi.fn()
onUploadError = vi.fn()
const { useNodeImageUpload } = await import('./useNodeImageUpload')
useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
folder: 'input'
})
await mountImageUpload()
})
it.for([
@@ -180,4 +213,60 @@ 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')
})
})

View File

@@ -1,12 +1,15 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, 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'
@@ -27,10 +30,12 @@ vi.mock('@vueuse/core', () => ({
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
StrokeProcessor: vi.fn(function StrokeProcessor() {
return {
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}
})
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -42,14 +47,15 @@ vi.mock('@/platform/updates/common/toastStore', () => {
return { useToastStore: () => store }
})
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeImageUrls: vi.fn(() => undefined as string[] | undefined),
nodeOutputs: {},
nodePreviewImages: {}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/scripts/api', () => ({
api: {
@@ -61,7 +67,7 @@ vi.mock('@/scripts/api', () => ({
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn(() => null)
const mockGetInputNode = vi.fn((): LGraphNode | null => null)
vi.mock('@/scripts/app', () => ({
app: {
@@ -93,9 +99,6 @@ 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 = ''
@@ -119,11 +122,140 @@ function mountPainter(
}
})
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
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
}
describe('usePainter', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
@@ -133,6 +265,7 @@ describe('usePainter', () => {
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue(undefined)
})
describe('syncCanvasSizeFromWidgets', () => {
@@ -151,6 +284,25 @@ 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', () => {
@@ -226,6 +378,18 @@ 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', () => {
@@ -241,6 +405,16 @@ 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', () => {
@@ -258,6 +432,34 @@ 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', () => {
@@ -282,6 +484,20 @@ 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', () => {
@@ -299,6 +515,17 @@ 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', () => {
@@ -365,24 +592,13 @@ 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 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 { maskWidget } = await mountPainterWithMaskCanvas()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(fetchApiMock).toHaveBeenCalledWith(
@@ -399,33 +615,81 @@ 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 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 { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/missing 'name'/)
})
it('throws when the upload response body is not valid JSON', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
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 () => {
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => {
@@ -433,21 +697,62 @@ describe('usePainter', () => {
}
} as unknown as Response)
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 { maskWidget } = await mountPainterWithMaskCanvas()
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)
@@ -498,6 +803,83 @@ 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', () => {
@@ -506,6 +888,36 @@ 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', () => {
@@ -547,6 +959,184 @@ 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', () => {
@@ -581,5 +1171,32 @@ 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

View File

@@ -1,24 +1,57 @@
import { fromAny } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { afterEach, beforeEach, 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: () => ({
playing: ref(false),
currentTime: ref(0),
duration: ref(0)
})
useMediaControls: () =>
mockMediaControls.values.shift() ?? {
playing: ref(false),
currentTime: ref(0),
duration: ref(0),
volume: ref(1),
muted: ref(false)
}
}
})
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()
@@ -50,6 +83,21 @@ 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 })
@@ -65,6 +113,56 @@ 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({
@@ -108,6 +206,91 @@ 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 })