// @vitest-environment happy-dom // // DashboardGrid contract coverage: // - normalizeGroups (pure): flat widgets → ordered groups by group/order. // - render per kind: stat/list/progress paint their value; chart kinds paint // the card chrome (title) — recharts itself isn't exercised in jsdom. // - skeletons while loading, then real content after the batch resolves. // - global empty state when there are no widgets. // - permission gating via (and isAdmin bypass). // - isolated per-widget error: a throwing renderer shows its error card and // the sibling widgets still render. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' // react-i18next: identity translator (returns the key) so specs' raw i18n keys // surface verbatim and we can assert on them. vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }), })) // recharts' ResponsiveContainer relies on ResizeObserver; stub it for happy-dom. class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} } ;(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub afterEach(cleanup) import { DashboardGrid, normalizeGroups } from '../dashboard-grid' import { PermissionsProvider } from '../permissions-context' import type { DashboardWidgetSpec, WidgetData, } from '../dashboard-types' const spec = (over: Partial): DashboardWidgetSpec => ({ key: 'k', title: 'Title', kind: 'stat', ...over, }) const loaderOf = (map: Record) => async (keys: string[]) => { const out: Record = {} for (const k of keys) if (map[k]) out[k] = map[k] return out } describe('normalizeGroups', () => { it('returns explicit groups untouched', () => { const groups = [{ title: 'g1', widgets: [spec({ key: 'a' })] }] expect(normalizeGroups(groups, undefined)).toBe(groups) }) it('groups flat widgets by `group` and sorts each by `order`', () => { const widgets = [ spec({ key: 'b', group: 'sales', order: 20 }), spec({ key: 'a', group: 'sales', order: 10 }), spec({ key: 'c', group: 'stock' }), ] const out = normalizeGroups(undefined, widgets) expect(out.map((g) => g.title)).toEqual(['sales', 'stock']) expect(out[0].widgets.map((w) => w.key)).toEqual(['a', 'b']) }) it('returns [] when there is nothing', () => { expect(normalizeGroups(undefined, [])).toEqual([]) }) }) describe('DashboardGrid render', () => { it('shows a skeleton while loading then the stat value', async () => { let resolve!: (v: Record) => void const loadData = vi.fn( () => new Promise>((r) => (resolve = r)), ) render( , ) // skeleton up first expect(screen.getByTestId('widget-skeleton-rev')).toBeTruthy() resolve({ rev: { value: 42 } }) await waitFor(() => expect(screen.getByTestId('widget-rev')).toBeTruthy(), ) expect(screen.getByText('42')).toBeTruthy() }) it('renders a currency stat with the org currency + delta chip', async () => { render( , ) await waitFor(() => expect(screen.getByTestId('widget-rev')).toBeTruthy()) // MX$1,000.00 (en-US + MXN) — assert the currency symbol + amount present expect(screen.getByText(/1,000/)).toBeTruthy() expect(screen.getByText(/\+14\.2%/)).toBeTruthy() }) it('renders a list widget with each bucket label + value', async () => { render( , ) await waitFor(() => expect(screen.getByText('Alpha')).toBeTruthy()) expect(screen.getByText('Beta')).toBeTruthy() }) it('paints the card chrome (title) for chart kinds', async () => { render( , ) await waitFor(() => expect(screen.getByTestId('widget-chart')).toBeTruthy()) expect(screen.getByText('My Chart')).toBeTruthy() }) it('shows the per-widget empty state when data is missing', async () => { render( , ) await waitFor(() => expect(screen.getByTestId('widget-empty')).toBeTruthy()) expect(screen.getByText('No stock')).toBeTruthy() }) it('shows the global empty state when there are no widgets', () => { render() expect(screen.getByTestId('dashboard-empty')).toBeTruthy() }) it('renders the static full-width skeleton stack (decorative) in the empty state', () => { render() const mock = screen.getByTestId('dashboard-empty-mockup') expect(mock).toBeTruthy() expect(screen.getByTestId('dashboard-empty-mockup-stack')).toBeTruthy() // Purely decorative → hidden from the a11y tree. expect(mock.getAttribute('aria-hidden')).toBe('true') // Shimmer styles still inject once (no reflow keyframes required). expect(document.getElementById('mc-dashboard-empty-mockup-style')).toBeTruthy() // No caption text — avoids shipping un-localized copy from the package. expect(screen.queryByText(/dashboard/i)).toBeNull() }) it('survives an empty → populated transition (React #310 regression)', async () => { // The flatten/order useMemo must run BEFORE the empty-state early return. // When it sat after the return, an empty render called one fewer hook // than the populated render → "Rendered more hooks" (React #310) crash. const { rerender } = render( , ) expect(screen.getByTestId('dashboard-empty')).toBeTruthy() rerender( , ) await waitFor(() => expect(screen.getByTestId('widget-rev')).toBeTruthy()) expect(screen.getByText('5')).toBeTruthy() }) }) describe('DashboardGrid permission gating', () => { it('without a PermissionsProvider every widget is visible', async () => { render( , ) await waitFor(() => expect(screen.getByTestId('widget-p')).toBeTruthy()) }) it('hides widgets whose permission is not granted', () => { render( , ) // gated out → whole grid is empty → global empty state expect(screen.queryByTestId('widget-skeleton-p')).toBeNull() expect(screen.getByTestId('dashboard-empty')).toBeTruthy() }) it('shows granted widgets and respects isAdmin bypass', async () => { render( , ) await waitFor(() => expect(screen.getByTestId('widget-p')).toBeTruthy()) }) }) describe('DashboardGrid error isolation', () => { it('a throwing widget shows its error card; siblings still render', async () => { // An unknown kind without a renderer falls to the error card path. render( , ) await waitFor(() => expect(screen.getByTestId('widget-ok')).toBeTruthy()) // sibling renders its value expect(screen.getByText('9')).toBeTruthy() // broken one shows the error card (not crashing the grid) expect(screen.getByTestId('widget-bad')).toBeTruthy() expect(screen.getByText('Could not load this widget.')).toBeTruthy() }) })