import { describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen, within } from '@testing-library/react' import { CategoryUI } from './category-ui' describe('', () => { // ── Rendering basics ──────────────────────────────────────────────── describe('rendering', () => { it('renders one row per item with name + value', () => { render( , ) expect(screen.getByText('A')).toBeTruthy() expect(screen.getByText('B')).toBeTruthy() expect(screen.getByText('10')).toBeTruthy() expect(screen.getByText('20')).toBeTruthy() expect(screen.getAllByRole('button')).toHaveLength(2) }) it('returns null when data is empty (no rows, no container)', () => { const { container } = render() expect(container.firstChild).toBeNull() }) it('returns null when all series are empty', () => { const { container } = render() expect(container.firstChild).toBeNull() }) }) // ── maxItems + "Other" overflow ──────────────────────────────────── describe('maxItems + Other row', () => { const oneToTwelve = () => Array.from({ length: 12 }, (_, i) => ({ name: `n${i}`, value: 12 - i, })) it('defaults to 20 → no Other row when data fits', () => { render() expect(screen.queryByText(/more/)).toBeNull() expect(screen.getAllByRole('button')).toHaveLength(12) }) it('default caps at 20 when data exceeds it → "Others " footer', () => { const twentyFive = Array.from({ length: 25 }, (_, i) => ({ name: `n${i}`, value: 25 - i, })) render() expect(screen.getAllByRole('button')).toHaveLength(20) expect(screen.getByText('Others')).toBeTruthy() expect(screen.getByText('5')).toBeTruthy() }) it('maxItems=null → no cap, no scroll (canonical form)', () => { // Explicit opt-out from both the default cap AND the searcher's // scroll viewport. `null` is required because `undefined` resolves // to the 20-row default-parameter value. const twentyFive = Array.from({ length: 25 }, (_, i) => ({ name: `n${i}`, value: 25 - i, })) const { container } = render( , ) expect(screen.queryByText('Others')).toBeNull() expect(screen.getAllByRole('button')).toHaveLength(25) const root = container.firstChild as HTMLElement const list = root.firstChild as HTMLElement expect(list.style.maxHeight).toBe('') expect(list.style.overflowY).toBe('') }) it('maxItems=Infinity also yields no cap, no scroll (alias)', () => { const twentyFive = Array.from({ length: 25 }, (_, i) => ({ name: `n${i}`, value: 25 - i, })) render( , ) expect(screen.queryByText('Others')).toBeNull() expect(screen.getAllByRole('button')).toHaveLength(25) }) it('slices to maxItems and shows "Others " footer', () => { render() expect(screen.getAllByRole('button')).toHaveLength(5) expect(screen.getByText('Others')).toBeTruthy() expect(screen.getByText('7')).toBeTruthy() }) it('onShowAll makes the Other row a button that fires the callback', () => { const onShowAll = vi.fn() render( , ) // 5 category rows + the now-interactive "Others" row = 6 buttons. const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(6) fireEvent.click(screen.getByText('Others')) expect(onShowAll).toHaveBeenCalledTimes(1) }) it('maxItems=0 is the no-cap-with-scroll sentinel: every row renders inside a fixed scroll viewport', () => { // `0` is the composer-driven "disable pagination" sentinel — // used e.g. when the SearcherToggle is open so the search filter // has the full dataset to chew through. The "Other" footer is // suppressed because nothing is hidden, and the list locks to a // scrollable viewport so the widget card height stays stable. const { container } = render( , ) expect(screen.getAllByRole('button')).toHaveLength(12) expect(screen.queryByText('Others')).toBeNull() expect(screen.queryByText(/more/)).toBeNull() // List has the scroll viewport inline style applied. const root = container.firstChild as HTMLElement const list = root.firstChild as HTMLElement expect(list.style.maxHeight).not.toBe('') expect(list.style.overflowY).toBe('auto') }) // ── Natural-height measurement ──────────────────────────────── // // Scroll mode (`maxItems === 0`) sizes its viewport from the // list's `clientHeight` measured on the first render where the // list was NOT in scroll mode (i.e., the capped view). jsdom's // layout engine reports `clientHeight === 0`, so the tests stub // it on `HTMLDivElement.prototype` for the renders they care // about. describe('scroll mode height (measure-once)', () => { const stubClientHeight = (value: number) => { Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', { configurable: true, get: () => value, }) } const restoreClientHeight = () => { // Delete the stub so other tests aren't affected. delete (HTMLDivElement.prototype as { clientHeight?: unknown }) .clientHeight } it('locks scroll viewport to the capped view height after the user toggles into scroll mode', () => { stubClientHeight(240) try { const { rerender, container } = render( , ) // First render: capped, no scroll lock. Effect measures 240. const root = container.firstChild as HTMLElement const list = root.firstChild as HTMLElement expect(list.style.maxHeight).toBe('') // Composer flips to scroll mode → viewport uses the measured // 240px, NOT the hard-coded fallback. rerender() expect(list.style.maxHeight).toBe('240px') expect(list.style.overflowY).toBe('auto') } finally { restoreClientHeight() } }) it('freezes the lock anchor: subsequent re-measures while in scroll mode are ignored', () => { stubClientHeight(180) try { const { rerender, container } = render( , ) const root = container.firstChild as HTMLElement const list = root.firstChild as HTMLElement rerender() expect(list.style.maxHeight).toBe('180px') // Change the stub mid-flight. The lock must hold — measurement // is one-shot, taken on the first non-scroll render. stubClientHeight(999) rerender() expect(list.style.maxHeight).toBe('180px') } finally { restoreClientHeight() } }) it('falls back to 8 × ROW_HEIGHT_PX[size] when mounted directly into scroll mode (no capped render to measure)', () => { // No stub — clientHeight reads 0 in jsdom by default, so the // effect's `if (h > 0)` guard skips the setState. The viewport // must still have a sane size: 36 × 8 = 288px for small. const { container } = render( , ) const root = container.firstChild as HTMLElement const list = root.firstChild as HTMLElement expect(list.style.maxHeight).toBe('288px') expect(list.style.overflowY).toBe('auto') }) it('uses the same fallback for multi-series widgets (no seriesCount multiplier)', () => { // Regression: a 3-series widget mounted directly into scroll // mode previously rendered at 3× the intended viewport (864px) // because the fallback was multiplied by `seriesCount`. The // fallback should be 288px regardless of series count. const threeSeries = [oneToTwelve(), oneToTwelve(), oneToTwelve()] const { container } = render( , ) const root = container.firstChild as HTMLElement const list = root.firstChild as HTMLElement expect(list.style.maxHeight).toBe('288px') }) }) it('honors custom labels.other + labels.otherCount with {count} substitution', () => { render( , ) expect(screen.getByText('Others')).toBeTruthy() expect(screen.getByText('+9 hidden')).toBeTruthy() }) it('large maxItems = no Other row', () => { render() expect(screen.queryByText(/more/)).toBeNull() expect(screen.getAllByRole('button')).toHaveLength(12) }) }) // ── Multi-series grouped rendering ───────────────────────────────── describe('multi-series', () => { const twoSeries = () => [ [ { name: 'A', value: 10 }, { name: 'B', value: 20 }, ], [ { name: 'A', value: 5 }, { name: 'B', value: 8 }, ], ] it('renders one row per unique name across series', () => { render() // 2 unique names → 2 row buttons (not 4 like a per-series layout would give). expect(screen.getAllByRole('button')).toHaveLength(2) expect(screen.getByText('A')).toBeTruthy() expect(screen.getByText('B')).toBeTruthy() }) it('renders both values per row, formatted', () => { render( `$${n}`} />) const rowA = screen.getAllByRole('button')[0]! expect(within(rowA).getByText('A')).toBeTruthy() expect(within(rowA).getByText('$10')).toBeTruthy() expect(within(rowA).getByText('$5')).toBeTruthy() }) it('clicking a multi-row toggles by name with raw key', () => { const onSelectionChange = vi.fn() render( , ) fireEvent.click(screen.getAllByRole('button')[1]!) expect(onSelectionChange).toHaveBeenCalledWith(['B']) }) it('falls back gracefully when data.length > series.length (extra series unlabeled)', () => { render() // 2 rows still render (data.length = 2 is the truth). expect(screen.getAllByRole('button')).toHaveLength(2) // Legend has just one entry. expect(screen.getByText('Only-One')).toBeTruthy() }) it('falls back gracefully when series.length > data.length (extras ignored, no legend overflow)', () => { render( , ) // data.length === 1 → single-series layout, no legend. expect(screen.queryByText('S2')).toBeNull() }) }) // ── Legend ───────────────────────────────────────────────────────── describe('legend', () => { it('renders the legend when multi-series + series prop supplied', () => { render( , ) expect(screen.getByText('2024')).toBeTruthy() expect(screen.getByText('2025')).toBeTruthy() }) it('does NOT render the legend for single-series even when series is given', () => { render( , ) expect(screen.queryByText('OnlySeries')).toBeNull() }) it('does NOT render the legend for multi-series when series is omitted', () => { render( , ) // No legend element should be in the DOM (no series names to show). // The text "A" is still present from the row label. expect(screen.getAllByText('A')).toHaveLength(1) }) }) // ── Selection visual ─────────────────────────────────────────────── describe('selection visual', () => { it('marks selected rows aria-pressed=true', () => { render( , ) const pressed = screen.getAllByRole('button', { pressed: true }) expect(pressed).toHaveLength(1) expect(pressed[0]!.textContent).toContain('A') }) it('fires onSelectionChange with the toggled set on click', () => { const onSelectionChange = vi.fn() render( , ) fireEvent.click(screen.getByText('B')) expect(onSelectionChange).toHaveBeenCalledWith(['B']) }) it('removes a selected item on second click', () => { const onSelectionChange = vi.fn() render( , ) fireEvent.click(screen.getByText('A')) expect(onSelectionChange).toHaveBeenCalledWith(['B']) }) it('toggles via keyboard Enter and Space', () => { const onSelectionChange = vi.fn() render( , ) const row = screen.getByRole('button') fireEvent.keyDown(row, { key: 'Enter' }) expect(onSelectionChange).toHaveBeenCalledWith(['A']) onSelectionChange.mockClear() fireEvent.keyDown(row, { key: ' ' }) expect(onSelectionChange).toHaveBeenCalledWith(['A']) }) }) // ── Selection dim color (bar-fill-only signal) ───────────────────── // // When a selection exists, non-selected rows' bar fills swap to // `theme.palette.action.disabled` — the ONLY visual difference // between selected and non-selected rows. Text colors stay at full // values; rows have no bg tint, no opacity rule. // // jsdom + MUI default light theme: `action.disabled` resolves to // `rgba(0, 0, 0, 0.26)` — what the browser writes to // `style.backgroundColor`. describe('selection dim color', () => { const DIM = 'rgba(0, 0, 0, 0.26)' function getBarFill(row: HTMLElement): HTMLElement | null { return row.querySelector('div[style*="width:"]') } it('non-selected rows render the bar fill in the theme dim color when a selection exists', () => { render( , ) const rows = screen.getAllByRole('button') const aRow = rows.find((r) => r.textContent?.includes('A'))! const bRow = rows.find((r) => r.textContent?.includes('B'))! // Selected: keeps its real color. expect(getBarFill(aRow)!.style.backgroundColor).toBe('rgb(255, 0, 0)') // Non-selected: swaps to the dim color. expect(getBarFill(bRow)!.style.backgroundColor).toBe(DIM) }) it('without a selection, no row is dimmed (every bar keeps its real color)', () => { render( , ) const rows = screen.getAllByRole('button') for (const row of rows) { expect(getBarFill(row)!.style.backgroundColor).not.toBe(DIM) } }) it('multi-series: every bar in a dimmed row gets the dim color', () => { render( , ) const rows = screen.getAllByRole('button') const bRow = rows.find((r) => r.textContent?.includes('B'))! const fills = bRow.querySelectorAll('div[style*="width:"]') expect(fills.length).toBe(2) fills.forEach((f) => { expect(f.style.backgroundColor).toBe(DIM) }) }) }) // ── labelFormatter (selection-key integrity) ─────────────────────── describe('labelFormatter', () => { it('transforms displayed names without changing selection keys', () => { const onSelectionChange = vi.fn() render( String(n).toUpperCase()} selection={[]} onSelectionChange={onSelectionChange} />, ) // Display labels are upper-cased. expect(screen.getByText('DOG')).toBeTruthy() expect(screen.getByText('CAT')).toBeTruthy() expect(screen.queryByText('dog')).toBeNull() // Selection callback receives the RAW name. fireEvent.click(screen.getByText('DOG')) expect(onSelectionChange).toHaveBeenCalledWith(['dog']) }) it('formats names in multi-series rows too, with raw selection keys', () => { const onSelectionChange = vi.fn() render( `[${n}]`} selection={[]} onSelectionChange={onSelectionChange} />, ) expect(screen.getByText('[dog]')).toBeTruthy() fireEvent.click(screen.getByText('[dog]')) expect(onSelectionChange).toHaveBeenCalledWith(['dog']) }) }) // ── Palette + per-item / per-series color overrides ──────────────── describe('color palette', () => { function getBarFill(row: HTMLElement): HTMLElement | null { // The bar fill is the second
inside the bar track div. return row.querySelector( // The MUI Box renders as a div; the fill has inline style with width. 'div[style*="width:"]', ) } it('per-item color (data[s][r].color) wins over series + palette', () => { render( , ) const row = screen.getByRole('button') const fill = getBarFill(row) expect(fill).toBeTruthy() // jsdom resolves theme tokens but inline color literals come through. expect(fill!.style.backgroundColor).toBe('rgb(255, 0, 0)') }) it('series-level color (series[i].color) wins over palette', () => { render( , ) const fill = getBarFill(screen.getByRole('button')) expect(fill!.style.backgroundColor).toBe('rgb(123, 45, 67)') }) }) // ── maxOverride ──────────────────────────────────────────────────── describe('maxOverride', () => { it('changes the bar width denominator (different markup)', () => { const { rerender, container } = render( , ) const before = container.innerHTML rerender( , ) expect(container.innerHTML).not.toBe(before) }) }) // ── formatter ────────────────────────────────────────────────────── describe('formatter', () => { it('uses formatter for the value display', () => { render( n.toLocaleString('en-US')} />, ) expect(screen.getByText('1,234')).toBeTruthy() }) }) // ── Size variant ─────────────────────────────────────────────────── describe('size variant', () => { const TWO_SERIES = [ [ { name: 'A', value: 10 }, { name: 'B', value: 20 }, ], [ { name: 'A', value: 5 }, { name: 'B', value: 15 }, ], ] it('defaults to size="small" — every bar carries data-size="small"', () => { const { container } = render( , ) const tracks = container.querySelectorAll('[data-size]') expect(tracks.length).toBe(2) tracks.forEach((t) => { expect(t.getAttribute('data-size')).toBe('small') }) }) it('propagates size="medium" to every bar (single-series)', () => { const { container } = render( , ) const tracks = container.querySelectorAll('[data-size]') expect(tracks.length).toBe(2) tracks.forEach((t) => { expect(t.getAttribute('data-size')).toBe('medium') }) }) it('propagates size="medium" to every bar (multi-series — N bars per row)', () => { const { container } = render( , ) // 2 rows × 2 series = 4 bars const tracks = container.querySelectorAll('[data-size]') expect(tracks.length).toBe(4) tracks.forEach((t) => { expect(t.getAttribute('data-size')).toBe('medium') }) }) }) // ── Stacked mode ──────────────────────────────────────────────────── describe('stacked', () => { const TWO_SERIES = [ [ { name: 'A', value: 45 }, { name: 'B', value: 30 }, ], [ { name: 'A', value: 32 }, { name: 'B', value: 18 }, ], ] it('multi-series + stacked → one stacked row per group with one bar track per row', () => { const { container } = render( , ) // 2 unique names → 2 stacked rows → 2 bar tracks (NOT 4 like CategoryRowMulti). const tracks = container.querySelectorAll('[data-size]') expect(tracks.length).toBe(2) // Each track has 2 segments (data-bar-fill) for the two non-zero series. const fills = container.querySelectorAll('[data-bar-fill="true"]') expect(fills.length).toBe(4) }) it('renders the formatted total in the header and the breakdown line under each row', () => { render( `${n}K`} />, ) // Totals: A=45+32=77, B=30+18=48 expect(screen.getByText('77K')).toBeTruthy() expect(screen.getByText('48K')).toBeTruthy() // Breakdown labels per row. expect(screen.getByText('Women: 45K')).toBeTruthy() expect(screen.getByText('Men: 32K')).toBeTruthy() expect(screen.getByText('Women: 30K')).toBeTruthy() expect(screen.getByText('Men: 18K')).toBeTruthy() }) it('falls back to "Series N" in the breakdown when series prop is omitted', () => { render( `${n}`} />) expect(screen.getByText('Series 1: 45')).toBeTruthy() expect(screen.getByText('Series 2: 32')).toBeTruthy() }) it('stacked is a no-op for single-series data (still renders CategoryRowSingle)', () => { const { container } = render( , ) // Single-series row has no breakdown text — assert by the lack of // "Series 1: ..." pattern (which would appear in stacked rendering). expect(screen.queryByText(/Series 1:/)).toBeNull() // 1 bar track for the one row. expect(container.querySelectorAll('[data-size]').length).toBe(1) }) it('clicking a stacked row toggles by raw name', () => { const onSelectionChange = vi.fn() render( , ) fireEvent.click(screen.getAllByRole('button')[1]!) expect(onSelectionChange).toHaveBeenCalledWith(['B']) }) it('dim color applies to every segment of non-selected rows when a selection exists', () => { const DIM = 'rgba(0, 0, 0, 0.26)' render( , ) const rows = screen.getAllByRole('button') const bRow = rows.find((r) => r.textContent?.includes('B'))! const fills = bRow.querySelectorAll('[data-bar-fill="true"]') expect(fills.length).toBe(2) fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM)) }) }) })