mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
Closes coverage gaps in \`src/composables/\` as part of the unit-test backfill. ## Testing focus Three composables, each a different kind of test challenge: reactive pagination state, DOM-track drag math, and SVG pointer interaction. No third-party library is mocked. ### \`useLazyPagination\` (10 tests) - Accepts both \`Ref<T[]>\` and plain \`T[]\` inputs. - \`currentPage\` ceiling at \`totalPages\` (clamp behavior). - Source-array replacement resets internal page state. - \`loadedPages\` (Set) accumulates across navigation. - **Observed source issue.** \`loadNextPage\` is declared \`async\` but contains no \`await\` (the artificial delay is commented out). Consequence: \`isLoading\` is never externally observable as \`true\`, and the concurrent-call dedup in the design doesn't actually fire in practice. Tests cover **observable** behavior only; the finding is noted here as a candidate follow-up fix. ### \`useRangeEditor\` (11 tests) - Drags each of \`min\` / \`max\` / \`midpoint\` handles; respects the \`showMidpoint\` toggle (events on the midpoint are ignored when hidden). - Value clamping within \`[valueMin, valueMax]\`. - \`denormalize\` receives the correct normalized position — verifies the 0–1 mapping math, not just that it was called. - \`trackRef.value === null\` → pointer events are no-ops (null-safety). - **Real lifecycle.** Mounts a tiny \`defineComponent\` via \`@testing-library/vue\`'s \`render\` and exercises cleanup through \`unmount()\`. \`onBeforeUnmount\` only fires inside a component instance — \`effectScope.stop()\` alone is insufficient. ### \`useCurveEditor\` (14 tests) - \`curvePath\` empty when fewer than 2 points. - Linear interpolation: \`M\` + \`L\` command sequence, points sorted by x before drawing. - Non-linear uses \`createInterpolator\` (our module → OK to mock and assert call shape). - Drag: dispatching \`pointermove\` updates \`modelValue\`; after \`pointerup\`, a follow-up \`pointermove\` is a no-op. - **happy-dom gaps polyfilled.** \`Element.setPointerCapture\` is stubbed per-element and \`DOMPoint.prototype.matrixTransform\` is added in \`beforeEach\`. Since the SVG has no CTM, \`DOMMatrix.inverse()\` returns identity — so \`svgCoords\` maps \`clientX\`/\`clientY\` directly into curve space, giving deterministic assertions without brittle coordinate math. ## Principles applied - No mocks of \`vue\`, \`@vueuse/core\`, or \`es-toolkit\`. - Behavioral assertions only — no return-shape checks. - All 35 tests pass; typecheck/lint/format clean. Test-only; no production code touched.
327 lines
8.5 KiB
TypeScript
327 lines
8.5 KiB
TypeScript
import { render } from '@testing-library/vue'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, ref } from 'vue'
|
|
import type { Ref } from 'vue'
|
|
|
|
import { useRangeEditor } from '@/composables/useRangeEditor'
|
|
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
|
|
|
const TRACK_RECT: DOMRect = {
|
|
left: 0,
|
|
top: 0,
|
|
width: 200,
|
|
height: 10,
|
|
right: 200,
|
|
bottom: 10,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({})
|
|
}
|
|
|
|
const createTrackElement = (): HTMLElement => {
|
|
const el = document.createElement('div')
|
|
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue(TRACK_RECT)
|
|
el.setPointerCapture = vi.fn()
|
|
el.releasePointerCapture = vi.fn()
|
|
document.body.appendChild(el)
|
|
return el
|
|
}
|
|
|
|
const createPointerEvent = (
|
|
type: string,
|
|
init: {
|
|
clientX?: number
|
|
clientY?: number
|
|
button?: number
|
|
pointerId?: number
|
|
} = {}
|
|
): PointerEvent =>
|
|
new PointerEvent(type, {
|
|
clientX: init.clientX ?? 0,
|
|
clientY: init.clientY ?? 0,
|
|
button: init.button ?? 0,
|
|
pointerId: init.pointerId ?? 1,
|
|
bubbles: true
|
|
})
|
|
|
|
interface HarnessOptions {
|
|
initial?: RangeValue
|
|
valueMin?: number
|
|
valueMax?: number
|
|
showMidpoint?: boolean
|
|
track?: HTMLElement | null
|
|
}
|
|
|
|
interface Harness {
|
|
trackRef: Ref<HTMLElement | null>
|
|
modelValue: Ref<RangeValue>
|
|
valueMin: Ref<number>
|
|
valueMax: Ref<number>
|
|
showMidpoint: Ref<boolean>
|
|
api: ReturnType<typeof useRangeEditor>
|
|
unmount: () => void
|
|
}
|
|
|
|
const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
|
|
const track =
|
|
opts.track === null ? null : (opts.track ?? createTrackElement())
|
|
const trackRef = ref<HTMLElement | null>(track)
|
|
const modelValue = ref<RangeValue>(
|
|
opts.initial ?? { min: 20, max: 80, midpoint: 0.5 }
|
|
)
|
|
const valueMin = ref(opts.valueMin ?? 0)
|
|
const valueMax = ref(opts.valueMax ?? 100)
|
|
const showMidpoint = ref(opts.showMidpoint ?? true)
|
|
|
|
let api: ReturnType<typeof useRangeEditor> | undefined
|
|
const TestComponent = defineComponent({
|
|
setup() {
|
|
api = useRangeEditor({
|
|
trackRef,
|
|
modelValue,
|
|
valueMin,
|
|
valueMax,
|
|
showMidpoint
|
|
})
|
|
return () => null
|
|
}
|
|
})
|
|
const { unmount } = render(TestComponent)
|
|
if (!api) throw new Error('useRangeEditor did not run')
|
|
|
|
return {
|
|
trackRef,
|
|
modelValue,
|
|
valueMin,
|
|
valueMax,
|
|
showMidpoint,
|
|
api,
|
|
unmount
|
|
}
|
|
}
|
|
|
|
describe('useRangeEditor', () => {
|
|
let harness: Harness | undefined
|
|
|
|
beforeEach(() => {
|
|
harness = undefined
|
|
})
|
|
|
|
afterEach(() => {
|
|
harness?.unmount()
|
|
document.body.innerHTML = ''
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('does nothing when trackRef is null', () => {
|
|
harness = mountRangeEditor({ track: null })
|
|
const original = { ...harness.modelValue.value }
|
|
|
|
harness.api.handleTrackPointerDown(
|
|
createPointerEvent('pointerdown', { clientX: 100 })
|
|
)
|
|
|
|
expect(harness.modelValue.value).toEqual(original)
|
|
})
|
|
|
|
it('ignores non-primary button clicks on the track', () => {
|
|
harness = mountRangeEditor()
|
|
const before = { ...harness.modelValue.value }
|
|
|
|
harness.api.handleTrackPointerDown(
|
|
createPointerEvent('pointerdown', { clientX: 100, button: 2 })
|
|
)
|
|
|
|
expect(harness.modelValue.value).toEqual(before)
|
|
})
|
|
|
|
it('drags the min handle and clamps to the configured floor', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
|
|
harness.api.startDrag(
|
|
'min',
|
|
createPointerEvent('pointerdown', { clientX: 20 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: -500 })
|
|
)
|
|
|
|
expect(harness.modelValue.value.min).toBe(0)
|
|
expect(harness.modelValue.value.max).toBe(80)
|
|
})
|
|
|
|
it('drags the max handle and clamps to the configured ceiling', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
|
|
harness.api.startDrag(
|
|
'max',
|
|
createPointerEvent('pointerdown', { clientX: 160 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 9999 })
|
|
)
|
|
|
|
expect(harness.modelValue.value.max).toBe(100)
|
|
expect(harness.modelValue.value.min).toBe(20)
|
|
})
|
|
|
|
it('prevents min handle from crossing above max', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 50, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
|
|
harness.api.startDrag(
|
|
'min',
|
|
createPointerEvent('pointerdown', { clientX: 20 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 180 })
|
|
)
|
|
|
|
expect(harness.modelValue.value.min).toBe(50)
|
|
expect(harness.modelValue.value.max).toBe(50)
|
|
})
|
|
|
|
it('updates the midpoint as a normalized fraction of the current range', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.25 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
|
|
harness.api.startDrag(
|
|
'midpoint',
|
|
createPointerEvent('pointerdown', { clientX: 100 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 100 })
|
|
)
|
|
|
|
const { min, max, midpoint } = harness.modelValue.value
|
|
expect(min).toBe(20)
|
|
expect(max).toBe(80)
|
|
expect(midpoint).toBeCloseTo((50 - 20) / (80 - 20), 5)
|
|
})
|
|
|
|
it('picks the nearest handle on a track pointer down', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100,
|
|
showMidpoint: false
|
|
})
|
|
|
|
harness.api.handleTrackPointerDown(
|
|
createPointerEvent('pointerdown', { clientX: 10 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 30 })
|
|
)
|
|
|
|
expect(harness.modelValue.value.min).toBe(15)
|
|
expect(harness.modelValue.value.max).toBe(80)
|
|
})
|
|
|
|
it('ignores the midpoint handle when showMidpoint is false', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 10, max: 20, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100,
|
|
showMidpoint: false
|
|
})
|
|
|
|
// clientX 100 maps to value 50; midpoint would normally win since it sits
|
|
// mid-range, but showMidpoint=false forces min/max only — max (20) is nearest.
|
|
harness.api.handleTrackPointerDown(
|
|
createPointerEvent('pointerdown', { clientX: 100 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 100 })
|
|
)
|
|
|
|
expect(harness.modelValue.value.midpoint).toBe(0.5)
|
|
expect(harness.modelValue.value.max).toBe(50)
|
|
})
|
|
|
|
it('stops responding to pointermove after pointerup', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
|
|
harness.api.startDrag(
|
|
'min',
|
|
createPointerEvent('pointerdown', { clientX: 20 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 40 })
|
|
)
|
|
harness.trackRef.value!.dispatchEvent(createPointerEvent('pointerup'))
|
|
|
|
const afterUp = { ...harness.modelValue.value }
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 200 })
|
|
)
|
|
|
|
expect(harness.modelValue.value).toEqual(afterUp)
|
|
})
|
|
|
|
it('releases the prior drag when starting a new one', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
|
|
harness.api.startDrag(
|
|
'min',
|
|
createPointerEvent('pointerdown', { clientX: 20 })
|
|
)
|
|
harness.api.startDrag(
|
|
'max',
|
|
createPointerEvent('pointerdown', { clientX: 160 })
|
|
)
|
|
|
|
harness.trackRef.value!.dispatchEvent(
|
|
createPointerEvent('pointermove', { clientX: 120 })
|
|
)
|
|
|
|
expect(harness.modelValue.value.max).toBe(60)
|
|
expect(harness.modelValue.value.min).toBe(20)
|
|
})
|
|
|
|
it('cleans up drag listeners on unmount', () => {
|
|
harness = mountRangeEditor({
|
|
initial: { min: 20, max: 80, midpoint: 0.5 },
|
|
valueMin: 0,
|
|
valueMax: 100
|
|
})
|
|
const track = harness.trackRef.value!
|
|
|
|
harness.api.startDrag(
|
|
'min',
|
|
createPointerEvent('pointerdown', { clientX: 20 })
|
|
)
|
|
|
|
const removeSpy = vi.spyOn(track, 'removeEventListener')
|
|
harness.unmount()
|
|
harness = undefined
|
|
|
|
const removedTypes = removeSpy.mock.calls.map(([type]) => type)
|
|
expect(removedTypes).toEqual(
|
|
expect.arrayContaining(['pointermove', 'pointerup', 'lostpointercapture'])
|
|
)
|
|
})
|
|
})
|