/** * ChartExport — image-export failure vs not-ready (arch 05 §7 fail-loud). * * `getImageUrl` (injected by LivePreview) returns `null` ONLY when no view is * live — the not-ready case — and rejects on a real rasterize/serialize failure. * These two outcomes must land as DIFFERENT toasts: the not-ready one tells the * user to wait (no `detail`), the failure one carries diagnostic `detail` so the * problem can be reported. Burying the rejection behind the not-ready copy is the * regression this guards. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useNotificationStore } from '../stores/NotificationStore'; import { usePopoverStore } from '../stores/PopoverStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { ChartExport, type ChartExportProps } from './ChartExport'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLDivElement; let root: Root; beforeEach(() => { useNotificationStore.getState().clear(); usePopoverStore.setState({ openId: null }); useSnippetStore.getState().reset(); useDatasetStore.getState().reset(); // A non-empty draft makes `hasSpec` true so the export trigger is enabled. useSnippetStore.setState({ draftText: '{"mark":"point"}' }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); useNotificationStore.getState().clear(); usePopoverStore.setState({ openId: null }); useSnippetStore.getState().reset(); useDatasetStore.getState().reset(); }); /** Mount, open the export popover (portaled to ), and click "Download PNG". * `downloadImage` awaits `getImageUrl`, so the caller flushes microtasks after. */ async function mountAndDownloadPng(getImageUrl: ChartExportProps['getImageUrl']) { act(() => { root.render(); }); act(() => usePopoverStore.getState().show('chart-export')); const pngBtn = Array.from(document.body.querySelectorAll('button')).find( (b) => b.textContent?.trim() === 'Download PNG', ); if (!pngBtn) throw new Error('Download PNG button not found — popover did not render'); await act(async () => { pngBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); } describe('ChartExport image-export error handling', () => { test('a rejecting getImageUrl surfaces the failure with diagnostic detail', async () => { await mountAndDownloadPng(vi.fn().mockRejectedValue(new TypeError('tainted canvas'))); const toasts = useNotificationStore.getState().notifications; expect(toasts).toHaveLength(1); expect(toasts[0].kind).toBe('error'); // A real failure carries its diagnostic detail (not the "wait, it's not ready" copy). expect(toasts[0].detail).toBe('TypeError: tainted canvas'); expect(toasts[0].message).not.toMatch(/ready yet/i); }); test('a null getImageUrl reports not-ready, with no diagnostic detail', async () => { await mountAndDownloadPng(vi.fn().mockResolvedValue(null)); const toasts = useNotificationStore.getState().notifications; expect(toasts).toHaveLength(1); expect(toasts[0].kind).toBe('error'); expect(toasts[0].message).toMatch(/ready yet/i); // Nothing for the user to report — the not-ready case omits detail. expect(toasts[0].detail).toBeUndefined(); }); });