326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
|
|
import { flushPromises } from '@vue/test-utils'
|
||
|
|
import { describe, expect, it } from 'vitest'
|
||
|
|
|
||
|
|
import { flushAnimationFrames } from '../../test/setup'
|
||
|
|
import { type WaveformData } from '../waveform'
|
||
|
|
import WaveformChartView from '../WaveformChartView.vue'
|
||
|
|
|
||
|
|
import { gridSeries, mountSizedChart, visibilitySeries } from '../../test/waveformChart'
|
||
|
|
|
||
|
|
describe('WaveformChart', () => {
|
||
|
|
it('resolves legend positions by stable track id across pages', async () => {
|
||
|
|
const wrapper = await mountSizedChart(
|
||
|
|
{
|
||
|
|
kind: 'series',
|
||
|
|
series: Array.from({ length: 8 }, (_, index) => ({
|
||
|
|
id: `series-${index}`,
|
||
|
|
trackId: `frame-${Math.floor(index / 2)}`,
|
||
|
|
name: `series ${index}`,
|
||
|
|
data: {
|
||
|
|
kind: 'points' as const,
|
||
|
|
points: [
|
||
|
|
{ x: 0, y: index },
|
||
|
|
{ x: 1, y: index + 1 },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
grid: { rowCount: 2, columnCount: 1, showPagination: true },
|
||
|
|
legend: {
|
||
|
|
position: 'left',
|
||
|
|
orientation: 'auto',
|
||
|
|
trackPositions: {
|
||
|
|
'frame-0': 'top',
|
||
|
|
'frame-1': 'bottom-right',
|
||
|
|
'frame-2': 'bottom',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
const legendForTrack = (trackId: string) =>
|
||
|
|
wrapper.get(`[data-legend-track-id="${trackId}"] .waveform-chart__legend`)
|
||
|
|
|
||
|
|
expect(legendForTrack('frame-0').attributes('data-position')).toBe('top')
|
||
|
|
expect(legendForTrack('frame-0').attributes('data-orientation')).toBe('horizontal')
|
||
|
|
expect(legendForTrack('frame-1').attributes('data-position')).toBe('bottom-right')
|
||
|
|
expect(legendForTrack('frame-1').attributes('data-orientation')).toBe('vertical')
|
||
|
|
|
||
|
|
await wrapper.setProps({
|
||
|
|
legend: {
|
||
|
|
position: 'left',
|
||
|
|
orientation: 'vertical',
|
||
|
|
trackPositions: { 'frame-0': 'top', 'frame-1': 'bottom-right', 'frame-2': 'bottom' },
|
||
|
|
},
|
||
|
|
})
|
||
|
|
expect(legendForTrack('frame-0').attributes('data-orientation')).toBe('vertical')
|
||
|
|
|
||
|
|
await wrapper.get('.ant-pagination-next button').trigger('click')
|
||
|
|
expect(legendForTrack('frame-2').attributes('data-position')).toBe('bottom')
|
||
|
|
expect(legendForTrack('frame-2').attributes('data-orientation')).toBe('vertical')
|
||
|
|
expect(legendForTrack('frame-3').attributes('data-position')).toBe('left')
|
||
|
|
expect(legendForTrack('frame-3').attributes('data-orientation')).toBe('vertical')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('applies a configurable alpha background to every visible legend', async () => {
|
||
|
|
const wrapper = await mountSizedChart(
|
||
|
|
{
|
||
|
|
kind: 'series',
|
||
|
|
series: Array.from({ length: 4 }, (_, index) => ({
|
||
|
|
id: `series-${index}`,
|
||
|
|
trackId: `frame-${Math.floor(index / 2)}`,
|
||
|
|
name: `series ${index}`,
|
||
|
|
data: {
|
||
|
|
kind: 'points',
|
||
|
|
points: [
|
||
|
|
{ x: 0, y: index },
|
||
|
|
{ x: 1, y: index + 1 },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
grid: { rowCount: 2, columnCount: 1 },
|
||
|
|
legend: { backgroundColor: 'rgba(14, 165, 233, 0.25)' },
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
const legendPanels = wrapper.findAll('.waveform-legend__panel')
|
||
|
|
expect(legendPanels).toHaveLength(2)
|
||
|
|
legendPanels.forEach((panel) => {
|
||
|
|
expect(panel.attributes('style')).toContain('background-color: rgba(14, 165, 233, 0.25)')
|
||
|
|
})
|
||
|
|
|
||
|
|
await wrapper.setProps({ legend: { backgroundColor: '' } })
|
||
|
|
wrapper.findAll('.waveform-legend__panel').forEach((panel) => {
|
||
|
|
expect(panel.attributes('style')).toContain('background-color: rgba(255, 255, 255, 0.7)')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('keeps legends display-only unless interaction is explicitly enabled', async () => {
|
||
|
|
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||
|
|
grid: { rowCount: 1, columnCount: 1 },
|
||
|
|
})
|
||
|
|
|
||
|
|
const items = wrapper.findAll('.waveform-chart__legend-item')
|
||
|
|
expect(items).toHaveLength(2)
|
||
|
|
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||
|
|
expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain(
|
||
|
|
'waveform-legend__panel--interactive',
|
||
|
|
)
|
||
|
|
expect(wrapper.emitted('update:hidden-series-ids')).toBeUndefined()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('toggles series, axes, tooltips, and annotations from an interactive legend', async () => {
|
||
|
|
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||
|
|
annotations: [{ id: 'high-note', seriesId: 'high', x: 15, y: 1500, text: '高值' }],
|
||
|
|
grid: { rowCount: 1, columnCount: 1 },
|
||
|
|
legend: { interactive: true },
|
||
|
|
overlayMode: 'multi-axis',
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
|
||
|
|
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
|
||
|
|
const annotationLayer = wrapper.get('.waveform-annotation-layer').element
|
||
|
|
const legendLayer = wrapper.get('.waveform-chart__legend-layer').element
|
||
|
|
expect(
|
||
|
|
annotationLayer.compareDocumentPosition(legendLayer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||
|
|
).toBeTruthy()
|
||
|
|
const highLegendItem = wrapper.findAll('.waveform-chart__legend-item')[1]
|
||
|
|
expect(highLegendItem.attributes('aria-pressed')).toBe('true')
|
||
|
|
|
||
|
|
await highLegendItem.trigger('click')
|
||
|
|
await flushPromises()
|
||
|
|
|
||
|
|
expect(
|
||
|
|
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||
|
|
).toEqual(['low'])
|
||
|
|
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1)
|
||
|
|
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden')
|
||
|
|
expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe(
|
||
|
|
'false',
|
||
|
|
)
|
||
|
|
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([['high']])
|
||
|
|
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
|
||
|
|
{ seriesId: 'high', visible: false, hiddenSeriesIds: ['high'] },
|
||
|
|
])
|
||
|
|
|
||
|
|
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||
|
|
const overlayWidth = Number(overlay.attributes('width'))
|
||
|
|
const overlayHeight = Number(overlay.attributes('height'))
|
||
|
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||
|
|
value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
|
||
|
|
})
|
||
|
|
overlay.element.dispatchEvent(
|
||
|
|
new MouseEvent('pointermove', {
|
||
|
|
clientX: overlayWidth / 2,
|
||
|
|
clientY: overlayHeight / 2,
|
||
|
|
bubbles: true,
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
flushAnimationFrames()
|
||
|
|
await flushPromises()
|
||
|
|
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1)
|
||
|
|
expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程')
|
||
|
|
|
||
|
|
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
|
||
|
|
await flushPromises()
|
||
|
|
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2)
|
||
|
|
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
|
||
|
|
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
|
||
|
|
{ seriesId: 'high', visible: true, hiddenSeriesIds: [] },
|
||
|
|
])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('waits for controlled visibility updates and preserves unknown controlled IDs', async () => {
|
||
|
|
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||
|
|
grid: { rowCount: 1, columnCount: 1 },
|
||
|
|
hiddenSeriesIds: ['high', 'temporarily-absent'],
|
||
|
|
legend: { interactive: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(
|
||
|
|
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||
|
|
).toEqual(['low'])
|
||
|
|
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
|
||
|
|
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([
|
||
|
|
['high', 'temporarily-absent', 'low'],
|
||
|
|
])
|
||
|
|
expect(
|
||
|
|
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||
|
|
).toEqual(['low'])
|
||
|
|
|
||
|
|
await wrapper.setProps({ hiddenSeriesIds: ['low'] })
|
||
|
|
await flushPromises()
|
||
|
|
expect(
|
||
|
|
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||
|
|
).toEqual(['high'])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => {
|
||
|
|
const original = visibilitySeries()
|
||
|
|
const wrapper = await mountSizedChart(original, {
|
||
|
|
grid: { rowCount: 1, columnCount: 1 },
|
||
|
|
legend: { interactive: true },
|
||
|
|
})
|
||
|
|
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
|
||
|
|
|
||
|
|
const reversed: WaveformData = {
|
||
|
|
kind: 'series',
|
||
|
|
series: [...(original.kind === 'series' ? original.series : [])].reverse(),
|
||
|
|
}
|
||
|
|
await wrapper.setProps({ data: reversed })
|
||
|
|
await flushPromises()
|
||
|
|
expect(
|
||
|
|
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||
|
|
).toEqual(['low'])
|
||
|
|
|
||
|
|
await wrapper.setProps({
|
||
|
|
data: {
|
||
|
|
kind: 'series',
|
||
|
|
series: original.kind === 'series' ? [original.series[0]!] : [],
|
||
|
|
},
|
||
|
|
})
|
||
|
|
await flushPromises()
|
||
|
|
await wrapper.setProps({ data: original })
|
||
|
|
await flushPromises()
|
||
|
|
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => {
|
||
|
|
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||
|
|
defaultHiddenSeriesIds: ['low', 'high'],
|
||
|
|
grid: { rowCount: 1, columnCount: 1 },
|
||
|
|
legend: { interactive: true },
|
||
|
|
overlayMode: 'multi-axis',
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2)
|
||
|
|
expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线')
|
||
|
|
|
||
|
|
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
|
||
|
|
await flushPromises()
|
||
|
|
expect(
|
||
|
|
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||
|
|
).toEqual(['low'])
|
||
|
|
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__overlay--independent')).toHaveLength(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('closes an annotation editor when its series is hidden from the legend', async () => {
|
||
|
|
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||
|
|
grid: { rowCount: 1, columnCount: 1 },
|
||
|
|
legend: { interactive: true },
|
||
|
|
})
|
||
|
|
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||
|
|
const overlayWidth = Number(overlay.attributes('width'))
|
||
|
|
const overlayHeight = Number(overlay.attributes('height'))
|
||
|
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||
|
|
value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
|
||
|
|
})
|
||
|
|
overlay.element.dispatchEvent(
|
||
|
|
new MouseEvent('contextmenu', {
|
||
|
|
clientX: overlayWidth * 0.75,
|
||
|
|
clientY: overlayHeight / 2,
|
||
|
|
bubbles: true,
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
flushAnimationFrames()
|
||
|
|
await flushPromises()
|
||
|
|
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
|
||
|
|
|
||
|
|
const controller = wrapper.getComponent(WaveformChartView).props('controller') as unknown as {
|
||
|
|
annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } }
|
||
|
|
}
|
||
|
|
const draftSeriesId = controller.annotationInteraction.editorDraft.value?.annotation.seriesId
|
||
|
|
const item = wrapper
|
||
|
|
.findAll('.waveform-chart__legend-item')
|
||
|
|
.find((legendItem) =>
|
||
|
|
legendItem.text().includes(draftSeriesId === 'high' ? '高量程' : '低量程'),
|
||
|
|
)
|
||
|
|
expect(item).toBeDefined()
|
||
|
|
await item!.trigger('click')
|
||
|
|
await flushPromises()
|
||
|
|
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders independent cells with separate x axes and overlays', async () => {
|
||
|
|
const wrapper = await mountSizedChart(gridSeries(4), {
|
||
|
|
displayMode: 'independent',
|
||
|
|
grid: { rowCount: 2, columnCount: 2 },
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(wrapper.findAll('.waveform-chart__track')).toHaveLength(4)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(4)
|
||
|
|
expect(wrapper.findAll('.waveform-chart__overlay--independent')).toHaveLength(4)
|
||
|
|
const tracks = wrapper.findAll('.waveform-chart__track')
|
||
|
|
const firstTop = Number(tracks[0].attributes('data-track-top'))
|
||
|
|
const secondRowTop = Number(tracks[2].attributes('data-track-top'))
|
||
|
|
const firstHeight = Number(tracks[0].attributes('data-track-height'))
|
||
|
|
expect(secondRowTop).toBeGreaterThan(firstTop + firstHeight + 20)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('reserves horizontal clearance for Y-axis labels in multi-column grids', async () => {
|
||
|
|
for (const displayMode of ['independent', 'separated', 'compact'] as const) {
|
||
|
|
const wrapper = await mountSizedChart(gridSeries(4), {
|
||
|
|
displayMode,
|
||
|
|
grid: { rowCount: 2, columnCount: 2 },
|
||
|
|
})
|
||
|
|
const tracks = wrapper.findAll('.waveform-chart__track')
|
||
|
|
const firstLeft = Number(tracks[0].attributes('data-track-left') ?? 0)
|
||
|
|
const firstWidth = Number(tracks[0].attributes('data-track-width'))
|
||
|
|
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||
|
|
const labelX = Number(tracks[1].attributes('data-y-axis-label-x'))
|
||
|
|
|
||
|
|
expect(secondLeft - (firstLeft + firstWidth)).toBeGreaterThanOrEqual(Math.abs(labelX) + 16)
|
||
|
|
expect(tracks[1].find('.waveform-chart__y-axis-label-bg').exists()).toBe(true)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|