import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { CategoryUI } from './category-ui'

describe('<CategoryUI>', () => {
  // ── Rendering basics ────────────────────────────────────────────────
  describe('rendering', () => {
    it('renders one row per item with name + value', () => {
      render(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10 },
              { name: 'B', value: 20 },
            ],
          ]}
        />,
      )
      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(<CategoryUI data={[]} />)
      expect(container.firstChild).toBeNull()
    })

    it('returns null when all series are empty', () => {
      const { container } = render(<CategoryUI data={[[], [], []]} />)
      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(<CategoryUI data={[oneToTwelve()]} />)
      expect(screen.queryByText(/more/)).toBeNull()
      expect(screen.getAllByRole('button')).toHaveLength(12)
    })

    it('default caps at 20 when data exceeds it → "Others <count>" footer', () => {
      const twentyFive = Array.from({ length: 25 }, (_, i) => ({
        name: `n${i}`,
        value: 25 - i,
      }))
      render(<CategoryUI data={[twentyFive]} />)
      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(
        <CategoryUI data={[twentyFive]} maxItems={null} />,
      )
      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(
        <CategoryUI data={[twentyFive]} maxItems={Number.POSITIVE_INFINITY} />,
      )
      expect(screen.queryByText('Others')).toBeNull()
      expect(screen.getAllByRole('button')).toHaveLength(25)
    })

    it('slices to maxItems and shows "Others <count>" footer', () => {
      render(<CategoryUI data={[oneToTwelve()]} maxItems={5} />)
      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(
        <CategoryUI
          data={[oneToTwelve()]}
          maxItems={5}
          onShowAll={onShowAll}
        />,
      )
      // 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(
        <CategoryUI data={[oneToTwelve()]} maxItems={0} />,
      )
      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(
            <CategoryUI data={[oneToTwelve()]} maxItems={5} />,
          )
          // 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(<CategoryUI data={[oneToTwelve()]} maxItems={0} />)
          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(
            <CategoryUI data={[oneToTwelve()]} maxItems={5} />,
          )
          const root = container.firstChild as HTMLElement
          const list = root.firstChild as HTMLElement
          rerender(<CategoryUI data={[oneToTwelve()]} maxItems={0} />)
          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(<CategoryUI data={[oneToTwelve()]} maxItems={0} />)
          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(
          <CategoryUI data={[oneToTwelve()]} maxItems={0} />,
        )
        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(
          <CategoryUI data={threeSeries} maxItems={0} />,
        )
        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(
        <CategoryUI
          data={[oneToTwelve()]}
          maxItems={3}
          labels={{
            other: 'Others',
            otherCount: '+{count} hidden',
          }}
        />,
      )
      expect(screen.getByText('Others')).toBeTruthy()
      expect(screen.getByText('+9 hidden')).toBeTruthy()
    })

    it('large maxItems = no Other row', () => {
      render(<CategoryUI data={[oneToTwelve()]} maxItems={1_000} />)
      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(<CategoryUI data={twoSeries()} />)
      // 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(<CategoryUI data={twoSeries()} formatter={(n) => `$${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(
        <CategoryUI
          data={twoSeries()}
          selection={[]}
          onSelectionChange={onSelectionChange}
        />,
      )
      fireEvent.click(screen.getAllByRole('button')[1]!)
      expect(onSelectionChange).toHaveBeenCalledWith(['B'])
    })

    it('falls back gracefully when data.length > series.length (extra series unlabeled)', () => {
      render(<CategoryUI data={twoSeries()} series={[{ name: 'Only-One' }]} />)
      // 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(
        <CategoryUI
          data={[[{ name: 'A', value: 1 }]]}
          series={[{ name: 'S1' }, { name: 'S2' }, { name: 'S3' }]}
        />,
      )
      // 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(
        <CategoryUI
          data={[[{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }]]}
          series={[{ name: '2024' }, { name: '2025' }]}
        />,
      )
      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(
        <CategoryUI
          data={[[{ name: 'A', value: 1 }]]}
          series={[{ name: 'OnlySeries' }]}
        />,
      )
      expect(screen.queryByText('OnlySeries')).toBeNull()
    })

    it('does NOT render the legend for multi-series when series is omitted', () => {
      render(
        <CategoryUI
          data={[[{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }]]}
        />,
      )
      // 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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10 },
              { name: 'B', value: 20 },
            ],
          ]}
          selection={['A']}
        />,
      )
      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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10 },
              { name: 'B', value: 20 },
            ],
          ]}
          selection={[]}
          onSelectionChange={onSelectionChange}
        />,
      )
      fireEvent.click(screen.getByText('B'))
      expect(onSelectionChange).toHaveBeenCalledWith(['B'])
    })

    it('removes a selected item on second click', () => {
      const onSelectionChange = vi.fn()
      render(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10 },
              { name: 'B', value: 20 },
            ],
          ]}
          selection={['A', 'B']}
          onSelectionChange={onSelectionChange}
        />,
      )
      fireEvent.click(screen.getByText('A'))
      expect(onSelectionChange).toHaveBeenCalledWith(['B'])
    })

    it('toggles via keyboard Enter and Space', () => {
      const onSelectionChange = vi.fn()
      render(
        <CategoryUI
          data={[[{ name: 'A', value: 10 }]]}
          selection={[]}
          onSelectionChange={onSelectionChange}
        />,
      )
      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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10, color: 'rgb(255, 0, 0)' },
              { name: 'B', value: 20, color: 'rgb(0, 255, 0)' },
            ],
          ]}
          selection={['A']}
        />,
      )
      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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10, color: 'rgb(255, 0, 0)' },
              { name: 'B', value: 20, color: 'rgb(0, 255, 0)' },
            ],
          ]}
        />,
      )
      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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10, color: 'rgb(255, 0, 0)' },
              { name: 'B', value: 20, color: 'rgb(255, 0, 0)' },
            ],
            [
              { name: 'A', value: 5, color: 'rgb(0, 0, 255)' },
              { name: 'B', value: 7, color: 'rgb(0, 0, 255)' },
            ],
          ]}
          selection={['A']}
        />,
      )
      const rows = screen.getAllByRole('button')
      const bRow = rows.find((r) => r.textContent?.includes('B'))!
      const fills = bRow.querySelectorAll<HTMLElement>('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(
        <CategoryUI
          data={[
            [
              { name: 'dog', value: 10 },
              { name: 'cat', value: 20 },
            ],
          ]}
          labelFormatter={(n) => 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(
        <CategoryUI
          data={[[{ name: 'dog', value: 1 }], [{ name: 'dog', value: 2 }]]}
          labelFormatter={(n) => `[${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 <div> 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(
        <CategoryUI
          data={[[{ name: 'A', value: 10, color: 'rgb(255, 0, 0)' }]]}
          series={[{ name: 'S', color: 'rgb(0, 255, 0)' }]}
        />,
      )
      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(
        <CategoryUI
          data={[[{ name: 'A', value: 10 }]]}
          series={[{ name: 'S', color: 'rgb(123, 45, 67)' }]}
        />,
      )
      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(
        <CategoryUI data={[[{ name: 'A', value: 100 }]]} />,
      )
      const before = container.innerHTML
      rerender(
        <CategoryUI data={[[{ name: 'A', value: 100 }]]} maxOverride={200} />,
      )
      expect(container.innerHTML).not.toBe(before)
    })
  })

  // ── formatter ──────────────────────────────────────────────────────
  describe('formatter', () => {
    it('uses formatter for the value display', () => {
      render(
        <CategoryUI
          data={[[{ name: 'A', value: 1234 }]]}
          formatter={(n) => 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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10 },
              { name: 'B', value: 20 },
            ],
          ]}
        />,
      )
      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(
        <CategoryUI
          data={[
            [
              { name: 'A', value: 10 },
              { name: 'B', value: 20 },
            ],
          ]}
          size='medium'
        />,
      )
      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(
        <CategoryUI data={TWO_SERIES} size='medium' />,
      )
      // 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(
        <CategoryUI
          data={TWO_SERIES}
          series={[{ name: 'Women' }, { name: 'Men' }]}
          stacked
        />,
      )
      // 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(
        <CategoryUI
          data={TWO_SERIES}
          series={[{ name: 'Women' }, { name: 'Men' }]}
          stacked
          formatter={(n) => `${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(<CategoryUI data={TWO_SERIES} stacked formatter={(n) => `${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(
        <CategoryUI data={[[{ name: 'A', value: 10 }]]} stacked />,
      )
      // 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(
        <CategoryUI
          data={TWO_SERIES}
          series={[{ name: 'Women' }, { name: 'Men' }]}
          stacked
          selection={[]}
          onSelectionChange={onSelectionChange}
        />,
      )
      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(
        <CategoryUI
          data={TWO_SERIES}
          series={[{ name: 'Women' }, { name: 'Men' }]}
          stacked
          selection={['A']}
        />,
      )
      const rows = screen.getAllByRole('button')
      const bRow = rows.find((r) => r.textContent?.includes('B'))!
      const fills = bRow.querySelectorAll<HTMLElement>('[data-bar-fill="true"]')
      expect(fills.length).toBe(2)
      fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
    })
  })
})
