import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PlotlyChart from '../../components/PlotlyChart.vue'
const plotly = vi.hoisted(() => ({
Plots: { resize: vi.fn() },
purge: vi.fn(),
react: vi.fn(),
}))
vi.mock('plotly.js-dist-min', () => ({ default: plotly }))
enableAutoUnmount(afterEach)
describe('PlotlyChart', () => {
beforeEach(() => {
vi.clearAllMocks()
plotly.react.mockReset().mockResolvedValue(undefined)
plotly.Plots.resize.mockReset().mockResolvedValue(undefined)
document.documentElement.classList.remove('dark')
})
afterEach(() => {
vi.unstubAllGlobals()
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('style')
})
it('should merge MINT defaults with native Plotly props', async () => {
const trace = { type: 'scatter' as const, x: [1], y: [2] }
mount(PlotlyChart, {
props: {
data: [trace],
layout: { title: { text: 'QC' } },
config: { toImageButtonOptions: { filename: 'qc-export' } },
},
})
await flushPromises()
expect(plotly.react).toHaveBeenCalledWith(
expect.any(HTMLElement),
[trace],
expect.objectContaining({
autosize: true,
title: { text: 'QC' },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
}),
expect.objectContaining({
responsive: true,
displaylogo: false,
toImageButtonOptions: { filename: 'qc-export' },
}),
)
})
it('should render the shared chart empty state when explicitly empty', async () => {
const wrapper = mount(PlotlyChart, {
props: { data: [], empty: true, emptyMessage: 'No peaks found' },
})
await flushPromises()
expect(wrapper.text()).toContain('No peaks found')
})
it('should show a safe error when Plotly rejects rendering', async () => {
plotly.react.mockRejectedValue(new Error(''))
const wrapper = mount(PlotlyChart, { props: { data: [] } })
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('Unable to display this chart.')
})
it('should rerender when immutable native props change', async () => {
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
await flushPromises()
await wrapper.setProps({ data: [{ x: [3], y: [4] }] })
await flushPromises()
expect(plotly.react).toHaveBeenCalledTimes(2)
})
// Plotly fills the hover name box from plot/paper bgcolor over white; no hoverlabel option reaches it.
it('should paint the hover label name box with the card surface token', () => {
const source = readFileSync(resolve(process.cwd(), 'src/components/PlotlyChart.vue'), 'utf8')
const rule = source.match(/:deep\(\.hovertext rect\)\s*\{([^}]*)\}/)?.[1]
expect(rule).toContain('fill: var(--bg-card) !important')
})
it('should rerender when the MINT dark theme changes', async () => {
let themeChanged: MutationCallback | undefined
vi.stubGlobal('MutationObserver', class {
constructor(callback: MutationCallback) {
themeChanged = callback
}
observe(): void {}
disconnect(): void {}
takeRecords(): MutationRecord[] { return [] }
})
mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
await flushPromises()
document.documentElement.classList.add('dark')
themeChanged?.([], {} as MutationObserver)
await flushPromises()
expect(plotly.react).toHaveBeenCalledTimes(2)
})
it('should resize the Plotly canvas when its container changes size', async () => {
let resize: ResizeObserverCallback | undefined
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) {
resize = callback
}
observe(): void {}
disconnect(): void {}
})
mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
await flushPromises()
resize?.([], {} as ResizeObserver)
expect(plotly.Plots.resize).toHaveBeenCalledTimes(1)
})
it('should observe the Plotly canvas after leaving the empty state', async () => {
const observe = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe = observe
disconnect(): void {}
})
const wrapper = mount(PlotlyChart, {
props: { data: [], empty: true },
})
await flushPromises()
await wrapper.setProps({ empty: false })
await flushPromises()
expect(observe).toHaveBeenCalledWith(expect.any(HTMLElement))
})
it('should render once when data arrives as loading completes', async () => {
const wrapper = mount(PlotlyChart, {
props: { data: [], loading: true },
})
await flushPromises()
await wrapper.setProps({ data: [{ x: [1], y: [2] }], loading: false })
await flushPromises()
expect(plotly.react).toHaveBeenCalledTimes(1)
})
it('should serialize renders and use the latest props after an in-flight render', async () => {
let finish!: () => void
plotly.react.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
await flushPromises()
await wrapper.setProps({ data: [{ x: [2] }] })
await wrapper.setProps({ data: [{ x: [3] }] })
await flushPromises()
expect(plotly.react).toHaveBeenCalledTimes(1)
finish()
await flushPromises()
expect(plotly.react).toHaveBeenCalledTimes(2)
expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [3] }])
})
it('should skip rendering when unmounted before the initial DOM update', async () => {
const wrapper = mount(PlotlyChart, { props: { data: [] } })
wrapper.unmount()
await flushPromises()
expect(plotly.react).not.toHaveBeenCalled()
})
it('should recover from a stale failure without overwriting the newer render', async () => {
let reject!: (error: Error) => void
plotly.react.mockImplementationOnce(() => new Promise((_resolve, fail) => { reject = fail }))
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
await flushPromises()
await wrapper.setProps({ data: [{ x: [2] }] })
reject(new Error('Old render failed'))
await flushPromises()
expect(plotly.react).toHaveBeenCalledTimes(2)
expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [2] }])
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('should purge an in-flight canvas removed by loading and then render its replacement', async () => {
let finish!: () => void
plotly.react.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
const wrapper = mount(PlotlyChart, { props: { data: [] } })
await flushPromises()
const element = wrapper.get('.mint-plotly-chart__plot').element
await wrapper.setProps({ loading: true })
await wrapper.setProps({ loading: false, data: [{ x: [3] }] })
finish()
await flushPromises()
expect(plotly.purge).toHaveBeenCalledWith(element)
expect(plotly.react).toHaveBeenCalledTimes(2)
expect(plotly.react.mock.lastCall?.[0]).toBe(wrapper.get('.mint-plotly-chart__plot').element)
expect(plotly.react.mock.lastCall?.[0]).not.toBe(element)
})
it('should clean up a render that completes after unmount', async () => {
let finish!: () => void
plotly.react.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
const wrapper = mount(PlotlyChart, { props: { data: [] } })
await flushPromises()
const element = wrapper.get('.mint-plotly-chart__plot').element
wrapper.unmount()
plotly.purge.mockClear()
finish()
await flushPromises()
expect(plotly.purge).toHaveBeenCalledWith(element)
expect(plotly.react).toHaveBeenCalledTimes(1)
})
it('should purge the removed canvas and render new data after loading or empty states', async () => {
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
unobserve = vi.fn()
disconnect = disconnect
})
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
await flushPromises()
for (const state of ['loading', 'empty'] as const) {
const element = wrapper.get('.mint-plotly-chart__plot').element
await wrapper.setProps({ [state]: true })
await flushPromises()
expect(plotly.purge).toHaveBeenCalledWith(element)
expect(disconnect).toHaveBeenCalled()
await wrapper.setProps({ [state]: false, data: [{ x: [2] }] })
await flushPromises()
expect(wrapper.get('.mint-plotly-chart__plot').element).not.toBe(element)
}
expect(plotly.react).toHaveBeenCalledTimes(3)
})
it('should use MINT colors and protect reactive native data and nested layout from Plotly mutations', async () => {
document.documentElement.style.setProperty('--text-primary', '#f8fafc')
document.documentElement.style.setProperty('--border-color', '#334155')
const data = reactive([{ x: [1], y: [2] }])
const layout = reactive({ title: { text: 'Original' }, xaxis: { title: { text: 'Sample' } } })
plotly.react.mockImplementationOnce((_element, traces, options) => {
traces[0].x.push(9)
options.title.text = 'Changed'
options.xaxis.title.text = 'Changed'
return Promise.resolve()
})
mount(PlotlyChart, { attachTo: document.body, props: { data, layout } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[2].font.color).toBe('#f8fafc')
expect(plotly.react.mock.lastCall?.[2].xaxis.linecolor).toBe('#334155')
expect(data[0].x).toEqual([1])
expect(layout.title.text).toBe('Original')
expect(layout.xaxis.title.text).toBe('Sample')
})
it('should accept nested reactive arrays, typed arrays, dates, and native export callbacks', async () => {
const x = reactive([new Date('2026-01-01')])
const y = new Float64Array([2])
const click = vi.fn()
mount(PlotlyChart, {
props: {
data: [{ x, y }],
config: { modeBarButtonsToAdd: [{ name: 'Export', title: 'Export', icon: 'camera', click }] },
},
})
await flushPromises()
expect(plotly.react).toHaveBeenCalledOnce()
const [, traces, , config] = plotly.react.mock.lastCall!
expect(traces[0].x).toEqual(x)
expect(traces[0].x).not.toBe(x)
expect(traces[0].x[0]).toBeInstanceOf(Date)
expect(traces[0].y).toEqual(y)
expect(traces[0].y).not.toBe(y)
expect(config.modeBarButtonsToAdd[0].click).toBe(click)
})
it('should ignore resize rejection when the canvas becomes hidden', async () => {
let resize!: ResizeObserverCallback
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { resize = callback }
observe(): void {}
disconnect(): void {}
})
plotly.Plots.resize.mockRejectedValueOnce(new Error('Resize must be passed a displayed plot div'))
const wrapper = mount(PlotlyChart, { props: { data: [] } })
await flushPromises()
resize([], {} as ResizeObserver)
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('should purge the Plotly canvas when unmounted', async () => {
const wrapper = mount(PlotlyChart, { props: { data: [] } })
await flushPromises()
wrapper.unmount()
expect(plotly.purge).toHaveBeenCalledTimes(1)
})
it('should use an injected Plotly build and forward point clicks', async () => {
let clickHandler: ((event: unknown) => void) | undefined
const custom = {
Plots: { resize: vi.fn() },
purge: vi.fn(),
react: vi.fn(async (element: HTMLElement) => {
Object.assign(element, {
on: (_name: string, handler: (event: unknown) => void) => { clickHandler = handler },
removeAllListeners: vi.fn(),
})
}),
}
const wrapper = mount(PlotlyChart, {
props: {
data: [{ x: [1] }],
plotly: custom as never,
clickEvents: true,
height: 320,
variant: 'frame',
},
slots: { header: 'Chart header', footer: 'Time (min)' },
})
await flushPromises()
expect(custom.react).toHaveBeenCalledOnce()
expect(plotly.react).not.toHaveBeenCalled()
expect(wrapper.get('.mint-plotly-chart__plot').attributes('style')).toContain('height: 320px')
expect(wrapper.get('.mint-chart').classes()).toContain('mint-chart--frame')
expect(wrapper.text()).toContain('Chart header')
expect(wrapper.text()).toContain('Time (min)')
const event = { points: [] }
clickHandler?.(event)
expect(wrapper.emitted('plotly-click')?.[0]?.[0]).toBe(event)
})
it('should wait to render until an inactive chart becomes active', async () => {
const wrapper = mount(PlotlyChart, {
props: { data: [{ x: [1] }], active: false },
})
await flushPromises()
expect(plotly.react).not.toHaveBeenCalled()
await wrapper.setProps({ active: true })
await flushPromises()
expect(plotly.react).toHaveBeenCalledOnce()
})
})
describe('PlotlyChart MINT template and header legend (9a)', () => {
const traces = [
{ type: 'scatter' as const, name: 'Control', x: [1], y: [2] },
{ type: 'scatter' as const, name: 'Treatment A', x: [1], y: [3] },
]
beforeEach(() => {
vi.clearAllMocks()
plotly.react.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.unstubAllGlobals()
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('style')
})
it('should apply the MINT colorway when the caller sets none', async () => {
document.documentElement.style.setProperty('--mint-sample-1', '#3B82F6')
mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[2].colorway[0]).toBe('#3B82F6')
})
it('should keep the caller colorway when the layout sets one', async () => {
mount(PlotlyChart, { props: { data: traces, layout: { colorway: ['#000000'] } } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[2].colorway).toEqual(['#000000'])
})
it('should hide the modebar when the config does not set it', async () => {
mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[3].displayModeBar).toBe(false)
})
it('should show the modebar when the config enables it', async () => {
mount(PlotlyChart, { props: { data: traces, config: { displayModeBar: true } } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[3].displayModeBar).toBe(true)
})
it('should hide the Plotly Cloud upload button when the modebar is shown', async () => {
mount(PlotlyChart, { props: { data: traces, config: { displayModeBar: true } } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[3].showSendToCloud).toBe(false)
})
it('should list each trace in the header legend when there are several traces', async () => {
const wrapper = mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
expect(wrapper.findAll('.mint-chart__header .mint-chart__legend-item').map(item => item.text()))
.toEqual(['Control', 'Treatment A'])
})
it('should hide the Plotly legend when the header legend is shown', async () => {
mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
expect(plotly.react.mock.lastCall?.[2].showlegend).toBe(false)
})
it('should hide a trace when its header legend item is clicked', async () => {
const wrapper = mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
await wrapper.findAll('.mint-chart__legend-item')[1].trigger('click')
await flushPromises()
expect(plotly.react.mock.lastCall?.[1][1].visible).toBe('legendonly')
})
it('should not render a header legend when there is a single trace', async () => {
const wrapper = mount(PlotlyChart, { props: { data: [traces[0]] } })
await flushPromises()
expect(wrapper.find('.mint-chart__legend-item').exists()).toBe(false)
})
it('should keep the Plotly legend when headerLegend is false', async () => {
const wrapper = mount(PlotlyChart, { props: { data: traces, headerLegend: false } })
await flushPromises()
expect(wrapper.find('.mint-chart__legend-item').exists()).toBe(false)
})
it('should keep the Plotly legend when the layout asks for it', async () => {
const wrapper = mount(PlotlyChart, { props: { data: traces, layout: { showlegend: true } } })
await flushPromises()
expect(wrapper.find('.mint-chart__legend-item').exists()).toBe(false)
})
it('should not render a header legend when the layout turns the legend off', async () => {
const wrapper = mount(PlotlyChart, { props: { data: traces, layout: { showlegend: false } } })
await flushPromises()
expect(wrapper.find('.mint-chart__legend-item').exists()).toBe(false)
})
it('should not hide a different trace when the data changes after a trace was hidden', async () => {
const wrapper = mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
await wrapper.findAll('.mint-chart__legend-item')[1].trigger('click')
await wrapper.setProps({ data: [traces[1], { ...traces[0], name: 'Vehicle' }] })
await flushPromises()
expect(plotly.react.mock.lastCall?.[1][1].visible).toBeUndefined()
})
it('should hide only the clicked trace when two traces share a name', async () => {
const wrapper = mount(PlotlyChart, { props: { data: [{ ...traces[0], name: 'Replicate' }, { ...traces[1], name: 'Replicate' }] } })
await flushPromises()
await wrapper.findAll('.mint-chart__legend-item')[1].trigger('click')
await flushPromises()
expect(plotly.react.mock.lastCall?.[1][0].visible).toBeUndefined()
})
it('should show a legendonly trace when its header legend item is clicked', async () => {
const wrapper = mount(PlotlyChart, { props: { data: [traces[0], { ...traces[1], visible: 'legendonly' as const }] } })
await flushPromises()
await wrapper.findAll('.mint-chart__legend-item')[1].trigger('click')
await flushPromises()
expect(plotly.react.mock.lastCall?.[1][1].visible).toBe(true)
})
it('should re-read the group tokens when the theme class changes', async () => {
let themeChanged: MutationCallback | undefined
vi.stubGlobal('MutationObserver', class {
constructor(callback: MutationCallback) { themeChanged = callback }
observe(): void {}
disconnect(): void {}
takeRecords(): MutationRecord[] { return [] }
})
document.documentElement.style.setProperty('--mint-sample-1', '#3B82F6')
mount(PlotlyChart, { props: { data: traces } })
await flushPromises()
document.documentElement.classList.add('dark')
document.documentElement.style.setProperty('--mint-sample-1', '#60A5FA')
themeChanged?.([], {} as MutationObserver)
await flushPromises()
expect(plotly.react.mock.lastCall?.[2].colorway[0]).toBe('#60A5FA')
})
})