Files
waveform-analysis/src/components/waveformChartCases/viewportBoxAndPan.test.ts

351 lines
13 KiB
TypeScript
Raw Normal View History

import { flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import { type WaveformData } from '../waveform'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
describe('WaveformChart', () => {
it('keeps independent multi-column zoom inside the active track domain', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
const endpoints = () =>
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text())
const initialEndpoints = endpoints()
const firstWidth = Number(overlays[0].attributes('width'))
Object.defineProperty(overlays[0].element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: firstWidth, height: 260 }),
})
overlays[0].element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4_000,
clientX: firstWidth - 1,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(domain[0]).toBeGreaterThanOrEqual(0)
expect(domain[1]).toBeLessThanOrEqual(1)
expect(endpoints()[0]).not.toBe(initialEndpoints[0])
expect(endpoints()[1]).toBe(initialEndpoints[1])
})
it('emits one zoom-end payload after a shared zoom gesture completes', async () => {
vi.useFakeTimers()
try {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 1 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay')
const overlayWidth = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: overlayWidth / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
expect(wrapper.emitted('zoom-end')).toBeUndefined()
flushAnimationFrames()
// The wheel debounce is 200ms: it must not end early, then ends at the boundary.
await vi.advanceTimersByTimeAsync(199)
await flushPromises()
expect(wrapper.emitted('zoom-end')).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
const endEvents = wrapper.emitted('zoom-end') ?? []
expect(endEvents).toHaveLength(1)
const payload = endEvents[0]?.[0] as { start: number; end: number }
expect(payload.start).toBeGreaterThanOrEqual(0)
expect(payload.end).toBeLessThanOrEqual(2)
expect(payload.start).toBeLessThan(payload.end)
expect(payload.end - payload.start).toBeCloseTo(2 / 40)
} finally {
vi.useRealTimers()
}
})
it('zooms only the x axis and identifies the box gesture', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const dispatchPointer = (type: string, clientX: number, clientY: number) => {
const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
Object.defineProperty(event, 'pointerId', { value: 7 })
overlay.element.dispatchEvent(event)
}
dispatchPointer('pointerdown', width * 0.25, height / 2)
dispatchPointer('pointermove', width * 0.75, height / 2 + 2)
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
dispatchPointer('pointerup', width * 0.75, height * 0.75)
await flushPromises()
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as
| {
start: number
end: number
yStart?: number
yEnd?: number
trackIndex: number
seriesIds: string[]
gesture: string
}
| undefined
expect(payload).toMatchObject({ trackIndex: 0, seriesIds: ['series-0'], gesture: 'box' })
expect(payload?.end).toBeGreaterThan(payload?.start ?? Number.POSITIVE_INFINITY)
expect(payload?.yStart).toBeDefined()
expect(payload?.yEnd).toBeDefined()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
})
it('enables space-drag panning only when pannable is true and the pointer is inside', async () => {
const data: WaveformData = {
kind: 'points',
points: Array.from({ length: 5 }, (_, index) => ({ x: index, y: index })),
}
const disabled = await mountSizedChart(data)
const disabledOverlay = disabled.get('.waveform-chart__overlay--independent')
const disabledWidth = Number(disabledOverlay.attributes('width'))
const disabledHeight = Number(disabledOverlay.attributes('height'))
Object.defineProperty(disabledOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: disabledWidth, height: disabledHeight }),
})
await disabled.trigger('pointerenter')
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
const disabledDown = new MouseEvent('pointerdown', {
button: 0,
clientX: disabledWidth * 0.25,
clientY: disabledHeight / 2,
bubbles: true,
})
Object.defineProperty(disabledDown, 'pointerId', { value: 31 })
disabledOverlay.element.dispatchEvent(disabledDown)
const disabledMove = new MouseEvent('pointermove', {
clientX: disabledWidth * 0.75,
clientY: disabledHeight / 2,
bubbles: true,
})
Object.defineProperty(disabledMove, 'pointerId', { value: 31 })
disabledOverlay.element.dispatchEvent(disabledMove)
await flushPromises()
expect(disabled.find('.waveform-chart__zoom-selection').exists()).toBe(true)
const enabled = await mountSizedChart(data, { pannable: true })
const enabledOverlay = enabled.get('.waveform-chart__overlay--independent')
const enabledWidth = Number(enabledOverlay.attributes('width'))
const enabledHeight = Number(enabledOverlay.attributes('height'))
Object.defineProperty(enabledOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: enabledWidth, height: enabledHeight }),
})
const boxDown = new MouseEvent('pointerdown', {
button: 0,
clientX: enabledWidth * 0.25,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(boxDown, 'pointerId', { value: 30 })
enabledOverlay.element.dispatchEvent(boxDown)
const boxMove = new MouseEvent('pointermove', {
clientX: enabledWidth * 0.75,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(boxMove, 'pointerId', { value: 30 })
enabledOverlay.element.dispatchEvent(boxMove)
const boxUp = new MouseEvent('pointerup', {
clientX: enabledWidth * 0.75,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(boxUp, 'pointerId', { value: 30 })
enabledOverlay.element.dispatchEvent(boxUp)
await flushPromises()
const startBeforePan = enabled.get('.waveform-chart__axis-endpoint--start').text()
await enabled.trigger('pointerenter')
const spaceDown = new KeyboardEvent('keydown', { code: 'Space', cancelable: true })
window.dispatchEvent(spaceDown)
const enabledDown = new MouseEvent('pointerdown', {
button: 0,
clientX: enabledWidth / 2,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(enabledDown, 'pointerId', { value: 32 })
enabledOverlay.element.dispatchEvent(enabledDown)
const enabledMove = new MouseEvent('pointermove', {
clientX: enabledWidth / 2 + 20,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(enabledMove, 'pointerId', { value: 32 })
enabledOverlay.element.dispatchEvent(enabledMove)
await flushPromises()
expect(spaceDown.defaultPrevented).toBe(true)
expect(enabled.classes()).toContain('waveform-chart--panning')
expect(enabled.find('.waveform-chart__zoom-selection').exists()).toBe(false)
expect(enabled.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(startBeforePan)
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('keeps a fixed Y domain through panning and viewport reset', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 100 },
],
},
{ pannable: true, yDomain: [3, 97] },
)
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const yTickLabels = () =>
wrapper
.get('.waveform-chart__axis--y')
.findAll('.tick text')
.map((tick) => tick.text())
const initialLabels = yTickLabels()
await wrapper.trigger('pointerenter')
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
const down = new MouseEvent('pointerdown', {
button: 0,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(down, 'pointerId', { value: 34 })
overlay.element.dispatchEvent(down)
const move = new MouseEvent('pointermove', {
clientX: width / 2 + 20,
clientY: height / 2 + 40,
bubbles: true,
})
Object.defineProperty(move, 'pointerId', { value: 34 })
overlay.element.dispatchEvent(move)
await flushPromises()
expect(yTickLabels()).toEqual(initialLabels)
;(wrapper.vm as unknown as { resetViewport: () => void }).resetViewport()
await flushPromises()
expect(yTickLabels()).toEqual(initialLabels)
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('does not activate pannable on a chart that the pointer is outside', async () => {
const data: WaveformData = {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
}
const active = await mountSizedChart(data, { pannable: true })
const inactive = await mountSizedChart(data, { pannable: true })
await active.trigger('pointerenter')
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
const inactiveOverlay = inactive.get('.waveform-chart__overlay--independent')
const width = Number(inactiveOverlay.attributes('width'))
const height = Number(inactiveOverlay.attributes('height'))
Object.defineProperty(inactiveOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const down = new MouseEvent('pointerdown', {
button: 0,
clientX: width * 0.25,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(down, 'pointerId', { value: 33 })
inactiveOverlay.element.dispatchEvent(down)
const move = new MouseEvent('pointermove', {
clientX: width * 0.75,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(move, 'pointerId', { value: 33 })
inactiveOverlay.element.dispatchEvent(move)
await flushPromises()
expect(inactive.find('.waveform-chart__zoom-selection').exists()).toBe(true)
expect(inactive.classes()).not.toContain('waveform-chart--panning')
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('limits box zoom to the configured minimum x span', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
},
{ minZoomSpan: 0.25 },
)
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const dispatchPointer = (type: string, clientX: number, clientY: number) => {
const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
Object.defineProperty(event, 'pointerId', { value: 8 })
overlay.element.dispatchEvent(event)
}
dispatchPointer('pointerdown', width / 2, height / 2)
dispatchPointer('pointermove', width / 2 + 8, height / 2 + 8)
dispatchPointer('pointerup', width / 2 + 8, height / 2 + 8)
await flushPromises()
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as { start: number; end: number }
expect(payload.end - payload.start).toBeGreaterThanOrEqual(0.25 - 1e-8)
})
})