test(sidebar): rewrite Sidebar tests for compound component API
Replace legacy monolithic Sidebar test suite with 16 tests covering the new compound component API (Sidebar.Header, Section, Footer, FooterLink) including icon-rail collapsed mode, search input visibility, and active state highlighting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,172 +1,327 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { Sidebar, type SidebarApp } from './Sidebar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||||
|
||||
const TEST_APPS: SidebarApp[] = [
|
||||
{
|
||||
id: 'order-service',
|
||||
name: 'order-service',
|
||||
health: 'live',
|
||||
exchangeCount: 1433,
|
||||
routes: [
|
||||
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
|
||||
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 },
|
||||
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'payment-svc',
|
||||
name: 'payment-svc',
|
||||
health: 'live',
|
||||
exchangeCount: 912,
|
||||
routes: [
|
||||
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
|
||||
],
|
||||
agents: [],
|
||||
},
|
||||
]
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
|
||||
return render(
|
||||
const LogoIcon = () => <svg data-testid="logo-icon" />
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<MemoryRouter>
|
||||
<Sidebar apps={TEST_APPS} {...props} />
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sidebar compound component', () => {
|
||||
// 1. renders Header with logo, title, version
|
||||
it('renders Header with logo, title, and version', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('MyApp')).toBeInTheDocument()
|
||||
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the logo and brand name', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('cameleer')).toBeInTheDocument()
|
||||
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
|
||||
// 2. hides Header title and version when collapsed
|
||||
it('hides Header title and version when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
|
||||
// Logo should still be visible
|
||||
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the search input', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
|
||||
// 3. renders Section with label and children
|
||||
it('renders Section with label and children when open', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>Section Child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Section Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Navigation section header', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
// 4. hides Section children when section collapsed (open=false)
|
||||
it('hides Section children when section is not open', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>Section Child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Applications tree section', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
||||
// 5. calls onToggle when Section header clicked
|
||||
it('calls onToggle when Section chevron button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button', { name: /collapse settings/i })
|
||||
await user.click(btn)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Agents tree section', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
||||
// 6. renders collapse toggle and calls onCollapseToggle
|
||||
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapseToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar onCollapseToggle={onCollapseToggle}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
|
||||
await user.click(toggleBtn)
|
||||
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Routes nav link', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
||||
// 7. renders expand toggle label when collapsed
|
||||
it('renders expand toggle when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders bottom links', () => {
|
||||
renderSidebar()
|
||||
// 8. renders search input and calls onSearchChange
|
||||
it('renders search input and calls onSearchChange on input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSearchChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar searchValue="" onSearchChange={onSearchChange}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Filter...')
|
||||
expect(input).toBeInTheDocument()
|
||||
|
||||
await user.type(input, 'hello')
|
||||
expect(onSearchChange).toHaveBeenCalled()
|
||||
// Each keystroke fires once
|
||||
expect(onSearchChange.mock.calls[0][0]).toBe('h')
|
||||
})
|
||||
|
||||
// 9. hides search when collapsed
|
||||
it('hides search input when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 10. hides search when onSearchChange not provided
|
||||
it('hides search input when onSearchChange is not provided', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar searchValue="">
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 11. renders FooterLinks with icons and labels
|
||||
it('renders FooterLinks with icon and label', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<span data-testid="footer-icon">ic</span>}
|
||||
label="Admin"
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument()
|
||||
expect(screen.getByText('API Docs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders app names in the Applications tree', () => {
|
||||
renderSidebar()
|
||||
// order-service appears in Applications, Routes, and Agents trees
|
||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
// 12. hides FooterLink labels when collapsed and sets title tooltip
|
||||
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<span>ic</span>}
|
||||
label="Admin"
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||
|
||||
// The clickable element should carry a title attribute for tooltip
|
||||
// (accessible name comes from icon content when label is hidden)
|
||||
const item = screen.getByTitle('Admin')
|
||||
expect(item).toHaveAttribute('title', 'Admin')
|
||||
})
|
||||
|
||||
it('renders exchange count badges', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('1.4k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders agent live count badge in Agents tree', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('2/2 live')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show starred section when nothing is starred', () => {
|
||||
renderSidebar()
|
||||
expect(screen.queryByText('★ Starred')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows starred section after starring an item', async () => {
|
||||
// 13. calls FooterLink onClick
|
||||
it('calls FooterLink onClick when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
const onClick = vi.fn()
|
||||
|
||||
// Find the first app row (order-service in Applications tree) and hover to reveal star
|
||||
const appRows = screen.getAllByText('order-service')
|
||||
const appRow = appRows[0].closest('[role="treeitem"]')!
|
||||
await user.hover(appRow)
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Click the star button
|
||||
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
|
||||
await user.click(starBtn)
|
||||
|
||||
expect(screen.getByText('★ Starred')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('Admin'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('filters tree items by search', async () => {
|
||||
// 14. renders Section as icon-rail item when sidebar collapsed
|
||||
it('renders Section as icon-rail item when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Section
|
||||
icon={<span data-testid="section-icon">ic</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Label text should not be visible (only as tooltip via title attr)
|
||||
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
||||
|
||||
// Rail item carries title attribute for tooltip
|
||||
// (accessible name comes from icon content when label is hidden)
|
||||
const railItem = screen.getByTitle('Settings')
|
||||
expect(railItem).toHaveAttribute('title', 'Settings')
|
||||
|
||||
// Icon should still render
|
||||
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
|
||||
|
||||
// Section children should not be rendered
|
||||
expect(screen.queryByText('child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
|
||||
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
const onCollapseToggle = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
||||
await user.type(searchInput, 'payment')
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
|
||||
<Sidebar.Section
|
||||
icon={<span>ic</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// payment-svc should still be visible (may appear in multiple trees)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
const railItem = screen.getByTitle('Settings')
|
||||
await user.click(railItem)
|
||||
|
||||
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('expands tree to show children when chevron is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
// 16. applies active highlight to FooterLink
|
||||
it('applies active highlight class to FooterLink when active', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Find the expand button for order-service in Applications tree
|
||||
const expandBtns = screen.getAllByLabelText('Expand')
|
||||
await user.click(expandBtns[0])
|
||||
|
||||
// Routes should now be visible
|
||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
||||
expect(screen.getByText('order-enrichment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses expanded tree when chevron is clicked again', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
|
||||
const expandBtns = screen.getAllByLabelText('Expand')
|
||||
await user.click(expandBtns[0])
|
||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
||||
|
||||
const collapseBtn = screen.getByLabelText('Collapse')
|
||||
await user.click(collapseBtn)
|
||||
expect(screen.queryByText('order-intake')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render apps with no agents in the Agents tree', () => {
|
||||
renderSidebar()
|
||||
// payment-svc has no agents, so it shouldn't appear under the Agents section header
|
||||
// But it still appears under Applications. Let's check the agent tree specifically.
|
||||
const agentBadges = screen.queryAllByText(/\/.*live/)
|
||||
// Only order-service should have an agent badge
|
||||
expect(agentBadges).toHaveLength(1)
|
||||
expect(agentBadges[0].textContent).toBe('2/2 live')
|
||||
const item = screen.getByText('Admin').closest('[role="button"]')!
|
||||
expect(item.className).toMatch(/bottomItemActive/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user