diff --git a/src/design-system/layout/Sidebar/Sidebar.test.tsx b/src/design-system/layout/Sidebar/Sidebar.test.tsx index ecaf6ed..21c8f52 100644 --- a/src/design-system/layout/Sidebar/Sidebar.test.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.test.tsx @@ -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[0]> = {}) { - return render( +const LogoIcon = () => + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( - - - - , + {children} + ) } -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( + + + } title="MyApp" version="v1.2.3" /> + + , + ) + 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( + + + } title="MyApp" version="v1.2.3" /> + + , + ) + 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( + + + icon} + label="Settings" + open + onToggle={vi.fn()} + > +
Section Child
+
+
+
, + ) + 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( + + + icon} + label="Settings" + open={false} + onToggle={vi.fn()} + > +
Section Child
+
+
+
, + ) + 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( + + + icon} + label="Settings" + open + onToggle={onToggle} + > +
child
+
+
+
, + ) + + 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( + + + } title="App" /> + + , + ) + + 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( + + + } title="App" /> + + , + ) + 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( + + + } title="App" /> + + , + ) + + 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( + + + } title="App" /> + + , + ) + expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument() + }) + + // 10. hides search when onSearchChange not provided + it('hides search input when onSearchChange is not provided', () => { + render( + + + } title="App" /> + + , + ) + expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument() + }) + + // 11. renders FooterLinks with icons and labels + it('renders FooterLinks with icon and label', () => { + render( + + + + ic} + label="Admin" + /> + + + , + ) + 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( + + + + ic} + label="Admin" + /> + + + , + ) + 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( + + + + ic} label="Admin" onClick={onClick} /> + + + , + ) - // 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( + + + ic} + label="Settings" + open={false} + onToggle={vi.fn()} + > +
child
+
+
+
, + ) + + // 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( + + + ic} + label="Settings" + open={false} + onToggle={onToggle} + > +
child
+
+
+
, + ) - // 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( + + + + ic} label="Admin" active /> + + + , + ) - // 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/) }) })