Compare commits

...

4 Commits

Author SHA1 Message Date
GitHub Action
2e15374c43 [automated] Apply ESLint and Oxfmt fixes 2026-04-20 04:04:02 +00:00
dante01yoon
94ccda5890 test: type CurveData explicitly and use valid IWidgetRangeOptions (Codex/CI fix)
Fixes lint-and-format CI failure on #11446.

- WidgetCurve.test.ts: the 'preserves points when interpolation changes'
  test inlined a number[][] literal; widen to a CurveData type so TS sees
  the tuple shape expected by the curve store.
- WidgetRange.test.ts: the display-option test used 'percentage', which
  is not in the IWidgetRangeOptions display union ('plain' | 'gradient'
  | 'histogram'). Use 'histogram' and drop the `as IWidgetRangeOptions`
  casts since Partial<IWidgetRangeOptions> already accepts it.
2026-04-20 13:00:57 +09:00
dante01yoon
6f2fd52396 test: prime useImageCrop state before a single render (Codex review)
Addresses Codex adversarial review finding on PR #11446:

- The Image-states block called renderWidget() twice per test — once
  before priming and once after — relying on stale DOM from the first
  mount. Replace with primeCropState() that sets the full mock state
  synchronously before the single render call, and assert each state
  against the current mount only.
- Loading test now supplies a valid imageUrl alongside isLoading=true so
  the assertion matches a state the component can actually render (the
  template branches on isLoading first, independent of imageUrl, but
  using a realistic pairing keeps the test honest against possible
  template refactors).
2026-04-20 12:31:23 +09:00
dante01yoon
ed9c1dab4b test: add unit tests for canvas-backed widget logic
Covers WidgetBoundingBox immutable coord updates, WidgetRange value
pass-through with upstream-value and histogram orchestration,
WidgetCurve points/interpolation handling with upstream override,
WidgetImageCrop empty/loading states and bounds delegation.

35 new tests across 4 files.
2026-04-20 11:28:14 +09:00
4 changed files with 821 additions and 0 deletions

View 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()
}
})
})
})

View 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)
})
})
})

View 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 })
})
})
})

View 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'
)
})
})
})