Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
84
packages/web/src/components/__tests__/Sidebar.test.tsx
Normal file
84
packages/web/src/components/__tests__/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUsePathname } = vi.hoisted(() => ({
|
||||
mockUsePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: mockUsePathname,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
import { Sidebar } from '../layout/Sidebar';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/dashboard');
|
||||
mockUseApi.mockReturnValue({ profile: { displayName: 'Alice' } });
|
||||
});
|
||||
|
||||
it('renders brand name', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('MeshiTrack')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders navigation sections', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medicines')).toBeInTheDocument();
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders medicines nav items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Cabinet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regimens')).toBeInTheDocument();
|
||||
expect(screen.getByText('Library')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders food nav items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights active route', () => {
|
||||
mockUsePathname.mockReturnValue('/medicines/cabinet');
|
||||
render(<Sidebar />);
|
||||
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||
expect(cabinetLink).toHaveAttribute('href', '/medicines/cabinet');
|
||||
});
|
||||
|
||||
it('shows user avatar with display name', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback name when no profile', () => {
|
||||
mockUseApi.mockReturnValue({ profile: null });
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByLabelText('User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights nested route', () => {
|
||||
mockUsePathname.mockReturnValue('/medicines/cabinet/some-id');
|
||||
render(<Sidebar />);
|
||||
// Cabinet link should still match via startsWith
|
||||
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||
expect(cabinetLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
125
packages/web/src/components/__tests__/ThemeProvider.test.tsx
Normal file
125
packages/web/src/components/__tests__/ThemeProvider.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ThemeProvider, useTheme } from '../ThemeProvider';
|
||||
import { Providers } from '../Providers';
|
||||
|
||||
// Mock next-auth SessionProvider
|
||||
vi.mock('next-auth/react', () => ({
|
||||
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
function ThemeConsumer() {
|
||||
const { theme, accent, setTheme, setAccent, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="accent">{accent}</span>
|
||||
<button onClick={() => setTheme('dark')}>Set dark</button>
|
||||
<button onClick={() => setAccent('cobalt')}>Set cobalt</button>
|
||||
<button onClick={toggleTheme}>Toggle</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.cssText = '';
|
||||
});
|
||||
|
||||
it('provides default light/sage theme', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(screen.getByTestId('accent').textContent).toBe('sage');
|
||||
});
|
||||
|
||||
it('hydrates from localStorage', () => {
|
||||
localStorage.setItem('mt-theme', 'dark');
|
||||
localStorage.setItem('mt-accent', 'terracotta');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(screen.getByTestId('accent').textContent).toBe('terracotta');
|
||||
});
|
||||
|
||||
it('setTheme updates theme and persists', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Set dark')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(localStorage.getItem('mt-theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('setAccent updates accent and applies CSS vars', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Set cobalt')));
|
||||
|
||||
expect(screen.getByTestId('accent').textContent).toBe('cobalt');
|
||||
expect(localStorage.getItem('mt-accent')).toBe('cobalt');
|
||||
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#2e5aa8');
|
||||
});
|
||||
|
||||
it('toggleTheme flips light to dark', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Toggle')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('toggleTheme flips dark to light', () => {
|
||||
localStorage.setItem('mt-theme', 'dark');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Toggle')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('useTheme throws outside provider', () => {
|
||||
expect(() => render(<ThemeConsumer />)).toThrow('useTheme must be used inside ThemeProvider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Providers', () => {
|
||||
it('renders children with session and theme providers', () => {
|
||||
render(
|
||||
<Providers>
|
||||
<span>child content</span>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('child content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
packages/web/src/components/__tests__/TopBar.test.tsx
Normal file
42
packages/web/src/components/__tests__/TopBar.test.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/ThemeProvider', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/PageHeaderContext', () => ({
|
||||
usePageHeader: () => ({
|
||||
header: {
|
||||
title: 'Test Page',
|
||||
subtitle: 'Subtitle here',
|
||||
crumbs: ['Home', 'Test Page'],
|
||||
actions: null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TopBar } from '../layout/TopBar';
|
||||
|
||||
describe('TopBar', () => {
|
||||
it('renders title and subtitle', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Page');
|
||||
expect(screen.getByText('Subtitle here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumbs', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByPlaceholderText(/Search medicines/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme toggle button', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByLabelText('Toggle theme')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
84
packages/web/src/components/__tests__/ui.test.tsx
Normal file
84
packages/web/src/components/__tests__/ui.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { Ring } from '../ui/Ring';
|
||||
import { SparkBars } from '../ui/SparkBars';
|
||||
import { SupplyBar } from '../ui/SupplyBar';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initial from name', () => {
|
||||
render(<Avatar name="Alice" />);
|
||||
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses custom size', () => {
|
||||
render(<Avatar name="Bob" size={48} />);
|
||||
const el = screen.getByLabelText('Bob');
|
||||
expect(el).toHaveStyle({ width: '48px', height: '48px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('renders with label and icon', () => {
|
||||
render(<IconButton icon="settings" label="Settings" />);
|
||||
expect(screen.getByLabelText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification dot when dot=true', () => {
|
||||
render(<IconButton icon="bell" label="Notifications" dot />);
|
||||
// dot is a span inside the button
|
||||
const btn = screen.getByLabelText('Notifications');
|
||||
expect(btn.querySelectorAll('span').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ring', () => {
|
||||
it('renders value and total', () => {
|
||||
render(<Ring value={5} total={10} />);
|
||||
expect(screen.getByLabelText('5 of 10 doses taken')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles zero total', () => {
|
||||
render(<Ring value={0} total={0} />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SparkBars', () => {
|
||||
it('renders bars with labels', () => {
|
||||
const data = [
|
||||
{ label: 'Jan', amount: 100 },
|
||||
{ label: 'Feb', amount: 200 },
|
||||
];
|
||||
render(<SparkBars data={data} />);
|
||||
expect(screen.getByText('Jan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feb')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted amounts', () => {
|
||||
const data = [{ label: 'Mar', amount: 5000 }];
|
||||
render(<SparkBars data={data} />);
|
||||
expect(screen.getByText('5k')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SupplyBar', () => {
|
||||
it('renders days value', () => {
|
||||
render(<SupplyBar days={30} />);
|
||||
expect(screen.getByText('30')).toBeInTheDocument();
|
||||
expect(screen.getByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders for critical days', () => {
|
||||
render(<SupplyBar days={5} />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders for medium days', () => {
|
||||
render(<SupplyBar days={10} />);
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue