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') }) })