mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 17:54:14 +00:00
## Summary - Replace 83 `as unknown as` double casts with safer alternatives across 33 files - Use `as Partial<X> as X` pattern where TypeScript allows it - Create/reuse factory functions from `litegraphTestUtils.ts` for mock objects - Widen `getWorkflowDataFromFile` return type to include `ComfyMetadata` directly - Reduce total `as unknown as` count from ~153 to 71 The remaining 71 occurrences are genuinely necessary due to cross-schema casts, generic variance, missing index signatures, Float64Array-to-tuple conversions, and DOM type incompatibilities. ## Test plan - [x] `pnpm typecheck` passes - [x] `pnpm lint` passes - [x] All affected unit tests pass ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9107-fix-replace-as-unknown-as-casts-with-safer-patterns-3106d73d3650815cb5bcd613ad635bd7) by [Unito](https://www.unito.io)
387 lines
10 KiB
TypeScript
387 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import type {
|
|
LGraph,
|
|
LGraphCanvas,
|
|
LGraphNode
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
|
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
|
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
|
import {
|
|
compressWidgetInputSlots,
|
|
createNode,
|
|
isAnimatedOutput,
|
|
isVideoOutput,
|
|
migrateWidgetsValues
|
|
} from '@/utils/litegraphUtil'
|
|
|
|
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
|
LiteGraph: {
|
|
createNode: vi.fn()
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/platform/updates/common/toastStore', () => ({
|
|
useToastStore: vi.fn(() => ({
|
|
addAlert: vi.fn(),
|
|
add: vi.fn(),
|
|
remove: vi.fn()
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/i18n', () => ({
|
|
t: vi.fn((key: string) => key)
|
|
}))
|
|
|
|
function createMockCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
|
|
const mockGraph = {
|
|
add: vi.fn((node) => node),
|
|
change: vi.fn()
|
|
} as Partial<LGraph> as LGraph
|
|
const mockCanvas: Partial<LGraphCanvas> = {
|
|
graph_mouse: [100, 200],
|
|
graph: mockGraph,
|
|
...overrides
|
|
}
|
|
return mockCanvas as LGraphCanvas
|
|
}
|
|
|
|
describe('createNode', () => {
|
|
beforeEach(vi.clearAllMocks)
|
|
|
|
it('should create a node successfully', async () => {
|
|
const mockNode = { pos: [0, 0] }
|
|
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
|
|
|
|
const mockCanvas = createMockCanvas()
|
|
const result = await createNode(mockCanvas, 'LoadImage')
|
|
|
|
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
|
expect(mockNode.pos).toEqual([100, 200])
|
|
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
|
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
|
expect(result).toBe(mockNode)
|
|
})
|
|
|
|
it('should return null when name is empty', async () => {
|
|
const mockCanvas = createMockCanvas()
|
|
const result = await createNode(mockCanvas, '')
|
|
|
|
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('should handle graph being null', async () => {
|
|
const mockNode = { pos: [0, 0] }
|
|
const mockCanvas = createMockCanvas({ graph: null })
|
|
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
|
|
|
|
const result = await createNode(mockCanvas, 'LoadImage')
|
|
|
|
expect(mockNode.pos).toEqual([0, 0])
|
|
expect(result).toBeNull()
|
|
})
|
|
it('should set position based on canvas graph_mouse', async () => {
|
|
const mockCanvas = createMockCanvas({ graph_mouse: [250, 350] })
|
|
const mockNode = { pos: [0, 0] }
|
|
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
|
|
|
|
await createNode(mockCanvas, 'LoadAudio')
|
|
|
|
expect(mockNode.pos).toEqual([250, 350])
|
|
})
|
|
})
|
|
|
|
describe('migrateWidgetsValues', () => {
|
|
it('should remove widget values for forceInput inputs', () => {
|
|
const inputDefs: Record<string, InputSpec> = {
|
|
normalInput: {
|
|
type: 'INT',
|
|
name: 'normalInput'
|
|
},
|
|
forceInputField: {
|
|
type: 'STRING',
|
|
name: 'forceInputField',
|
|
forceInput: true
|
|
},
|
|
anotherNormal: {
|
|
type: 'FLOAT',
|
|
name: 'anotherNormal'
|
|
}
|
|
}
|
|
|
|
const widgets = [
|
|
{ name: 'normalInput', type: 'number' },
|
|
{ name: 'anotherNormal', type: 'number' }
|
|
] as Partial<IWidget>[] as IWidget[]
|
|
|
|
const widgetValues = [42, 'dummy value', 3.14]
|
|
|
|
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
|
expect(result).toEqual([42, 3.14])
|
|
})
|
|
|
|
it('should return original values if lengths do not match', () => {
|
|
const inputDefs: Record<string, InputSpec> = {
|
|
input1: {
|
|
type: 'INT',
|
|
name: 'input1',
|
|
forceInput: true
|
|
}
|
|
}
|
|
|
|
const widgets: IWidget[] = []
|
|
const widgetValues = [42, 'extra value']
|
|
|
|
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
|
expect(result).toEqual(widgetValues)
|
|
})
|
|
|
|
it('should handle empty widgets and values', () => {
|
|
const inputDefs: Record<string, InputSpec> = {}
|
|
const widgets: IWidget[] = []
|
|
const widgetValues: unknown[] = []
|
|
|
|
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should preserve order of non-forceInput widget values', () => {
|
|
const inputDefs: Record<string, InputSpec> = {
|
|
first: {
|
|
type: 'INT',
|
|
name: 'first'
|
|
},
|
|
forced: {
|
|
type: 'STRING',
|
|
name: 'forced',
|
|
forceInput: true
|
|
},
|
|
last: {
|
|
type: 'FLOAT',
|
|
name: 'last'
|
|
}
|
|
}
|
|
|
|
const widgets = [
|
|
{ name: 'first', type: 'number' },
|
|
{ name: 'last', type: 'number' }
|
|
] as Partial<IWidget>[] as IWidget[]
|
|
|
|
const widgetValues = ['first value', 'dummy', 'last value']
|
|
|
|
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
|
expect(result).toEqual(['first value', 'last value'])
|
|
})
|
|
it('should correctly handle seed with unexpected value', () => {
|
|
const inputDefs: Record<string, InputSpec> = {
|
|
normalInput: {
|
|
type: 'INT',
|
|
name: 'normalInput',
|
|
control_after_generate: true
|
|
},
|
|
forceInputField: {
|
|
type: 'STRING',
|
|
name: 'forceInputField',
|
|
forceInput: true
|
|
}
|
|
}
|
|
|
|
const widgets = [
|
|
{ name: 'normalInput', type: 'number' },
|
|
{ name: 'control_after_generate', type: 'string' }
|
|
] as Partial<IWidget>[] as IWidget[]
|
|
|
|
const widgetValues = [42, 'fixed', 'unexpected widget value']
|
|
|
|
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
|
expect(result).toEqual([42, 'fixed'])
|
|
})
|
|
})
|
|
|
|
function createOutput(
|
|
overrides: Partial<ExecutedWsMessage['output']> = {}
|
|
): ExecutedWsMessage['output'] {
|
|
return { ...overrides }
|
|
}
|
|
|
|
describe('isAnimatedOutput', () => {
|
|
it('returns false for undefined output', () => {
|
|
expect(isAnimatedOutput(undefined)).toBe(false)
|
|
})
|
|
|
|
it('returns false when animated array is missing', () => {
|
|
expect(isAnimatedOutput(createOutput())).toBe(false)
|
|
})
|
|
|
|
it('returns false when all animated values are false', () => {
|
|
expect(isAnimatedOutput(createOutput({ animated: [false, false] }))).toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
it('returns true when any animated value is true', () => {
|
|
expect(isAnimatedOutput(createOutput({ animated: [false, true] }))).toBe(
|
|
true
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('isVideoOutput', () => {
|
|
it('returns false for non-animated output', () => {
|
|
expect(
|
|
isVideoOutput(
|
|
createOutput({
|
|
animated: [false],
|
|
images: [{ filename: 'video.webm' }]
|
|
})
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('returns false for animated webp output', () => {
|
|
expect(
|
|
isVideoOutput(
|
|
createOutput({
|
|
animated: [true],
|
|
images: [{ filename: 'anim.webp' }]
|
|
})
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('returns false for animated png output', () => {
|
|
expect(
|
|
isVideoOutput(
|
|
createOutput({
|
|
animated: [true],
|
|
images: [{ filename: 'anim.png' }]
|
|
})
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('returns true for animated webm output', () => {
|
|
expect(
|
|
isVideoOutput(
|
|
createOutput({
|
|
animated: [true],
|
|
images: [{ filename: 'output.webm' }]
|
|
})
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('returns true for animated mp4 output', () => {
|
|
expect(
|
|
isVideoOutput(
|
|
createOutput({
|
|
animated: [true],
|
|
images: [{ filename: 'output.mp4' }]
|
|
})
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('returns true for animated output with no images array', () => {
|
|
expect(isVideoOutput(createOutput({ animated: [true] }))).toBe(true)
|
|
})
|
|
|
|
it('does not false-positive on filenames containing webp as substring', () => {
|
|
expect(
|
|
isVideoOutput(
|
|
createOutput({
|
|
animated: [true],
|
|
images: [{ filename: 'my_webp_file.mp4' }]
|
|
})
|
|
)
|
|
).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('compressWidgetInputSlots', () => {
|
|
it('should remove unconnected widget input slots', () => {
|
|
// Using partial mock - only including properties needed for test
|
|
const graph = {
|
|
nodes: [
|
|
{
|
|
id: 1,
|
|
type: 'foo',
|
|
pos: [0, 0],
|
|
size: [100, 100],
|
|
flags: {},
|
|
order: 0,
|
|
mode: 0,
|
|
inputs: [
|
|
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
|
|
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
|
|
{ widget: { name: 'baz' }, link: null, type: 'INT', name: 'baz' }
|
|
],
|
|
outputs: []
|
|
}
|
|
],
|
|
links: [[2, 1, 0, 1, 0, 'INT']]
|
|
} as Partial<ISerialisedGraph> as ISerialisedGraph
|
|
|
|
compressWidgetInputSlots(graph)
|
|
|
|
expect(graph.nodes[0].inputs).toEqual([
|
|
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' }
|
|
])
|
|
})
|
|
|
|
it('should update link target slots correctly', () => {
|
|
const graph = {
|
|
nodes: [
|
|
{
|
|
id: 1,
|
|
type: 'foo',
|
|
pos: [0, 0],
|
|
size: [100, 100],
|
|
flags: {},
|
|
order: 0,
|
|
mode: 0,
|
|
inputs: [
|
|
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
|
|
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
|
|
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
|
|
],
|
|
outputs: []
|
|
}
|
|
],
|
|
links: [
|
|
[2, 1, 0, 1, 1, 'INT'],
|
|
[3, 1, 0, 1, 2, 'INT']
|
|
]
|
|
} as Partial<ISerialisedGraph> as ISerialisedGraph
|
|
|
|
compressWidgetInputSlots(graph)
|
|
|
|
expect(graph.nodes[0].inputs).toEqual([
|
|
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
|
|
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
|
|
])
|
|
|
|
expect(graph.links).toEqual([
|
|
[2, 1, 0, 1, 0, 'INT'],
|
|
[3, 1, 0, 1, 1, 'INT']
|
|
])
|
|
})
|
|
|
|
it('should handle graphs with no nodes gracefully', () => {
|
|
// Using partial mock - only including properties needed for test
|
|
const graph = {
|
|
nodes: [],
|
|
links: []
|
|
} as Partial<ISerialisedGraph> as ISerialisedGraph
|
|
|
|
compressWidgetInputSlots(graph)
|
|
|
|
expect(graph.nodes).toEqual([])
|
|
expect(graph.links).toEqual([])
|
|
})
|
|
})
|