mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
4 Commits
litegraph/
...
test/widge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e15374c43 | ||
|
|
94ccda5890 | ||
|
|
6f2fd52396 | ||
|
|
ed9c1dab4b |
132
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
132
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
import WidgetBoundingBox from './WidgetBoundingBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ScrubableNumberInputStub = defineComponent({
|
||||
name: 'ScrubableNumberInput',
|
||||
props: {
|
||||
modelValue: { type: Number, default: 0 },
|
||||
min: { type: Number, default: 0 },
|
||||
step: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:data-min="min"
|
||||
:data-step="step"
|
||||
@input="$emit('update:modelValue', Number(($event.target).value))"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
function renderBox(initial: Bounds, disabled = false) {
|
||||
const value = ref<Bounds>(initial)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetBoundingBox },
|
||||
setup: () => ({ value, disabled }),
|
||||
template: '<WidgetBoundingBox v-model="value" :disabled="disabled" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { ScrubableNumberInput: ScrubableNumberInputStub }
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetBoundingBox', () => {
|
||||
describe('Label rendering', () => {
|
||||
it('renders labels for x, y, width, and height', () => {
|
||||
renderBox({ x: 0, y: 0, width: 100, height: 100 })
|
||||
expect(screen.getByText('X')).toBeInTheDocument()
|
||||
expect(screen.getByText('Y')).toBeInTheDocument()
|
||||
expect(screen.getByText('Width')).toBeInTheDocument()
|
||||
expect(screen.getByText('Height')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Initial values', () => {
|
||||
it('displays the initial bounds across four inputs', () => {
|
||||
renderBox({ x: 10, y: 20, width: 300, height: 400 })
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
expect(inputs.map((i) => i.value)).toEqual(['10', '20', '300', '400'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constraints', () => {
|
||||
it('sets min=0 for x/y and min=1 for width/height', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 })
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
expect(inputs[0].dataset.min).toBe('0') // x
|
||||
expect(inputs[1].dataset.min).toBe('0') // y
|
||||
expect(inputs[2].dataset.min).toBe('1') // width
|
||||
expect(inputs[3].dataset.min).toBe('1') // height
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model updates', () => {
|
||||
it('updates x immutably, preserving y/width/height', async () => {
|
||||
const { value } = renderBox({ x: 10, y: 20, width: 100, height: 200 })
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
const user = userEvent.setup()
|
||||
await user.clear(inputs[0])
|
||||
await user.type(inputs[0], '55')
|
||||
expect(value.value).toEqual({
|
||||
x: 55,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('updates height immutably without mutating the original bounds', async () => {
|
||||
const initial = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const { value } = renderBox(initial)
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
const user = userEvent.setup()
|
||||
await user.clear(inputs[3])
|
||||
await user.type(inputs[3], '500')
|
||||
expect(value.value.height).toBe(500)
|
||||
expect(value.value).not.toBe(initial)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('disables all four inputs when disabled=true', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 }, true)
|
||||
for (const input of screen.getAllByRole('spinbutton')) {
|
||||
expect(input).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves all four inputs enabled when disabled=false', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 }, false)
|
||||
for (const input of screen.getAllByRole('spinbutton')) {
|
||||
expect(input).not.toBeDisabled()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
254
src/components/curve/WidgetCurve.test.ts
Normal file
254
src/components/curve/WidgetCurve.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
/* eslint-disable vue/no-unused-emit-declarations */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
curveWidget: {
|
||||
linear: 'Linear',
|
||||
monotone_cubic: 'Smooth',
|
||||
step: 'Step'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as { value: unknown } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
singleValueExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
const outputsHolder = vi.hoisted(() => ({
|
||||
nodeOutputs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => outputsHolder
|
||||
}))
|
||||
|
||||
import WidgetCurve from './WidgetCurve.vue'
|
||||
import type { CurveData } from './types'
|
||||
|
||||
const CurveEditorStub = defineComponent({
|
||||
name: 'CurveEditor',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
interpolation: { type: String, default: '' },
|
||||
histogram: { type: Object, default: null }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="curve-editor"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-interpolation="interpolation"
|
||||
:data-has-histogram="String(!!histogram)"
|
||||
:data-points="JSON.stringify(modelValue)"
|
||||
@click="$emit('update:modelValue', [[0,0],[0.5,1],[1,0]])"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'Select',
|
||||
props: { modelValue: { type: String, default: '' } },
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="interp-select" :data-value="modelValue">
|
||||
<button
|
||||
data-testid="select-linear"
|
||||
@click="$emit('update:modelValue', 'linear')"
|
||||
>linear</button>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
const Passthrough = defineComponent({
|
||||
name: 'SelectPassthrough',
|
||||
template: '<slot />'
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<SimplifiedWidget<CurveData>> = {}
|
||||
): SimplifiedWidget<CurveData> {
|
||||
return {
|
||||
name: 'curve_w',
|
||||
type: 'curve',
|
||||
value: {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
},
|
||||
options: {},
|
||||
...overrides
|
||||
} as unknown as SimplifiedWidget<CurveData>
|
||||
}
|
||||
|
||||
function setUpstream(value: CurveData | undefined) {
|
||||
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
|
||||
upstreamHolder.ref.value = value
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<CurveData>,
|
||||
initialModel: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
}
|
||||
) {
|
||||
const value = ref<CurveData>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetCurve },
|
||||
setup: () => ({ value, widget }),
|
||||
template: '<WidgetCurve v-model="value" :widget="widget" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
CurveEditor: CurveEditorStub,
|
||||
Select: SelectStub,
|
||||
SelectContent: Passthrough,
|
||||
SelectTrigger: Passthrough,
|
||||
SelectValue: Passthrough,
|
||||
SelectItem: Passthrough
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetCurve', () => {
|
||||
beforeEach(() => {
|
||||
upstreamHolder.ref = null
|
||||
outputsHolder.nodeOutputs = {}
|
||||
})
|
||||
|
||||
describe('Point forwarding', () => {
|
||||
it('forwards model points to CurveEditor', () => {
|
||||
renderWidget(makeWidget(), {
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.5, 0.2],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
})
|
||||
const parsed = JSON.parse(
|
||||
screen.getByTestId('curve-editor').dataset.points!
|
||||
)
|
||||
expect(parsed).toEqual([
|
||||
[0, 0],
|
||||
[0.5, 0.2],
|
||||
[1, 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('updates v-model when CurveEditor emits new points', async () => {
|
||||
const { value } = renderWidget(makeWidget())
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('curve-editor'))
|
||||
expect(value.value.points).toEqual([
|
||||
[0, 0],
|
||||
[0.5, 1],
|
||||
[1, 0]
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves interpolation when points change', async () => {
|
||||
const { value } = renderWidget(makeWidget(), {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'linear'
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('curve-editor'))
|
||||
expect(value.value.interpolation).toBe('linear')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interpolation select', () => {
|
||||
it('shows the Select when not disabled', () => {
|
||||
renderWidget(makeWidget())
|
||||
expect(screen.getByTestId('interp-select')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Select when disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.queryByTestId('interp-select')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates interpolation in v-model when Select emits a change', async () => {
|
||||
const { value } = renderWidget(makeWidget())
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('select-linear'))
|
||||
expect(value.value.interpolation).toBe('linear')
|
||||
})
|
||||
|
||||
it('preserves points when interpolation changes', async () => {
|
||||
const original: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.3, 0.8],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
}
|
||||
const { value } = renderWidget(makeWidget(), original)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('select-linear'))
|
||||
expect(value.value.points).toEqual(original.points)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state + upstream', () => {
|
||||
it('uses upstream curve when disabled and upstream is available', () => {
|
||||
const upstream: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'linear'
|
||||
}
|
||||
setUpstream(upstream)
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
})
|
||||
)
|
||||
const parsed = JSON.parse(
|
||||
screen.getByTestId('curve-editor').dataset.points!
|
||||
)
|
||||
expect(parsed).toEqual(upstream.points)
|
||||
})
|
||||
})
|
||||
})
|
||||
250
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
250
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const cropHolder = vi.hoisted(() => ({
|
||||
state: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useImageCrop', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
ASPECT_RATIOS: {
|
||||
'1:1': 1,
|
||||
'4:3': 4 / 3,
|
||||
custom: null
|
||||
},
|
||||
useImageCrop: () => {
|
||||
if (!cropHolder.state) {
|
||||
cropHolder.state = {
|
||||
imageUrl: ref<string | null>(null),
|
||||
isLoading: ref(false),
|
||||
selectedRatio: ref('1:1'),
|
||||
isLockEnabled: ref(false),
|
||||
cropBoxStyle: ref({}),
|
||||
resizeHandles: ref([]),
|
||||
handleImageLoad: () => {},
|
||||
handleImageError: () => {},
|
||||
handleDragStart: () => {},
|
||||
handleDragMove: () => {},
|
||||
handleDragEnd: () => {},
|
||||
handleResizeStart: () => {},
|
||||
handleResizeMove: () => {},
|
||||
handleResizeEnd: () => {}
|
||||
}
|
||||
}
|
||||
return cropHolder.state
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as { value: unknown } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
boundsExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
import WidgetImageCrop from './WidgetImageCrop.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
imageCrop: {
|
||||
loading: 'Loading...',
|
||||
noInputImage: 'No input image connected',
|
||||
cropPreviewAlt: 'Crop preview',
|
||||
ratio: 'Ratio',
|
||||
lockRatio: 'Lock aspect ratio',
|
||||
unlockRatio: 'Unlock aspect ratio',
|
||||
custom: 'Custom'
|
||||
},
|
||||
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
inheritAttrs: false,
|
||||
template: '<button v-bind="$attrs" type="button"><slot /></button>'
|
||||
})
|
||||
|
||||
const Passthrough = defineComponent({
|
||||
template: '<div><slot /></div>'
|
||||
})
|
||||
|
||||
const WidgetBoundingBoxStub = defineComponent({
|
||||
name: 'WidgetBoundingBox',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `<div data-testid="bbox-child"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-model="JSON.stringify(modelValue)"
|
||||
@click="$emit('update:modelValue', { x: 1, y: 2, width: 3, height: 4 })"
|
||||
/>`
|
||||
})
|
||||
|
||||
function primeCropState(overrides: Record<string, unknown> = {}) {
|
||||
cropHolder.state = {
|
||||
imageUrl: ref<string | null>(null),
|
||||
isLoading: ref(false),
|
||||
selectedRatio: ref('1:1'),
|
||||
isLockEnabled: ref(false),
|
||||
cropBoxStyle: ref({}),
|
||||
resizeHandles: ref([]),
|
||||
handleImageLoad: () => {},
|
||||
handleImageError: () => {},
|
||||
handleDragStart: () => {},
|
||||
handleDragMove: () => {},
|
||||
handleDragEnd: () => {},
|
||||
handleResizeStart: () => {},
|
||||
handleResizeMove: () => {},
|
||||
handleResizeEnd: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<SimplifiedWidget<Bounds>> = {}
|
||||
): SimplifiedWidget<Bounds> {
|
||||
return {
|
||||
name: 'crop',
|
||||
type: 'imagecrop',
|
||||
value: { x: 0, y: 0, width: 512, height: 512 },
|
||||
options: {},
|
||||
...overrides
|
||||
} as SimplifiedWidget<Bounds>
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<Bounds> = makeWidget(),
|
||||
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
|
||||
) {
|
||||
const value = ref<Bounds>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetImageCrop },
|
||||
setup: () => ({ value, widget }),
|
||||
template:
|
||||
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Select: Passthrough,
|
||||
SelectContent: Passthrough,
|
||||
SelectTrigger: Passthrough,
|
||||
SelectValue: Passthrough,
|
||||
SelectItem: Passthrough,
|
||||
WidgetBoundingBox: WidgetBoundingBoxStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetImageCrop', () => {
|
||||
beforeEach(() => {
|
||||
cropHolder.state = null
|
||||
upstreamHolder.ref = null
|
||||
})
|
||||
|
||||
describe('Image states', () => {
|
||||
it('shows the empty-state placeholder when imageUrl is null', () => {
|
||||
primeCropState()
|
||||
renderWidget()
|
||||
expect(screen.getByTestId('crop-empty-state')).toBeInTheDocument()
|
||||
expect(screen.getByText('No input image connected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the loading message when isLoading is true', () => {
|
||||
primeCropState({ isLoading: ref(true), imageUrl: ref('/img.png') })
|
||||
renderWidget()
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('crop-empty-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an img when imageUrl is set and not loading', () => {
|
||||
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
|
||||
const { container } = renderWidget()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('img')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading...')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the crop overlay when an image is loaded', () => {
|
||||
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
|
||||
renderWidget()
|
||||
expect(screen.getByTestId('crop-overlay')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('hides the ratio controls when widget is disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.queryByText('Ratio')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the ratio controls when widget is enabled', () => {
|
||||
renderWidget()
|
||||
expect(screen.getByText('Ratio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes disabled=true to the bounding box child when disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.getByTestId('bbox-child').dataset.disabled).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bounds delegation', () => {
|
||||
it('forwards v-model to the bounding box child', () => {
|
||||
renderWidget(undefined, { x: 5, y: 10, width: 100, height: 200 })
|
||||
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
|
||||
expect(parsed).toEqual({ x: 5, y: 10, width: 100, height: 200 })
|
||||
})
|
||||
|
||||
it('updates v-model when the bounding box emits a change', async () => {
|
||||
const { value } = renderWidget()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('bbox-child'))
|
||||
expect(value.value).toEqual({ x: 1, y: 2, width: 3, height: 4 })
|
||||
})
|
||||
|
||||
it('uses upstream bounds when disabled and upstream is available', () => {
|
||||
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
|
||||
upstreamHolder.ref.value = { x: 7, y: 8, width: 20, height: 30 }
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
}),
|
||||
{ x: 0, y: 0, width: 512, height: 512 }
|
||||
)
|
||||
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
|
||||
expect(parsed).toEqual({ x: 7, y: 8, width: 20, height: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
185
src/components/range/WidgetRange.test.ts
Normal file
185
src/components/range/WidgetRange.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
IWidgetRangeOptions,
|
||||
RangeValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as { value: unknown } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
singleValueExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
const outputsHolder = vi.hoisted(() => ({
|
||||
nodeOutputs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => outputsHolder
|
||||
}))
|
||||
|
||||
import WidgetRange from './WidgetRange.vue'
|
||||
|
||||
const RangeEditorStub = defineComponent({
|
||||
name: 'RangeEditor',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({ min: 0, max: 1 }) },
|
||||
disabled: { type: Boolean, default: false },
|
||||
histogram: { type: Object, default: null },
|
||||
display: { type: String, default: '' }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="range-editor"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-has-histogram="String(!!histogram)"
|
||||
:data-model="JSON.stringify(modelValue)"
|
||||
:data-display="display"
|
||||
@click="$emit('update:modelValue', { min: 5, max: 10 })"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
options: Partial<IWidgetRangeOptions> = {},
|
||||
widgetOverrides: Partial<
|
||||
SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
> = {}
|
||||
): SimplifiedWidget<RangeValue, IWidgetRangeOptions> {
|
||||
return {
|
||||
name: 'range_w',
|
||||
type: 'range',
|
||||
value: { min: 0, max: 1 },
|
||||
options: options as IWidgetRangeOptions,
|
||||
...widgetOverrides
|
||||
} as SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
}
|
||||
|
||||
function setUpstream(value: RangeValue | undefined) {
|
||||
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
|
||||
upstreamHolder.ref.value = value
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>,
|
||||
initialModel: RangeValue = { min: 0, max: 1 }
|
||||
) {
|
||||
const value = ref<RangeValue>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetRange },
|
||||
setup: () => ({ value, widget }),
|
||||
template: '<WidgetRange v-model="value" :widget="widget" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: { stubs: { RangeEditor: RangeEditorStub } }
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetRange', () => {
|
||||
beforeEach(() => {
|
||||
upstreamHolder.ref = null
|
||||
outputsHolder.nodeOutputs = {}
|
||||
})
|
||||
|
||||
describe('Value pass-through', () => {
|
||||
it('forwards modelValue to the RangeEditor', () => {
|
||||
renderWidget(makeWidget(), { min: 0.2, max: 0.8 })
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.2, max: 0.8 })
|
||||
})
|
||||
|
||||
it('propagates editor updates back to v-model', async () => {
|
||||
const { value } = renderWidget(makeWidget())
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('range-editor'))
|
||||
expect(value.value).toEqual({ min: 5, max: 10 })
|
||||
})
|
||||
|
||||
it('forwards the display option to the RangeEditor', () => {
|
||||
renderWidget(makeWidget({ display: 'histogram' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.display).toBe(
|
||||
'histogram'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('passes disabled=true when widget.options.disabled is set', () => {
|
||||
renderWidget(makeWidget({ disabled: true }))
|
||||
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('true')
|
||||
})
|
||||
|
||||
it('passes disabled=false by default', () => {
|
||||
renderWidget(makeWidget())
|
||||
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('false')
|
||||
})
|
||||
|
||||
it('shows upstream value when disabled with a valid upstream', () => {
|
||||
setUpstream({ min: 0.3, max: 0.7 })
|
||||
renderWidget(
|
||||
makeWidget({ disabled: true } as IWidgetRangeOptions, {
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
}),
|
||||
{ min: 0, max: 1 }
|
||||
)
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.3, max: 0.7 })
|
||||
})
|
||||
|
||||
it('ignores upstream value when not disabled', () => {
|
||||
setUpstream({ min: 0.3, max: 0.7 })
|
||||
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
|
||||
min: 0,
|
||||
max: 1
|
||||
})
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Histogram', () => {
|
||||
it('passes null histogram when nodeLocatorId is absent', () => {
|
||||
renderWidget(makeWidget())
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a histogram when node output has a matching histogram entry', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an empty histogram array as null', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user