diff --git a/frontend/src/components/common/DataTable/DataTable.test.tsx b/frontend/src/components/common/DataTable/DataTable.test.tsx new file mode 100644 index 00000000..42ecf0f4 --- /dev/null +++ b/frontend/src/components/common/DataTable/DataTable.test.tsx @@ -0,0 +1,300 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import DataTable from './DataTable'; +import type { ColumnDef, PaginationConfig } from './types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +interface Row extends Record { + id: string; + name: string; + status: string; + amount: number; +} + +const COLUMNS: ColumnDef[] = [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name', sortable: true }, + { key: 'status', label: 'Status' }, + { key: 'amount', label: 'Amount', sortable: true }, +]; + +const ROWS: Row[] = [ + { id: '1', name: 'Alpha', status: 'active', amount: 300 }, + { id: '2', name: 'Beta', status: 'inactive', amount: 100 }, + { id: '3', name: 'Gamma', status: 'active', amount: 200 }, +]; + +function renderTable(props: Partial[0]> = {}) { + return render( + + columns={COLUMNS} + data={ROWS} + {...props} + />, + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('DataTable', () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('basic render', () => { + it('renders column headers', () => { + renderTable(); + expect(screen.getByText('ID')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); + + it('renders all data rows', () => { + renderTable(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + expect(screen.getByText('Gamma')).toBeInTheDocument(); + }); + + it('renders custom cell content via render function', () => { + const columns: ColumnDef[] = [ + ...COLUMNS, + { + key: 'status', + label: 'Status', + render: (val) => {String(val).toUpperCase()}, + }, + ]; + render( columns={columns} data={ROWS} />); + const cells = screen.getAllByTestId('custom-cell'); + expect(cells[0]).toHaveTextContent('ACTIVE'); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('renders a loading skeleton when loading=true', () => { + const { container } = renderTable({ loading: true }); + // Skeleton rows use an animate-pulse div; check that they are present + const skeletons = container.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('still renders column headers while loading', () => { + renderTable({ loading: true }); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('does not render data rows while loading', () => { + renderTable({ loading: true }); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + }); + }); + + // ── Empty state ──────────────────────────────────────────────────────────── + + describe('empty state', () => { + it('renders the default empty message when data is empty', () => { + render( columns={COLUMNS} data={[]} />); + expect(screen.getByText('No data available')).toBeInTheDocument(); + }); + + it('renders a custom empty message', () => { + render( + + columns={COLUMNS} + data={[]} + emptyState={{ message: 'No shipments found' }} + />, + ); + expect(screen.getByText('No shipments found')).toBeInTheDocument(); + }); + + it('renders the empty state CTA button when provided', async () => { + const onCta = vi.fn(); + render( + + columns={COLUMNS} + data={[]} + emptyState={{ message: 'Empty', cta: { label: 'Add item', onClick: onCta } }} + />, + ); + const btn = screen.getByRole('button', { name: /add item/i }); + expect(btn).toBeInTheDocument(); + await userEvent.click(btn); + expect(onCta).toHaveBeenCalledTimes(1); + }); + }); + + // ── Sorting ──────────────────────────────────────────────────────────────── + + describe('sorting', () => { + it('sorts ascending on first click of a sortable column', async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByText('Name')); + + const rows = screen.getAllByRole('row').slice(1); // skip header + expect(rows[0]).toHaveTextContent('Alpha'); + expect(rows[1]).toHaveTextContent('Beta'); + expect(rows[2]).toHaveTextContent('Gamma'); + }); + + it('sorts descending on second click of the same sortable column', async () => { + const user = userEvent.setup(); + renderTable(); + + const nameHeader = screen.getByText('Name'); + await user.click(nameHeader); // asc + await user.click(nameHeader); // desc + + const rows = screen.getAllByRole('row').slice(1); + expect(rows[0]).toHaveTextContent('Gamma'); + expect(rows[1]).toHaveTextContent('Beta'); + expect(rows[2]).toHaveTextContent('Alpha'); + }); + + it('resets sort on third click of the same sortable column', async () => { + const user = userEvent.setup(); + renderTable(); + + const nameHeader = screen.getByText('Name'); + await user.click(nameHeader); // asc + await user.click(nameHeader); // desc + await user.click(nameHeader); // reset + + // Original order restored + const rows = screen.getAllByRole('row').slice(1); + expect(rows[0]).toHaveTextContent('Alpha'); + }); + + it('sorts numeric column correctly (ascending)', async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByText('Amount')); + + const rows = screen.getAllByRole('row').slice(1); + expect(rows[0]).toHaveTextContent('100'); + expect(rows[1]).toHaveTextContent('200'); + expect(rows[2]).toHaveTextContent('300'); + }); + + it('does not respond to click on non-sortable column', async () => { + const user = userEvent.setup(); + renderTable(); + + // "ID" column is not sortable — clicking it should not reorder rows + const originalFirst = screen.getAllByRole('row')[1].textContent; + await user.click(screen.getByText('ID')); + expect(screen.getAllByRole('row')[1].textContent).toBe(originalFirst); + }); + }); + + // ── Row click ───────────────────────────────────────────────────────────── + + describe('row click', () => { + it('calls onRowClick with the correct row when a row is clicked', async () => { + const onRowClick = vi.fn(); + const user = userEvent.setup(); + renderTable({ onRowClick }); + + await user.click(screen.getByText('Beta')); + expect(onRowClick).toHaveBeenCalledWith(ROWS[1]); + }); + + it('does not render cursor-pointer class when onRowClick is absent', () => { + const { container } = renderTable(); + const tbodyRows = container.querySelectorAll('tbody tr'); + tbodyRows.forEach((row) => { + expect(row.className).not.toContain('cursor-pointer'); + }); + }); + }); + + // ── Pagination ──────────────────────────────────────────────────────────── + + describe('pagination', () => { + const buildPagination = (overrides: Partial = {}): PaginationConfig => ({ + currentPage: 1, + totalPages: 3, + onPageChange: vi.fn(), + totalItems: 30, + itemsPerPage: 10, + ...overrides, + }); + + it('renders page buttons when pagination is provided', () => { + renderTable({ pagination: buildPagination() }); + expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Page 2' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Page 3' })).toBeInTheDocument(); + }); + + it('marks the current page button as aria-current="page"', () => { + renderTable({ pagination: buildPagination({ currentPage: 2 }) }); + expect(screen.getByRole('button', { name: 'Page 2' })).toHaveAttribute( + 'aria-current', + 'page', + ); + }); + + it('calls onPageChange when a page button is clicked', async () => { + const onPageChange = vi.fn(); + const user = userEvent.setup(); + renderTable({ pagination: buildPagination({ onPageChange }) }); + + await user.click(screen.getByRole('button', { name: 'Page 3' })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + it('disables the previous button on page 1', () => { + renderTable({ pagination: buildPagination({ currentPage: 1 }) }); + expect(screen.getByRole('button', { name: /previous page/i })).toBeDisabled(); + }); + + it('disables the next button on the last page', () => { + renderTable({ pagination: buildPagination({ currentPage: 3, totalPages: 3 }) }); + expect(screen.getByRole('button', { name: /next page/i })).toBeDisabled(); + }); + + it('calls onPageChange with currentPage - 1 when previous is clicked', async () => { + const onPageChange = vi.fn(); + const user = userEvent.setup(); + renderTable({ + pagination: buildPagination({ currentPage: 2, onPageChange }), + }); + + await user.click(screen.getByRole('button', { name: /previous page/i })); + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + it('shows item range summary when totalItems and itemsPerPage are given', () => { + renderTable({ + pagination: buildPagination({ currentPage: 2, totalItems: 30, itemsPerPage: 10 }), + }); + expect(screen.getByText(/11–20 of 30/)).toBeInTheDocument(); + }); + + it('does not render pagination when totalPages <= 1', () => { + renderTable({ + pagination: buildPagination({ currentPage: 1, totalPages: 1 }), + }); + expect(screen.queryByRole('button', { name: 'Page 1' })).not.toBeInTheDocument(); + }); + }); + + // ── Density variants ────────────────────────────────────────────────────── + + describe('density variants', () => { + it.each(['compact', 'comfortable', 'spacious'] as const)( + 'renders with density="%s" without error', + (density) => { + expect(() => renderTable({ density })).not.toThrow(); + }, + ); + }); +}); diff --git a/frontend/src/components/layout/DashboardLayout.test.tsx b/frontend/src/components/layout/DashboardLayout.test.tsx new file mode 100644 index 00000000..8e5bca6b --- /dev/null +++ b/frontend/src/components/layout/DashboardLayout.test.tsx @@ -0,0 +1,345 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import DashboardLayout from './DashboardLayout'; + +// ── Dependency Mocks ────────────────────────────────────────────────────────── + +// ThemeToggle (inside TopHeader) calls useTheme which requires ThemeProvider. +// Mock the hook module so the provider is not needed in test renders. +vi.mock('../../hooks/useTheme', () => ({ + useTheme: () => ({ theme: 'dark', toggleTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// ToastContext is consumed by SessionTimeoutModal and NotificationDropdown. +vi.mock('../../context/ToastContext', () => ({ + useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn() }), + ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// LiveRegionContext is used by ToastProvider internally via useLiveRegion. +vi.mock('../../context/LiveRegionContext', () => ({ + useLiveRegion: () => ({ announce: vi.fn() }), + LiveRegionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// realtimeService is consumed by TopHeader (child of DashboardLayout) +vi.mock('../../services/realtime/realtimeService', () => ({ + realtimeService: { + status: 'connected', + onStatusChange: vi.fn(() => () => undefined), // returns unsubscribe fn + }, +})); + +// authApi + tokenStorage used by SessionTimeoutModal +vi.mock('../../services/api/endpoints/auth', () => ({ + authApi: { + refresh: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../../services/auth/tokenStorage', () => ({ + getToken: vi.fn(() => null), // no token → modal won't open + clearToken: vi.fn(), +})); + +// WalletContext is consumed deep inside TopHeader's WalletPill/WalletModal +vi.mock('../../context/WalletContext', () => ({ + useWallet: () => ({ + isModalOpen: false, + closeModal: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + publicKey: null, + isConnecting: false, + network: 'testnet', + }), +})); + +// shipmentApi is consumed by GlobalSearch inside TopHeader +vi.mock('../../api/shipmentApi', () => ({ + shipmentApi: { + getAll: vi.fn().mockResolvedValue([]), + }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Render DashboardLayout inside a MemoryRouter so useNavigate / useLocation work. + * An child is provided via a nested route so the layout has content. + */ +function renderLayout(initialPath = '/dashboard') { + return render( + + + }> + Dashboard page} /> + Shipments page} /> + Settings page} /> + + + , + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('DashboardLayout', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ── Initial render ────────────────────────────────────────────────────────── + + describe('initial render', () => { + it('renders the NAVIN brand name in the sidebar', () => { + renderLayout(); + expect(screen.getByText('NAVIN')).toBeInTheDocument(); + }); + + it('renders the skip-to-content accessibility link', () => { + renderLayout(); + expect(screen.getByRole('link', { name: /skip to main content/i })).toBeInTheDocument(); + }); + + it('renders the main navigation landmark', () => { + renderLayout(); + expect(screen.getByRole('navigation', { name: /site navigation/i })).toBeInTheDocument(); + }); + + it('renders all primary nav items in the sidebar', () => { + renderLayout(); + const navItems = [ + 'Dashboard', + 'Shipments', + 'Shipment History', + 'Blockchain Ledger', + 'Settlements', + 'Payments', + 'Analytics', + 'Notifications', + ]; + navItems.forEach((label) => { + // May appear more than once (collapsed/expanded) - just assert at least one + expect(screen.getAllByText(label).length).toBeGreaterThanOrEqual(1); + }); + }); + + it('renders the outlet child content', () => { + renderLayout('/dashboard'); + expect(screen.getByTestId('outlet-content')).toBeInTheDocument(); + expect(screen.getByText('Dashboard page')).toBeInTheDocument(); + }); + + it('highlights the active nav item based on current location', () => { + renderLayout('/dashboard'); + // The Dashboard button should carry aria-current="page" + const dashboardButtons = screen.getAllByRole('button', { name: /^Dashboard$/i }); + const activeButton = dashboardButtons.find( + (btn) => btn.getAttribute('aria-current') === 'page', + ); + expect(activeButton).toBeTruthy(); + }); + }); + + // ── Sidebar collapse ──────────────────────────────────────────────────────── + + describe('sidebar collapse / expand', () => { + it('collapses the sidebar when the collapse button is clicked', async () => { + const user = userEvent.setup(); + renderLayout(); + + const collapseBtn = screen.getByRole('button', { name: /collapse sidebar/i }); + await user.click(collapseBtn); + + // After collapse the brand text disappears and expand button appears + expect(screen.queryByText('NAVIN')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument(); + }); + + it('expands the sidebar when the expand button is clicked after collapsing', async () => { + const user = userEvent.setup(); + renderLayout(); + + await user.click(screen.getByRole('button', { name: /collapse sidebar/i })); + await user.click(screen.getByRole('button', { name: /expand sidebar/i })); + + expect(screen.getByText('NAVIN')).toBeInTheDocument(); + }); + }); + + // ── Nav group accordion ───────────────────────────────────────────────────── + + describe('nav group accordion', () => { + it('renders the System group header', () => { + renderLayout(); + expect(screen.getByRole('button', { name: /^System$/i })).toBeInTheDocument(); + }); + + it('collapses and re-expands a nav group', async () => { + const user = userEvent.setup(); + renderLayout(); + + const mainMenuBtn = screen.getByRole('button', { name: /^Main Menu$/i }); + + // Initially expanded — aria-expanded should be true + expect(mainMenuBtn).toHaveAttribute('aria-expanded', 'true'); + + await user.click(mainMenuBtn); + expect(mainMenuBtn).toHaveAttribute('aria-expanded', 'false'); + + await user.click(mainMenuBtn); + expect(mainMenuBtn).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + // ── Favorites ─────────────────────────────────────────────────────────────── + + describe('favorites', () => { + it('adds a nav item to favorites when the star button is clicked', async () => { + const user = userEvent.setup(); + renderLayout(); + + const starBtn = screen.getByRole('button', { + name: /add Shipments to dashboard favorites/i, + }); + await user.click(starBtn); + + // Favorites section should appear + expect( + screen.getByRole('navigation', { name: /dashboard favorites/i }), + ).toBeInTheDocument(); + }); + + it('persists favorites to localStorage', async () => { + const user = userEvent.setup(); + renderLayout(); + + const starBtn = screen.getByRole('button', { + name: /add Shipments to dashboard favorites/i, + }); + await user.click(starBtn); + + const stored = localStorage.getItem('navin_dashboard_favorites'); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed).toContain('/dashboard/shipments'); + }); + + it('removes a nav item from favorites when the star is clicked again', async () => { + const user = userEvent.setup(); + renderLayout(); + + // Add then remove + const addBtn = screen.getByRole('button', { + name: /add Shipments to dashboard favorites/i, + }); + await user.click(addBtn); + + const removeBtn = screen.getByRole('button', { + name: /remove Shipments from dashboard favorites/i, + }); + await user.click(removeBtn); + + expect( + screen.queryByRole('navigation', { name: /dashboard favorites/i }), + ).not.toBeInTheDocument(); + }); + + it('restores favorites from localStorage on mount', () => { + localStorage.setItem( + 'navin_dashboard_favorites', + JSON.stringify(['/dashboard/analytics']), + ); + renderLayout(); + + // Favorites section should render immediately without user interaction + expect( + screen.getByRole('navigation', { name: /dashboard favorites/i }), + ).toBeInTheDocument(); + // "Analytics" should appear in the favorites section + expect(screen.getAllByText('Analytics').length).toBeGreaterThanOrEqual(2); + }); + }); + + // ── Mobile sidebar ────────────────────────────────────────────────────────── + + describe('mobile sidebar overlay', () => { + it('close button is present in the sidebar (mobile)', () => { + renderLayout(); + expect(screen.getByRole('button', { name: /close sidebar/i })).toBeInTheDocument(); + }); + + it('sidebar acts as dialog when open', async () => { + const user = userEvent.setup(); + renderLayout(); + + // Simulate opening on mobile via TopHeader toggle button + const toggleBtn = screen.getByRole('button', { name: /toggle sidebar/i }); + await user.click(toggleBtn); + + const sidebar = screen.getByRole('dialog', { name: /main navigation/i }); + expect(sidebar).toBeInTheDocument(); + expect(sidebar).toHaveAttribute('aria-modal', 'true'); + }); + + it('closes the sidebar when the close button is clicked', async () => { + const user = userEvent.setup(); + renderLayout(); + + const toggleBtn = screen.getByRole('button', { name: /toggle sidebar/i }); + await user.click(toggleBtn); + + const closeBtn = screen.getByRole('button', { name: /close sidebar/i }); + await user.click(closeBtn); + + // Sidebar should no longer be in dialog role + expect(screen.queryByRole('dialog', { name: /main navigation/i })).not.toBeInTheDocument(); + }); + }); + + // ── Keyboard shortcuts ────────────────────────────────────────────────────── + + describe('keyboard shortcuts modal', () => { + it('shows the keyboard shortcuts hint button in the sidebar', () => { + renderLayout(); + expect( + screen.getByRole('button', { name: /show keyboard shortcuts/i }), + ).toBeInTheDocument(); + }); + + it('opens the shortcuts help modal when the hint button is clicked', async () => { + const user = userEvent.setup(); + renderLayout(); + + await user.click(screen.getByRole('button', { name: /show keyboard shortcuts/i })); + + // ShortcutsHelpModal renders a dialog with these shortcuts listed + await waitFor(() => { + expect(screen.getByText(/Alt \+ D/i)).toBeInTheDocument(); + }); + }); + }); + + // ── Node status widget ────────────────────────────────────────────────────── + + describe('enterprise node status widget', () => { + it('renders the enterprise node status text when sidebar is expanded', () => { + renderLayout(); + expect(screen.getByText('Enterprise Node')).toBeInTheDocument(); + }); + + it('shows syncing status', () => { + renderLayout(); + expect(screen.getByText(/Syncing/)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/shipment/MilestoneTimeline/MilestoneTimeline.test.tsx b/frontend/src/components/shipment/MilestoneTimeline/MilestoneTimeline.test.tsx new file mode 100644 index 00000000..d0d45973 --- /dev/null +++ b/frontend/src/components/shipment/MilestoneTimeline/MilestoneTimeline.test.tsx @@ -0,0 +1,285 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import MilestoneTimeline, { type MilestoneDetail } from './MilestoneTimeline'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const COMPLETED: MilestoneDetail = { + id: 'm1', + name: 'Picked Up', + timestamp: '2026-01-01 09:00', + location: 'London Depot', + blockchainAddress: 'GABC123456789012345678901234567890', + status: 'completed', +}; + +const CURRENT: MilestoneDetail = { + id: 'm2', + name: 'In Transit', + timestamp: '2026-01-02 12:00', + location: 'Dover Checkpoint', + blockchainAddress: 'GDEF123456789012345678901234567890', + status: 'current', +}; + +const UPCOMING: MilestoneDetail = { + id: 'm3', + name: 'Delivered', + timestamp: 'Expected 2026-01-04', + location: 'Paris Warehouse', + blockchainAddress: 'pending...', + status: 'upcoming', +}; + +const ALL_MILESTONES: MilestoneDetail[] = [COMPLETED, CURRENT, UPCOMING]; + +const WITH_NOTES: MilestoneDetail = { + ...COMPLETED, + id: 'm4', + notes: 'Fragile — handle with care', +}; + +const WITH_SENSORS: MilestoneDetail = { + ...CURRENT, + id: 'm5', + sensorReadings: { + temperature: '4°C', + humidity: '65%', + }, +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('MilestoneTimeline', () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('initial render', () => { + it('renders the timeline list landmark', () => { + render(); + expect( + screen.getByRole('list', { name: /detailed shipment milestone timeline/i }), + ).toBeInTheDocument(); + }); + + it('renders all milestone names', () => { + render(); + expect(screen.getByText('Picked Up')).toBeInTheDocument(); + expect(screen.getByText('In Transit')).toBeInTheDocument(); + expect(screen.getByText('Delivered')).toBeInTheDocument(); + }); + + it('renders the milestone timestamp', () => { + render(); + expect(screen.getByText('2026-01-01 09:00')).toBeInTheDocument(); + }); + + it('renders the milestone location', () => { + render(); + expect(screen.getByText('London Depot')).toBeInTheDocument(); + }); + + it('renders a LIVE badge for the current milestone', () => { + render(); + expect(screen.getByText('LIVE')).toBeInTheDocument(); + }); + + it('shows a blockchain link for completed milestones', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', expect.stringContaining('stellar.expert')); + }); + + it('does not render a blockchain link for upcoming milestones', () => { + render(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + }); + + // ── Filter pills ─────────────────────────────────────────────────────────── + + describe('filter pills', () => { + it('renders all four filter pills (All, Completed, In Progress, Upcoming)', () => { + render(); + expect(screen.getByRole('button', { name: /^All/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Completed/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^In Progress/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Upcoming/ })).toBeInTheDocument(); + }); + + it('filters to only completed milestones when Completed pill is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Completed/ })); + + expect(screen.getByText('Picked Up')).toBeInTheDocument(); + expect(screen.queryByText('In Transit')).not.toBeInTheDocument(); + expect(screen.queryByText('Delivered')).not.toBeInTheDocument(); + }); + + it('filters to only current milestones when In Progress pill is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^In Progress/ })); + + expect(screen.queryByText('Picked Up')).not.toBeInTheDocument(); + expect(screen.getByText('In Transit')).toBeInTheDocument(); + expect(screen.queryByText('Delivered')).not.toBeInTheDocument(); + }); + + it('shows an empty state when no milestones match the filter', async () => { + const user = userEvent.setup(); + // Only COMPLETED milestones — filtering by "current" should show empty state + render(); + + await user.click(screen.getByRole('button', { name: /^In Progress/ })); + + expect( + screen.getByText(/No milestones match the selected filter/i), + ).toBeInTheDocument(); + }); + + it('shows a "Clear filter" button in the empty state', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^In Progress/ })); + + expect(screen.getByRole('button', { name: /clear filter/i })).toBeInTheDocument(); + }); + + it('clears the filter when "Clear filter" is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^In Progress/ })); + await user.click(screen.getByRole('button', { name: /clear filter/i })); + + expect(screen.getByText('Picked Up')).toBeInTheDocument(); + }); + + it('returns to full list when All filter is clicked after filtering', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Completed/ })); + await user.click(screen.getByRole('button', { name: /^All/ })); + + expect(screen.getByText('Picked Up')).toBeInTheDocument(); + expect(screen.getByText('In Transit')).toBeInTheDocument(); + expect(screen.getByText('Delivered')).toBeInTheDocument(); + }); + }); + + // ── Zoom controls ───────────────────────────────────────────────────────── + + describe('zoom controls', () => { + it('renders zoom in and zoom out buttons', () => { + render(); + expect(screen.getByRole('button', { name: /zoom in/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /zoom out/i })).toBeInTheDocument(); + }); + + it('shows "Normal" zoom level by default', () => { + render(); + expect(screen.getByText('Normal')).toBeInTheDocument(); + }); + + it('disables zoom out when already at compact level', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /zoom out/i })); + // Now at Compact — zoom out should be disabled + expect(screen.getByRole('button', { name: /zoom out/i })).toBeDisabled(); + }); + + it('disables zoom in when already at expanded level', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /zoom in/i })); + // Now at Expanded — zoom in should be disabled + expect(screen.getByRole('button', { name: /zoom in/i })).toBeDisabled(); + }); + + it('shows the reset button when zoom is changed from default', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /zoom in/i })); + expect( + screen.getByRole('button', { name: /reset filters and zoom/i }), + ).toBeInTheDocument(); + }); + + it('resets zoom and filter when the reset button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /zoom in/i })); + await user.click(screen.getByRole('button', { name: /reset filters and zoom/i })); + + expect(screen.getByText('Normal')).toBeInTheDocument(); + }); + }); + + // ── Expandable details ──────────────────────────────────────────────────── + + describe('expandable milestone details', () => { + it('renders the expand button for milestones with notes', () => { + render(); + expect( + screen.getByRole('button', { name: /expand details/i }), + ).toBeInTheDocument(); + }); + + it('does not render the expand button for milestones without notes', () => { + render(); + expect( + screen.queryByRole('button', { name: /expand details/i }), + ).not.toBeInTheDocument(); + }); + + it('reveals notes content when expand button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /expand details/i })); + expect(screen.getByText('Fragile — handle with care')).toBeInTheDocument(); + }); + + it('hides notes content after collapsing', async () => { + const user = userEvent.setup(); + render(); + + const expandBtn = screen.getByRole('button', { name: /expand details/i }); + await user.click(expandBtn); + await user.click(screen.getByRole('button', { name: /collapse details/i })); + + expect(screen.queryByText('Fragile — handle with care')).not.toBeInTheDocument(); + }); + + it('reveals sensor readings when expanded', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /expand details/i })); + expect(screen.getByText('4°C')).toBeInTheDocument(); + expect(screen.getByText('65%')).toBeInTheDocument(); + }); + }); + + // ── Empty props ─────────────────────────────────────────────────────────── + + describe('empty milestones array', () => { + it('renders the timeline container with no list items', () => { + render(); + const list = screen.getByRole('list', { name: /detailed shipment milestone timeline/i }); + expect(list).toBeInTheDocument(); + expect(list.children.length).toBe(0); + }); + }); +}); diff --git a/frontend/src/components/shipment/ShipmentSummaryPrint/ShipmentSummaryPrint.test.tsx b/frontend/src/components/shipment/ShipmentSummaryPrint/ShipmentSummaryPrint.test.tsx new file mode 100644 index 00000000..d440c59e --- /dev/null +++ b/frontend/src/components/shipment/ShipmentSummaryPrint/ShipmentSummaryPrint.test.tsx @@ -0,0 +1,392 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import ShipmentSummaryPrint, { + type ShipmentSummaryPrintData, +} from './ShipmentSummaryPrint'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Minimal valid ShipmentSummaryPrintData */ +function makeData( + overrides: Partial = {}, +): ShipmentSummaryPrintData { + return { + shipmentId: 'SHP-0042', + status: 'IN_TRANSIT', + sender: { name: 'Acme Corp', address: '1 Sender St, London' }, + receiver: { name: 'Global Freight', address: '99 Receiver Ave, Paris' }, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ShipmentSummaryPrint', () => { + beforeEach(() => { + vi.useFakeTimers(); + // window.print is not implemented in jsdom — provide a spy + vi.spyOn(window, 'print').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // ── Portal rendering ─────────────────────────────────────────────────────── + + describe('portal rendering', () => { + it('renders the print root element into document.body via portal', () => { + render(); + expect( + document.getElementById('shipment-summary-print-root'), + ).toBeInTheDocument(); + }); + + it('renders the NAVIN brand header', () => { + render(); + expect(screen.getByText('NAVIN')).toBeInTheDocument(); + }); + + it('renders "SHIPMENT SUMMARY" heading', () => { + render(); + expect(screen.getByText('SHIPMENT SUMMARY')).toBeInTheDocument(); + }); + }); + + // ── Shipment metadata ────────────────────────────────────────────────────── + + describe('shipment metadata', () => { + it('renders the shipment ID', () => { + render(); + expect(screen.getByText('SHP-0042')).toBeInTheDocument(); + }); + + it('renders the shipment status', () => { + render( + , + ); + // formatStatus('DELIVERED') → 'DELIVERED' (no underscores, already uppercase) + expect(screen.getByText('DELIVERED')).toBeInTheDocument(); + }); + + it('renders sender name and address', () => { + render(); + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + expect(screen.getByText('1 Sender St, London')).toBeInTheDocument(); + }); + + it('renders receiver name and address', () => { + render(); + expect(screen.getByText('Global Freight')).toBeInTheDocument(); + expect(screen.getByText('99 Receiver Ave, Paris')).toBeInTheDocument(); + }); + + it('renders the tracking number when provided', () => { + render( + , + ); + expect(screen.getByText('TRK-9999')).toBeInTheDocument(); + }); + + it('does not render tracking number section when absent', () => { + render( + , + ); + expect(screen.queryByText('TRK-9999')).not.toBeInTheDocument(); + }); + + it('renders createdAt and expectedDelivery dates when provided', () => { + render( + , + ); + expect(screen.getByText('2026-01-01')).toBeInTheDocument(); + expect(screen.getByText('2026-01-10')).toBeInTheDocument(); + }); + + it('renders carrier when provided', () => { + render( + , + ); + expect(screen.getByText('FedEx Logistics')).toBeInTheDocument(); + }); + + it('renders priority when provided', () => { + render( + , + ); + expect(screen.getByText('High')).toBeInTheDocument(); + }); + }); + + // ── Milestones ───────────────────────────────────────────────────────────── + + describe('milestone timeline section', () => { + const milestones = [ + { + name: 'Picked Up', + timestamp: '2026-01-01 09:00', + location: 'London', + status: 'completed', + blockchainAddress: 'GABC1234', + }, + { + name: 'In Transit', + timestamp: '2026-01-02', + location: 'Dover', + status: 'current', + }, + ]; + + it('renders the milestone timeline section heading', () => { + render( + , + ); + expect(screen.getByText('Milestone Timeline')).toBeInTheDocument(); + }); + + it('renders milestone event names', () => { + render( + , + ); + expect(screen.getByText('Picked Up')).toBeInTheDocument(); + expect(screen.getByText('In Transit')).toBeInTheDocument(); + }); + + it('does not render the milestone section when milestones is empty', () => { + render( + , + ); + expect(screen.queryByText('Milestone Timeline')).not.toBeInTheDocument(); + }); + }); + + // ── Cost breakdown ───────────────────────────────────────────────────────── + + describe('cost breakdown section', () => { + const costItems = [ + { label: 'Base Freight', amount: 450.0 }, + { label: 'Fuel Surcharge', amount: 50.0 }, + { label: 'Loyalty Discount', amount: 25.0, isDiscount: true }, + ]; + + it('renders the cost breakdown section heading', () => { + render( + , + ); + expect(screen.getByText('Cost Breakdown')).toBeInTheDocument(); + }); + + it('renders each cost item label', () => { + render( + , + ); + expect(screen.getByText('Base Freight')).toBeInTheDocument(); + expect(screen.getByText('Fuel Surcharge')).toBeInTheDocument(); + expect(screen.getByText('Loyalty Discount')).toBeInTheDocument(); + }); + + it('renders the total cost row when totalCost is provided', () => { + render( + , + ); + expect(screen.getByText('USD 475.00')).toBeInTheDocument(); + }); + }); + + // ── Payment info ────────────────────────────────────────────────────────── + + describe('payment info section', () => { + it('renders payment amount and token symbol', () => { + render( + , + ); + expect(screen.getByText('475.00 XLM')).toBeInTheDocument(); + }); + }); + + // ── Sensor snapshot ─────────────────────────────────────────────────────── + + describe('sensor snapshot section', () => { + it('renders the IoT sensor snapshot heading when provided', () => { + render( + , + ); + expect(screen.getByText(/IoT Sensor Snapshot/i)).toBeInTheDocument(); + }); + + it('renders temperature and humidity values', () => { + render( + , + ); + expect(screen.getByText('4°C')).toBeInTheDocument(); + expect(screen.getByText('65%')).toBeInTheDocument(); + }); + + it('renders GPS coordinates when location is provided', () => { + render( + , + ); + expect(screen.getByText('51.5074, -0.1278')).toBeInTheDocument(); + }); + }); + + // ── Notes and delivery proof ────────────────────────────────────────────── + + describe('notes and delivery proof', () => { + it('renders notes when provided', () => { + render( + , + ); + expect( + screen.getByText('Handle with care. Fragile items inside.'), + ).toBeInTheDocument(); + }); + + it('renders proof of delivery when provided', () => { + render( + , + ); + expect(screen.getByText('Signature confirmed by J. Smith')).toBeInTheDocument(); + expect(screen.getByText('Proof of Delivery')).toBeInTheDocument(); + }); + + it('renders Stellar TX hash when provided', () => { + render( + , + ); + expect(screen.getByText('TXHASH123456789ABCDEF')).toBeInTheDocument(); + }); + }); + + // ── Print + onClose lifecycle ───────────────────────────────────────────── + + describe('print and close lifecycle', () => { + it('calls window.print() after 200 ms', () => { + render(); + expect(window.print).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + expect(window.print).toHaveBeenCalledTimes(1); + }); + + it('calls onClose after window.print()', () => { + const onClose = vi.fn(); + render(); + expect(onClose).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call window.print() or onClose before the 200 ms delay', () => { + const onClose = vi.fn(); + render(); + + vi.advanceTimersByTime(100); // still within delay + expect(window.print).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('only calls window.print() once even if component re-renders', async () => { + const onClose = vi.fn(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + vi.advanceTimersByTime(200); + expect(window.print).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/components/ui/Combobox.test.tsx b/frontend/src/components/ui/Combobox.test.tsx new file mode 100644 index 00000000..a8333ec5 --- /dev/null +++ b/frontend/src/components/ui/Combobox.test.tsx @@ -0,0 +1,430 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Combobox, { type ComboboxOption } from './Combobox'; + +// jsdom does not implement scrollIntoView — Combobox calls it when navigating options. +beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); +}); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const OPTIONS: ComboboxOption[] = [ + { value: 'apple', label: 'Apple', sublabel: 'Fruit' }, + { value: 'banana', label: 'Banana', sublabel: 'Fruit' }, + { value: 'carrot', label: 'Carrot', sublabel: 'Vegetable' }, +]; + +function renderCombobox(props: Partial[0]> = {}) { + const defaultOnChange = vi.fn(); + const mergedProps = { onChange: defaultOnChange, ...props }; + const result = render( + , + ); + return { ...result, onChange: mergedProps.onChange }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Combobox', () => { + // ── Initial render ───────────────────────────────────────────────────────── + + describe('initial render', () => { + it('renders the combobox input', () => { + renderCombobox(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders with the provided placeholder', () => { + renderCombobox({ placeholder: 'Pick a fruit…' }); + expect(screen.getByPlaceholderText('Pick a fruit…')).toBeInTheDocument(); + }); + + it('renders with the controlled value', () => { + renderCombobox({ value: 'Apple' }); + expect(screen.getByRole('combobox')).toHaveValue('Apple'); + }); + + it('dropdown is closed on initial render', () => { + renderCombobox(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('clear button is not visible when value is empty', () => { + renderCombobox({ value: '' }); + expect(screen.queryByRole('button', { name: /clear input/i })).not.toBeInTheDocument(); + }); + }); + + // ── Opening the dropdown ─────────────────────────────────────────────────── + + describe('opening the dropdown', () => { + it('opens the dropdown when the chevron toggle button is clicked', async () => { + const user = userEvent.setup(); + renderCombobox({ value: '' }); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('opens the dropdown when the user types in the input', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.type(screen.getByRole('combobox'), 'a'); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('shows all options when the input is empty and dropdown opens', async () => { + const user = userEvent.setup(); + renderCombobox({ value: '' }); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getAllByRole('option').length).toBe(OPTIONS.length); + }); + + it('shows option labels in the dropdown', async () => { + const user = userEvent.setup(); + renderCombobox({ value: '' }); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.getByText('Carrot')).toBeInTheDocument(); + }); + + it('shows sublabels in the dropdown', async () => { + const user = userEvent.setup(); + renderCombobox({ value: '' }); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getAllByText('Fruit').length).toBeGreaterThanOrEqual(1); + }); + }); + + // ── Filtering ───────────────────────────────────────────────────────────── + + describe('filtering', () => { + it('filters options based on typed text', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + // Use a controlled wrapper to feed changing value back in + const { rerender } = render( + , + ); + + // Simulate typing 'ban' by re-rendering with updated value + await user.type(screen.getByRole('combobox'), 'ban'); + rerender( + , + ); + + const options = screen.getAllByRole('option'); + // Only 'Banana' should match + expect(options.length).toBe(1); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); + + it('shows the no-results message when nothing matches', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // Open dropdown — the message appears in both the live region (sr-only) + // and the visible listbox option, so use getAllByText + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getAllByText('Nothing found').length).toBeGreaterThanOrEqual(1); + }); + + it('is case-insensitive when filtering', async () => { + const onChange = vi.fn(); + + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /open suggestions/i })); + const options = screen.getAllByRole('option'); + expect(options.length).toBe(1); + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + }); + + // ── Selecting an option ─────────────────────────────────────────────────── + + describe('selecting an option', () => { + it('calls onChange with the option label when an option is clicked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + await user.click(screen.getByText('Banana')); + expect(onChange).toHaveBeenCalledWith('Banana'); + }); + + it('calls onSelectOption with the full option object when provided', async () => { + const user = userEvent.setup(); + const onSelectOption = vi.fn(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + await user.click(screen.getByText('Carrot')); + expect(onSelectOption).toHaveBeenCalledWith(OPTIONS[2]); + }); + + it('closes the dropdown after selecting an option', async () => { + const user = userEvent.setup(); + + // Use a controlled wrapper so the value prop updates when onChange fires, + // preventing the focus handler from reopening the dropdown with stale value. + function Wrapper() { + const [val, setVal] = React.useState(''); + return ( + + ); + } + + render(); + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + await user.click(screen.getByText('Apple')); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + // ── Keyboard navigation ─────────────────────────────────────────────────── + + describe('keyboard navigation', () => { + it('opens the dropdown with ArrowDown when closed', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('navigates options with ArrowDown / ArrowUp', async () => { + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByRole('combobox'); + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', 'cb-listbox-option-0'); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', 'cb-listbox-option-1'); + + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', 'cb-listbox-option-0'); + }); + + it('selects the active option with Enter', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onChange).toHaveBeenCalledWith('Apple'); + }); + + it('closes the dropdown with Escape', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + // ── Clear button ────────────────────────────────────────────────────────── + + describe('clear button', () => { + it('shows the clear button when there is a value and clearable=true', () => { + renderCombobox({ value: 'Apple', clearable: true }); + expect(screen.getByRole('button', { name: /clear input/i })).toBeInTheDocument(); + }); + + it('calls onChange with empty string when clear button is clicked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /clear input/i })); + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('does not show the clear button when clearable=false', () => { + renderCombobox({ value: 'Apple', clearable: false }); + expect(screen.queryByRole('button', { name: /clear input/i })).not.toBeInTheDocument(); + }); + }); + + // ── Loading state ───────────────────────────────────────────────────────── + + describe('loading state', () => { + it('shows the loading message when isLoading=true', async () => { + const user = userEvent.setup(); + renderCombobox({ isLoading: true, loadingMessage: 'Fetching…' }); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getByText('Fetching…')).toBeInTheDocument(); + }); + }); + + // ── Disabled state ──────────────────────────────────────────────────────── + + describe('disabled state', () => { + it('disables the input when disabled=true', () => { + renderCombobox({ disabled: true }); + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + }); + + // ── Closing on outside click ────────────────────────────────────────────── + + describe('outside click', () => { + it('closes the dropdown when clicking outside the component', async () => { + const user = userEvent.setup(); + render( +
+ + +
, + ); + + await user.click(screen.getByRole('button', { name: /open suggestions/i })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Outside' })); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); +});