Phases 6-7

This commit is contained in:
Aerilyn Weber 2026-05-14 14:47:23 +09:00
parent 76a516a417
commit 029940b079
111 changed files with 17247 additions and 447 deletions

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});