refactor(chart): enforce 400-line source limit
Some checks failed
Package component / package (push) Failing after 4m19s
Some checks failed
Package component / package (push) Failing after 4m19s
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames, pendingAnimationFrameCount } from '../../test/setup'
|
||||
import WaveformChart from '../WaveformChart.vue'
|
||||
|
||||
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||
|
||||
describe('WaveformChart', () => {
|
||||
it('suppresses native context menus across the waveform component', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||
title: { text: '波形标题' },
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: true },
|
||||
})
|
||||
|
||||
for (const selector of [
|
||||
'.waveform-chart__title-area',
|
||||
'.waveform-chart__grid',
|
||||
'.waveform-chart__overlay',
|
||||
'.waveform-chart__pagination',
|
||||
]) {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
const dispatched = wrapper.get(selector).element.dispatchEvent(event)
|
||||
|
||||
expect(dispatched).toBe(false)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
}
|
||||
|
||||
const sharedWrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
{ displayMode: 'separated' },
|
||||
)
|
||||
const sharedEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
const sharedDispatched = sharedWrapper
|
||||
.get('.waveform-chart__overlay--shared')
|
||||
.element.dispatchEvent(sharedEvent)
|
||||
|
||||
expect(sharedDispatched).toBe(false)
|
||||
expect(sharedEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves native context menus for editable controls', async () => {
|
||||
const wrapper = await mountSizedChart({ kind: 'samples', values: [0, 1], sampleRate: 1 })
|
||||
const editableElements = [
|
||||
document.createElement('input'),
|
||||
document.createElement('textarea'),
|
||||
document.createElement('div'),
|
||||
]
|
||||
editableElements[2]?.setAttribute('contenteditable', 'true')
|
||||
|
||||
for (const element of editableElements) {
|
||||
wrapper.element.appendChild(element)
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
const dispatched = element.dispatchEvent(event)
|
||||
|
||||
expect(dispatched).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('applies size fallbacks for minimum, negative, and non-finite values', async () => {
|
||||
const minimumWrapper = mount(WaveformChart, {
|
||||
props: {
|
||||
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
width: -20,
|
||||
height: -20,
|
||||
},
|
||||
})
|
||||
|
||||
expect(minimumWrapper.attributes('style')).toContain('width: 0px')
|
||||
expect(minimumWrapper.attributes('style')).toContain('height: 180px')
|
||||
expect(minimumWrapper.get('.waveform-chart__svg').attributes('height')).toBe('180')
|
||||
|
||||
const adaptiveWrapper = mount(WaveformChart, {
|
||||
props: {
|
||||
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
width: Number.POSITIVE_INFINITY,
|
||||
height: Number.NaN,
|
||||
},
|
||||
})
|
||||
|
||||
expect(adaptiveWrapper.attributes('style')).toContain('width: 100%')
|
||||
expect(adaptiveWrapper.attributes('style')).toContain('height: 100%')
|
||||
})
|
||||
|
||||
it('keeps a 100k-point SVG path bounded by the plot width', async () => {
|
||||
const sourcePoints = Array.from({ length: 100_000 }, (_, index) => ({
|
||||
x: index / 1_000,
|
||||
y: index === 50_001 ? 100 : Math.sin(index / 50),
|
||||
}))
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'points', points: sourcePoints },
|
||||
{ rendering: { downsampleThreshold: 1_000, maxPointsPerPixel: 4 } },
|
||||
)
|
||||
const overlayWidth = Number(wrapper.get('.waveform-chart__overlay').attributes('width'))
|
||||
const path = wrapper.get('.waveform-chart__line').attributes('d') ?? ''
|
||||
const renderedPointCount = path.match(/[ML]/g)?.length ?? 0
|
||||
|
||||
expect(renderedPointCount).toBeGreaterThan(0)
|
||||
expect(renderedPointCount).toBeLessThanOrEqual(Math.floor(overlayWidth * 4) + 2)
|
||||
expect(path).toContain(',0')
|
||||
})
|
||||
|
||||
it('bounds dense decorations by pixel spacing while keeping one SVG path per series', async () => {
|
||||
const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
x: index,
|
||||
y: Math.sin(index / 20),
|
||||
error: 0.1,
|
||||
}))
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'dense-decorations',
|
||||
name: '密集标记',
|
||||
pointType: 'triangle',
|
||||
errorBar: { visible: true },
|
||||
data: { kind: 'points', points: sourcePoints },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ rendering: { pointMinSpacing: 10, errorBarMinSpacing: 12 } },
|
||||
)
|
||||
const overlayWidth = Number(wrapper.get('.waveform-chart__overlay').attributes('width'))
|
||||
const pointPaths = wrapper.findAll('.waveform-chart__point')
|
||||
const errorBarPaths = wrapper.findAll('.waveform-chart__error-bar')
|
||||
const pointCount = pointPaths[0]?.attributes('d')?.match(/M/g)?.length ?? 0
|
||||
const errorBarCount = (errorBarPaths[0]?.attributes('d')?.match(/M/g)?.length ?? 0) / 3
|
||||
|
||||
expect(pointPaths).toHaveLength(1)
|
||||
expect(errorBarPaths).toHaveLength(1)
|
||||
expect(pointCount).toBeLessThanOrEqual(Math.ceil(overlayWidth / 10) + 2)
|
||||
expect(errorBarCount).toBeLessThanOrEqual(Math.ceil(overlayWidth / 12) + 2)
|
||||
})
|
||||
|
||||
it('renders explicit points and supports a single point', async () => {
|
||||
const wrapper = await mountSizedChart({ kind: 'points', points: [{ x: 3, y: 8 }] })
|
||||
|
||||
expect(wrapper.get('.waveform-chart__line').attributes('d')).toContain('M')
|
||||
expect(wrapper.find('.waveform-chart__empty').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an empty state for empty and invalid data', async () => {
|
||||
const wrapper = await mountSizedChart({ kind: 'samples', values: [1], sampleRate: -1 })
|
||||
|
||||
expect(wrapper.get('.waveform-chart__empty').text()).toContain('暂无有效波形数据')
|
||||
expect(wrapper.find('.waveform-chart__line').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits the nearest point on hover and clears it on leave', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ grid: { rowCount: 1, columnCount: 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 MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||
expect(crosshairLines).toHaveLength(1)
|
||||
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
|
||||
expect(crosshairLines[0].attributes('y1')).toBe('0')
|
||||
expect(wrapper.find('.waveform-chart__crosshair circle').exists()).toBe(false)
|
||||
|
||||
await overlay.trigger('pointerleave')
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ grid: { rowCount: 1, columnCount: 1 }, showTooltip: false },
|
||||
)
|
||||
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 MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ showTooltip: true })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces pointer moves per frame and cancels pending hover work', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ grid: { rowCount: 1, columnCount: 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 }),
|
||||
})
|
||||
const emittedBeforeMove = wrapper.emitted('point-hover')?.length ?? 0
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 0, clientY: 100, bubbles: true }),
|
||||
)
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||
)
|
||||
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
expect(wrapper.emitted('point-hover')?.length ?? 0).toBe(emittedBeforeMove)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')).toHaveLength(emittedBeforeMove + 1)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 0, clientY: 100, bubbles: true }),
|
||||
)
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
await overlay.trigger('pointerleave')
|
||||
const emittedAfterLeave = wrapper.emitted('point-hover')?.length ?? 0
|
||||
expect(pendingAnimationFrameCount()).toBe(0)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')).toHaveLength(emittedAfterLeave)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 100, bubbles: true }),
|
||||
)
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
wrapper.unmount()
|
||||
expect(pendingAnimationFrameCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('isolates hover rendering from the chart, track, and waveform path subtrees', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ grid: { rowCount: 1, columnCount: 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 }),
|
||||
})
|
||||
|
||||
const track = wrapper.getComponent({ name: 'WaveformTrack' })
|
||||
const seriesLayer = wrapper.getComponent({ name: 'WaveformSeriesLayer' })
|
||||
const chartUpdate = vi.spyOn(wrapper.vm.$, 'update')
|
||||
const trackUpdate = vi.spyOn(track.vm.$, 'update')
|
||||
const seriesLayerUpdate = vi.spyOn(seriesLayer.vm.$, 'update')
|
||||
const pathBeforeHover = wrapper.get('.waveform-chart__line').element
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
|
||||
expect(chartUpdate).not.toHaveBeenCalled()
|
||||
expect(trackUpdate).not.toHaveBeenCalled()
|
||||
expect(seriesLayerUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user