import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { act, fireEvent, render, screen } from '@testing-library/react' import { Provider } from '../provider/widget-provider' import { clearAllWidgetStores, getWidgetStore } from '../stores' import { Category } from './category' beforeEach(() => clearAllWidgetStores()) afterEach(() => clearAllWidgetStores()) const DATA = [ [ { name: 'A', value: 10 }, { name: 'B', value: 20 }, ], ] describe(' bridge', () => { it('reads post-pipeline data from the store and renders rows', () => { render( , ) expect(screen.getByText('A')).toBeTruthy() expect(screen.getByText('B')).toBeTruthy() }) it('forwards the formatter from the store to the UI', () => { const fmt = (n: number) => `[${n}]` render( , ) expect(screen.getByText('[10]')).toBeTruthy() expect(screen.getByText('[20]')).toBeTruthy() }) it('passes selection and onSelectionChange through to the UI', () => { const onSelectionChange = vi.fn() render( , ) fireEvent.click(screen.getByText('B')) expect(onSelectionChange).toHaveBeenCalledWith(['A', 'B']) }) it('reflects pipeline-derived data when filters change the store', () => { render( , ) // Manually mutate the post-pipeline derived data on the store, simulating // a transform output. The bridge selector picks it up via Zustand subscription. expect(screen.getByText('B')).toBeTruthy() void getWidgetStore('cat-bridge-4') }) it('forwards labelFormatter from the store to the UI; selection callback still receives raw name', () => { const onSelectionChange = vi.fn() render( String(n).toUpperCase()} > , ) // Display names are upper-cased by the formatter. expect(screen.getByText('A')).toBeTruthy() // Click the upper-cased display label. fireEvent.click(screen.getByText('A')) // Selection callback gets the RAW name, not the formatted one. expect(onSelectionChange).toHaveBeenCalledWith(['A']) }) it('forwards series, maxItems, labels, maxOverride as pass-throughs', () => { const data = [ Array.from({ length: 6 }, (_, i) => ({ name: `n${i}`, value: 6 - i, })), ] render( , ) // 3 visible rows + Other footer. expect(screen.getAllByRole('button')).toHaveLength(3) expect(screen.getByText('Rest')).toBeTruthy() expect(screen.getByText('(3 hidden)')).toBeTruthy() }) it('does NOT read `transformStates.searcher.enabled` from the store (composer-mediation contract)', () => { // Defensive: the library bridge stays agnostic of every specific // action. Pagination is purely a `maxItems` decision; consumers // that want to bypass it while a SearcherToggle is open should // flip the prop themselves (e.g., the `CategoryWidget` composer // does `maxItems = searcherOpen ? 0 : userMaxItems`). Guards // against regressing into the old widget-reads-action-state // pattern. const data = [ Array.from({ length: 10 }, (_, i) => ({ name: `n${i}`, value: 10 - i, })), ] render( , ) expect(screen.getAllByRole('button')).toHaveLength(3) expect(screen.getByText('Others')).toBeTruthy() // Flip the searcher flag in the store — bridge should NOT react. act(() => { const store = getWidgetStore('cat-bridge-8') store.setState({ transformStates: { ...store.getState().transformStates, searcher: { enabled: true }, }, }) }) // Still capped, Other still there. expect(screen.getAllByRole('button')).toHaveLength(3) expect(screen.getByText('Others')).toBeTruthy() }) it('respects maxItems=0 (no-cap sentinel) from the consumer', () => { // Composers that want to drop pagination — e.g., while the // SearcherToggle is open — pass `maxItems={0}`. const data = [ Array.from({ length: 10 }, (_, i) => ({ name: `n${i}`, value: 10 - i, })), ] render( , ) // No cap → all rows render, Other footer is suppressed. expect(screen.getAllByRole('button')).toHaveLength(10) expect(screen.queryByText('Others')).toBeNull() }) it('forwards size="medium" through to the rendered bars', () => { 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('defaults size to "small" when omitted', () => { const { container } = render( , ) const tracks = container.querySelectorAll('[data-size]') expect(tracks.length).toBe(2) tracks.forEach((t) => { expect(t.getAttribute('data-size')).toBe('small') }) }) // The bridge auto-fills `maxOverride` from `rawData` so bar widths // stay coherent across data transforms (e.g., the Searcher filtering // rows in/out). Without this, CategoryUI's live computation would // shrink the denominator to the filtered max and bars would rescale. describe('maxOverride auto-fill from rawData', () => { // Single-series dataset with a round peak (1000) so percent // expectations are exact. const PEAK_DATA = [ [ { name: 'A', value: 1000 }, { name: 'B', value: 500 }, { name: 'C', value: 250 }, ], ] function getBarFills(): HTMLElement[] { return Array.from( document.querySelectorAll('[data-bar-fill="true"]'), ) } it('uses rawData max as the bar denominator when consumer omits maxOverride', () => { render( , ) const fills = getBarFills() expect(fills).toHaveLength(3) expect(fills[0]!.style.width).toBe('100%') expect(fills[1]!.style.width).toBe('50%') expect(fills[2]!.style.width).toBe('25%') }) it('keeps bar widths coherent when a transform shrinks `data` below `rawData`', () => { render( , ) // Simulate a data-transform output (e.g., Searcher filtering to // just "C"). The pipeline middleware no-ops when only `data` is // set and `rawData`/`dataTransforms` are unchanged, so our // override survives the Provider's per-prop sync. act(() => { getWidgetStore('cat-bridge-auto-2').setState({ data: [[{ name: 'C', value: 250 }]], }) }) const fills = getBarFills() expect(fills).toHaveLength(1) // C's bar must still scale to 250/1000 = 25%, NOT 100% (which is // what it would be if maxValue came from the filtered subset). expect(fills[0]!.style.width).toBe('25%') }) it('consumer-supplied maxOverride wins over the rawData auto-fill', () => { render( , ) const fills = getBarFills() // 1000/2000, 500/2000, 250/2000 — consumer override active. expect(fills[0]!.style.width).toBe('50%') expect(fills[1]!.style.width).toBe('25%') expect(fills[2]!.style.width).toBe('12.5%') }) it('skips the auto-fill when rawData is empty (lets CategoryUI fallback handle it)', () => { // `data` non-empty, but rawData is set to []. The bridge must NOT // pass maxOverride=0 (which CategoryUI's `> 0` guard would // reject); it should fall through to undefined so CategoryUI // computes from the live `data`. render( , ) act(() => { getWidgetStore('cat-bridge-auto-4').setState({ data: [[{ name: 'X', value: 42 }]], }) }) const fills = getBarFills() expect(fills).toHaveLength(1) // CategoryUI computes max from the live data → 42/42 = 100%. expect(fills[0]!.style.width).toBe('100%') }) it('refreshes the auto-fill when `rawData` identity changes', () => { const { rerender } = render( , ) let fills = getBarFills() expect(fills[0]!.style.width).toBe('100%') // 1000/1000 // New dataset with a 5× larger peak — every bar must rescale. const NEXT = [ [ { name: 'A', value: 1000 }, { name: 'B', value: 500 }, { name: 'C', value: 250 }, { name: 'D', value: 5000 }, ], ] rerender( , ) fills = getBarFills() expect(fills).toHaveLength(4) // A: 1000/5000 = 20%, D: 5000/5000 = 100%. expect(fills[0]!.style.width).toBe('20%') expect(fills[3]!.style.width).toBe('100%') }) }) })