import { describe, it, expect, beforeAll, vi } from 'vitest'
import { render, fireEvent, screen } from '@testing-library/react'
import { mockReplace, setMockSearchParams } from '../../../../vitest.setup'
import { TabNavigation, type TabItem } from '../tab-navigation'
beforeAll(() => {
// jsdom ships no ResizeObserver; the tab strip observes itself on mount to
// keep the edge fades and the underline in sync with its own width.
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver
})
const TABS: TabItem[] = [
{ id: 'general', label: 'General' },
{ id: 'software', label: 'Software' },
]
/**
* Renders with `urlSync` and reports the tab id the BODY was last rendered with
* — the one thing a consumer actually observes, and the value the deferred
* render settles on once React has flushed.
*/
function renderNav(tabs: TabItem[]) {
let seen = '__never-rendered__'
const element = (next: TabItem[]) => (
{(active) => {
seen = active
return null
}}
)
const utils = render(element(tabs))
return {
get active() {
return seen
},
rerenderWith(next: TabItem[]) {
utils.rerender(element(next))
},
}
}
describe('TabNavigation with urlSync', () => {
describe('a tab list that arrives late', () => {
// The sync effect is guarded on the URL value changing, so that our own
// `router.replace` doesn't come back looking like someone else's edit. The
// guard must not also swallow the tabs themselves arriving — an empty list
// resolves to no active tab, and the URL never moves again to correct it.
it('applies the deep-linked tab once the tabs arrive', () => {
setMockSearchParams(new URLSearchParams('tab=software'))
const nav = renderNav([])
expect(nav.active).toBe('')
nav.rerenderWith(TABS)
expect(nav.active).toBe('software')
})
it('falls back to the first tab once the tabs arrive', () => {
const nav = renderNav([])
expect(nav.active).toBe('')
nav.rerenderWith(TABS)
expect(nav.active).toBe('general')
})
it('leaves a tab the user picked alone when the tabs array is merely re-created', () => {
const nav = renderNav(TABS)
fireEvent.click(screen.getByText('Software'))
expect(nav.active).toBe('software')
// Same ids, new array + new Set identity — what a consumer passing an
// inline literal produces on every one of its renders.
nav.rerenderWith([...TABS])
expect(nav.active).toBe('software')
})
})
describe('clicking a tab', () => {
// The regression this component was rewritten for: state moves on click,
// `router.replace` lands a tick or more later, and an effect comparing the
// state against the URL in between would read the tab just LEFT and push
// the tab back to it.
it('does not revert before the navigation lands', () => {
const nav = renderNav(TABS)
fireEvent.click(screen.getByText('Software'))
// The URL has deliberately NOT been updated here — this is the window in
// which the old effect fought its own click.
expect(nav.active).toBe('software')
})
it('writes the tab to the URL without scrolling to the top', () => {
renderNav(TABS)
fireEvent.click(screen.getByText('Software'))
expect(mockReplace).toHaveBeenCalledWith('/?tab=software', { scroll: false })
})
it('stays put when its own write finally lands', () => {
const nav = renderNav(TABS)
fireEvent.click(screen.getByText('Software'))
setMockSearchParams(new URLSearchParams('tab=software'))
nav.rerenderWith(TABS)
expect(nav.active).toBe('software')
})
// Two clicks inside one navigation: the FIRST write lands while the second
// is still in flight. Arriving out of step like that, it is indistinguishable
// from an external change unless the component remembers what it wrote.
it('ignores an earlier write landing after a second click', () => {
const nav = renderNav(TABS)
fireEvent.click(screen.getByText('Software'))
fireEvent.click(screen.getByText('General'))
expect(nav.active).toBe('general')
setMockSearchParams(new URLSearchParams('tab=software'))
nav.rerenderWith(TABS)
expect(nav.active).toBe('general')
setMockSearchParams(new URLSearchParams('tab=general'))
nav.rerenderWith(TABS)
expect(nav.active).toBe('general')
})
it('keeps unrelated query params', () => {
setMockSearchParams(new URLSearchParams('page=2&tab=general'))
renderNav(TABS)
fireEvent.click(screen.getByText('Software'))
expect(mockReplace).toHaveBeenCalledWith('/?page=2&tab=software', { scroll: false })
})
})
describe('an external URL change', () => {
it('moves the tab (back/forward, or a link into a tab)', () => {
setMockSearchParams(new URLSearchParams('tab=general'))
const nav = renderNav(TABS)
expect(nav.active).toBe('general')
setMockSearchParams(new URLSearchParams('tab=software'))
nav.rerenderWith(TABS)
expect(nav.active).toBe('software')
})
it('falls back when the URL names a tab that does not exist', () => {
setMockSearchParams(new URLSearchParams('tab=general'))
const nav = renderNav(TABS)
setMockSearchParams(new URLSearchParams('tab=nonsense'))
nav.rerenderWith(TABS)
expect(nav.active).toBe('general')
})
})
describe('the underline', () => {
it('is sized in real pixels rather than a scaled 1px bar', () => {
// Regression guard. The underline used to be `w-px` stretched with
// `scaleX(width)`, which is only exact when one CSS pixel lands on a whole
// device pixel. At 125%/150% OS scaling or any browser zoom the base is
// rasterized to the nearest whole device pixel and `scaleX` multiplies
// that rounding by the tab's width, so the bar rendered visibly shorter or
// longer than the tab it underlines — while `offsetWidth` had been right
// all along. Asserting the STYLE (not the pixels, which jsdom has none of)
// is what keeps the multiplier from coming back.
const widthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(120)
const leftSpy = vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(40)
try {
setMockSearchParams(new URLSearchParams('tab=general'))
const { container } = render(
{() => null}
)
const underline = container.querySelector('[aria-hidden]') as HTMLElement
expect(underline.style.width).toBe('120px')
expect(underline.style.transform).toBe('translateX(40px)')
expect(underline.style.transform).not.toContain('scaleX')
expect(underline.className).not.toContain('w-px')
} finally {
widthSpy.mockRestore()
leftSpy.mockRestore()
}
})
})
})