Feat/frontend tests (#62)

* test(frontend): add vitest test infrastructure

- Add vitest, testing-library, jsdom dependencies
- Configure vitest with jsdom environment
- Add test setup with mocks for fetch, localStorage, matchMedia
- Set 75% coverage threshold

* Add frontend tests (#61)

* Initial plan

* Add frontend tests - utilities, hooks, and components (21% coverage)

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add more component tests (Auth, AboutModal, ExportModal) and useRefill hook tests - 30% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add useMedicationForm utility function tests - 30% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add AppHeader tests and more schedule.ts tests - 32% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Fix page tests and add more tests - 326 tests passing, 34% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add tests for ProfileModal, UserFilterModal, MedDetailModal - 361 tests, 36% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add SharedSchedule tests - 366 tests, 39% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Expand page tests - 383 tests, 39% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add MobileEditModal tests - 409 tests, 40% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Expand Dashboard and Schedule page tests - 427 tests, 40% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Fix code review issues - remove invalid remindEnabled property

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
This commit is contained in:
Daniel Volz
2026-01-22 10:25:11 +01:00
committed by GitHub
parent 8718311876
commit fd055a3a2a
36 changed files with 7602 additions and 3 deletions
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import AboutModal from '../../components/AboutModal';
// Mock App module for constants
vi.mock('../../App', () => ({
FRONTEND_VERSION: '1.0.0',
GITHUB_URL: 'https://github.com/test/repo'
}));
describe('AboutModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: '1.0.0' })
});
});
it('returns null when not open', () => {
const { container } = render(<AboutModal {...defaultProps} isOpen={false} />);
expect(container.firstChild).toBeNull();
});
it('renders when open', () => {
render(<AboutModal {...defaultProps} />);
expect(screen.getByText(/about\.appName/i)).toBeInTheDocument();
});
it('displays version number', () => {
render(<AboutModal {...defaultProps} />);
expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<AboutModal {...defaultProps} />);
fireEvent.click(screen.getByText('×'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when overlay is clicked', () => {
const { container } = render(<AboutModal {...defaultProps} />);
const overlay = container.querySelector('.modal-overlay');
fireEvent.click(overlay!);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('does not call onClose when modal content is clicked', () => {
const { container } = render(<AboutModal {...defaultProps} />);
const content = container.querySelector('.about-modal');
if (content) {
fireEvent.click(content);
expect(defaultProps.onClose).not.toHaveBeenCalled();
}
});
it('renders GitHub link', () => {
render(<AboutModal {...defaultProps} />);
const links = screen.getAllByRole('link');
expect(links.length).toBeGreaterThan(0);
});
it('fetches backend version on open', async () => {
render(<AboutModal {...defaultProps} />);
expect(fetch).toHaveBeenCalledWith('/api/health');
});
});
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { AppHeader } from '../../components/AppHeader';
import { AuthProvider } from '../../components/Auth';
// Mock useNavigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
describe('AppHeader', () => {
beforeEach(() => {
vi.clearAllMocks();
mockNavigate.mockClear();
// Set up default auth mock - auth disabled
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
authEnabled: false,
localAuthEnabled: true,
hasUsers: false,
needsSetup: false
})
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
});
it('renders header with logo', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
const logo = screen.getByAltText('MedAssist-ng');
expect(logo).toBeInTheDocument();
});
});
it('renders navigation tabs', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
// Use getAllBy since there are multiple elements with same text
const dashboardElements = screen.getAllByText(/nav\.dashboard/i);
expect(dashboardElements.length).toBeGreaterThan(0);
});
});
it('renders theme toggle button', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
const themeBtn = buttons.find(btn => btn.textContent?.includes('🌙') || btn.textContent?.includes('☀️'));
expect(themeBtn).toBeInTheDocument();
});
});
it('renders settings button when auth is disabled', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
const settingsBtn = screen.queryByTitle(/nav\.settings/i);
expect(settingsBtn).toBeInTheDocument();
});
});
it('shows page eyebrow and title', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/header\.eyebrow\.overview/i)).toBeInTheDocument();
});
});
it('shows medications page title on medications route', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
// Reset mock for this test
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
authEnabled: false,
localAuthEnabled: true,
hasUsers: false,
needsSetup: false
})
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
render(
<MemoryRouter initialEntries={['/medications']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/header\.eyebrow\.inventory/i)).toBeInTheDocument();
});
});
it('shows planner page title on planner route', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
authEnabled: false,
localAuthEnabled: true,
hasUsers: false,
needsSetup: false
})
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
render(
<MemoryRouter initialEntries={['/planner']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/header\.eyebrow\.planner/i)).toBeInTheDocument();
});
});
it('shows settings page title on settings route', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
authEnabled: false,
localAuthEnabled: true,
hasUsers: false,
needsSetup: false
})
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
render(
<MemoryRouter initialEntries={['/settings']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/header\.eyebrow\.settings/i)).toBeInTheDocument();
});
});
it('navigates when tab clicked', async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
const medsBtn = buttons.find(btn => btn.textContent?.includes('nav.medications'));
if (medsBtn) {
fireEvent.click(medsBtn);
expect(mockNavigate).toHaveBeenCalledWith('/medications');
}
});
});
});
+359
View File
@@ -0,0 +1,359 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth, LoginForm, RegisterForm, UserProfile, AuthPage } from '../../components/Auth';
import React from 'react';
// Wrapper component for testing hooks that require AuthProvider
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe('AuthProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true })
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('provides auth context to children', () => {
render(
<AuthProvider>
<div data-testid="child">Child content</div>
</AuthProvider>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('initializes with loading state', () => {
const { result } = renderHook(() => useAuth(), { wrapper });
// Initially loading
expect(result.current.loading).toBe(true);
});
it('fetches auth state on mount', async () => {
renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/api/auth/state');
});
});
it('throws error when useAuth is used outside AuthProvider', () => {
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
});
});
describe('LoginForm', () => {
const mockAuthState = {
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: true,
needsSetup: false,
oidcProviderName: ''
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthState)
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
});
it('renders login form', async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
});
});
it('renders username and password fields', async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
});
});
it('renders remember me checkbox', async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/auth\.rememberMe/i)).toBeInTheDocument();
});
});
it('renders create account link when registration enabled', async () => {
const onSwitchToRegister = vi.fn();
render(
<AuthProvider>
<LoginForm onSwitchToRegister={onSwitchToRegister} />
</AuthProvider>
);
await waitFor(() => {
const createAccountBtn = screen.getByText(/auth\.createAccount/i);
expect(createAccountBtn).toBeInTheDocument();
});
});
it('handles form input changes', async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: 'testuser' } });
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: 'password123' } });
expect(screen.getByLabelText(/auth\.username/i)).toHaveValue('testuser');
expect(screen.getByLabelText(/auth\.password/i)).toHaveValue('password123');
});
it('renders submit button', async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
expect(submitBtn).toBeInTheDocument();
});
});
});
describe('RegisterForm', () => {
const mockAuthState = {
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: false,
needsSetup: true,
oidcProviderName: ''
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthState)
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
});
it('renders registration form', async () => {
render(
<AuthProvider>
<RegisterForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
});
});
it('renders all required fields', async () => {
render(
<AuthProvider>
<RegisterForm />
</AuthProvider>
);
await waitFor(() => {
// Check for username field
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
// Check for password field
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
});
});
it('renders switch to login link', async () => {
const onSwitchToLogin = vi.fn();
render(
<AuthProvider>
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
</AuthProvider>
);
await waitFor(() => {
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
expect(loginLink).toBeInTheDocument();
});
});
it('calls onSwitchToLogin when clicked', async () => {
const onSwitchToLogin = vi.fn();
render(
<AuthProvider>
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
</AuthProvider>
);
await waitFor(() => {
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
fireEvent.click(loginLink);
});
expect(onSwitchToLogin).toHaveBeenCalled();
});
});
describe('AuthPage', () => {
const mockAuthState = {
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: true,
needsSetup: false,
oidcProviderName: ''
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthState)
})
.mockResolvedValueOnce({
status: 401,
ok: false
});
});
it('renders login form by default', async () => {
render(
<AuthProvider>
<AuthPage />
</AuthProvider>
);
await waitFor(() => {
// Should show login form with username field
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
});
});
});
describe('UserProfile', () => {
const mockUser = {
id: 1,
username: 'testuser',
avatarUrl: null
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true })
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser)
});
});
it('renders user profile when user is logged in', async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText('testuser')).toBeInTheDocument();
});
});
it('displays user avatar initial when no avatar', async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
// The avatar shows first letter of username
expect(screen.getByText('T')).toBeInTheDocument();
});
});
it('renders change password section', async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/auth\.changePassword/i)).toBeInTheDocument();
});
});
it('renders cancel button that calls onClose', async () => {
const onClose = vi.fn();
render(
<AuthProvider>
<UserProfile onClose={onClose} />
</AuthProvider>
);
await waitFor(() => {
const cancelBtn = screen.getByText(/common\.cancel/i);
fireEvent.click(cancelBtn);
});
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmModal } from '../../components/ConfirmModal';
describe('ConfirmModal', () => {
const defaultProps = {
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
confirmLabel: 'Yes',
cancelLabel: 'No',
onConfirm: vi.fn(),
onCancel: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders title', () => {
render(<ConfirmModal {...defaultProps} />);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
});
it('renders message as string', () => {
render(<ConfirmModal {...defaultProps} />);
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('renders message as ReactNode', () => {
render(
<ConfirmModal
{...defaultProps}
message={<span data-testid="custom-message">Custom message</span>}
/>
);
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
});
it('renders confirm and cancel buttons', () => {
render(<ConfirmModal {...defaultProps} />);
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('No')).toBeInTheDocument();
});
it('calls onConfirm when confirm button is clicked', () => {
render(<ConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('Yes'));
expect(defaultProps.onConfirm).toHaveBeenCalled();
});
it('calls onCancel when cancel button is clicked', () => {
render(<ConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('No'));
expect(defaultProps.onCancel).toHaveBeenCalled();
});
it('calls onCancel when close button is clicked', () => {
render(<ConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('×'));
expect(defaultProps.onCancel).toHaveBeenCalled();
});
it('calls onCancel when overlay is clicked', () => {
const { container } = render(<ConfirmModal {...defaultProps} />);
const overlay = container.querySelector('.modal-overlay');
fireEvent.click(overlay!);
expect(defaultProps.onCancel).toHaveBeenCalled();
});
it('does not call onCancel when modal content is clicked', () => {
const { container } = render(<ConfirmModal {...defaultProps} />);
const content = container.querySelector('.modal-content');
fireEvent.click(content!);
expect(defaultProps.onCancel).not.toHaveBeenCalled();
});
it('disables buttons when loading', () => {
render(<ConfirmModal {...defaultProps} isLoading={true} />);
expect(screen.getByText('Yes')).toBeDisabled();
expect(screen.getByText('No')).toBeDisabled();
});
it('applies primary variant by default', () => {
render(<ConfirmModal {...defaultProps} />);
const confirmBtn = screen.getByText('Yes');
expect(confirmBtn.className).toContain('primary');
});
it('applies danger variant when specified', () => {
render(<ConfirmModal {...defaultProps} confirmVariant="danger" />);
const confirmBtn = screen.getByText('Yes');
expect(confirmBtn.className).toContain('danger');
});
it('applies success variant when specified', () => {
render(<ConfirmModal {...defaultProps} confirmVariant="success" />);
const confirmBtn = screen.getByText('Yes');
expect(confirmBtn.className).toContain('success');
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ExportModal from '../../components/ExportModal';
describe('ExportModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onExport: vi.fn(),
exporting: false
};
beforeEach(() => {
vi.clearAllMocks();
});
it('returns null when not open', () => {
const { container } = render(<ExportModal {...defaultProps} isOpen={false} />);
expect(container.firstChild).toBeNull();
});
it('renders when open', () => {
render(<ExportModal {...defaultProps} />);
expect(screen.getByText(/exportImport\.exportOptions/i)).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ExportModal {...defaultProps} />);
fireEvent.click(screen.getByText('×'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when overlay is clicked', () => {
const { container } = render(<ExportModal {...defaultProps} />);
const overlay = container.querySelector('.modal-overlay');
fireEvent.click(overlay!);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('renders export options', () => {
const { container } = render(<ExportModal {...defaultProps} />);
// Should have action card buttons
const actionCards = container.querySelectorAll('.action-card');
expect(actionCards.length).toBe(2);
});
it('calls onExport with true when export with images button clicked', () => {
const { container } = render(<ExportModal {...defaultProps} />);
const actionCards = container.querySelectorAll('.action-card');
fireEvent.click(actionCards[0]);
expect(defaultProps.onClose).toHaveBeenCalled();
expect(defaultProps.onExport).toHaveBeenCalledWith(true);
});
it('calls onExport with false when export data only button clicked', () => {
const { container } = render(<ExportModal {...defaultProps} />);
const actionCards = container.querySelectorAll('.action-card');
fireEvent.click(actionCards[1]);
expect(defaultProps.onClose).toHaveBeenCalled();
expect(defaultProps.onExport).toHaveBeenCalledWith(false);
});
it('disables buttons when exporting', () => {
const { container } = render(<ExportModal {...defaultProps} exporting={true} />);
const actionCards = container.querySelectorAll('.action-card');
actionCards.forEach(card => {
expect(card).toBeDisabled();
});
});
it('renders cancel button', () => {
render(<ExportModal {...defaultProps} />);
expect(screen.getByText(/exportImport\.cancelButton/i)).toBeInTheDocument();
});
it('calls onClose when cancel button is clicked', () => {
render(<ExportModal {...defaultProps} />);
fireEvent.click(screen.getByText(/exportImport\.cancelButton/i));
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Lightbox } from '../../components/Lightbox';
describe('Lightbox', () => {
const defaultProps = {
src: '/test-image.jpg',
alt: 'Test Image',
onClose: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders image with correct src and alt', () => {
render(<Lightbox {...defaultProps} />);
const img = screen.getByAltText('Test Image');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/test-image.jpg');
});
it('renders close button', () => {
render(<Lightbox {...defaultProps} />);
expect(screen.getByText('×')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
const onClose = vi.fn();
render(<Lightbox {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByText('×'));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when overlay is clicked', () => {
const onClose = vi.fn();
const { container } = render(<Lightbox {...defaultProps} onClose={onClose} />);
const overlay = container.querySelector('.lightbox-overlay');
fireEvent.click(overlay!);
expect(onClose).toHaveBeenCalled();
});
it('does not call onClose when image is clicked', () => {
const onClose = vi.fn();
render(<Lightbox {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByAltText('Test Image'));
expect(onClose).not.toHaveBeenCalled();
});
it('applies correct CSS classes', () => {
const { container } = render(<Lightbox {...defaultProps} />);
expect(container.querySelector('.lightbox-overlay')).toBeInTheDocument();
expect(container.querySelector('.lightbox-close')).toBeInTheDocument();
expect(container.querySelector('.lightbox-image')).toBeInTheDocument();
});
});
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MedDetailModal } from '../../components/MedDetailModal';
import type { Medication, Coverage, StockThresholds, RefillEntry } from '../../types';
const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90
};
const mockMedication: Medication = {
id: 1,
name: 'Test Med',
genericName: 'Generic Name',
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ['John'],
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00' }],
updatedAt: null,
expiryDate: '2025-12-31',
notes: 'Test notes'
};
const mockCoverage: Coverage = {
name: 'Test Med',
medsLeft: 25,
daysLeft: 25,
depletionDate: '2024-04-01',
depletionTime: Date.now() + 25 * 86400000,
nextDose: null
};
const defaultProps = {
selectedMed: mockMedication,
coverage: { all: [mockCoverage] },
settings: defaultSettings,
showImageLightbox: false,
showRefillModal: false,
showEditStockModal: false,
onClose: vi.fn(),
onOpenImageLightbox: vi.fn(),
onCloseImageLightbox: vi.fn(),
onOpenRefillModal: vi.fn(),
onCloseRefillModal: vi.fn(),
onOpenEditStockModal: vi.fn(),
onCloseEditStockModal: vi.fn(),
refillPacks: 0,
onRefillPacksChange: vi.fn(),
refillLoose: 0,
onRefillLooseChange: vi.fn(),
refillSaving: false,
refillHistory: [] as RefillEntry[],
refillHistoryExpanded: false,
onRefillHistoryExpandedChange: vi.fn(),
onSubmitRefill: vi.fn(),
editStockFullBlisters: 0,
onEditStockFullBlistersChange: vi.fn(),
editStockPartialBlisterPills: 0,
onEditStockPartialBlisterPillsChange: vi.fn(),
editStockSaving: false,
onSubmitStockCorrection: vi.fn()
};
describe('MedDetailModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when selectedMed is null', () => {
render(<MedDetailModal {...defaultProps} selectedMed={null} />);
expect(screen.queryByText('Test Med')).not.toBeInTheDocument();
});
it('renders modal when medication is selected', () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
it('displays medication name', () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
it('displays generic name', () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText('Generic Name')).toBeInTheDocument();
});
it('renders close button', () => {
render(<MedDetailModal {...defaultProps} />);
const closeBtn = screen.getByText('×');
expect(closeBtn).toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const closeBtn = screen.getByText('×');
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when overlay clicked', () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const overlay = document.querySelector('.modal-overlay');
if (overlay) {
fireEvent.click(overlay);
}
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not call onClose when modal content clicked', () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const content = document.querySelector('.modal-content');
if (content) {
fireEvent.click(content);
}
expect(onClose).not.toHaveBeenCalled();
});
it('displays notes when available', () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText('Test notes')).toBeInTheDocument();
});
it('displays schedule information', () => {
render(<MedDetailModal {...defaultProps} />);
// Should have schedule section
const scheduleSection = document.querySelector('.med-detail-schedules');
expect(scheduleSection).toBeInTheDocument();
});
it('renders med detail header', () => {
render(<MedDetailModal {...defaultProps} />);
const header = document.querySelector('.med-detail-header');
expect(header).toBeInTheDocument();
});
it('renders med detail body', () => {
render(<MedDetailModal {...defaultProps} />);
const body = document.querySelector('.med-detail-body');
expect(body).toBeInTheDocument();
});
});
describe('MedDetailModal without coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('works without coverage data', () => {
render(<MedDetailModal {...defaultProps} coverage={{ all: [] }} />);
// Should still render the medication name
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
});
describe('MedDetailModal without optional fields', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('works without generic name', () => {
const med = { ...mockMedication, genericName: null };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
it('works without notes', () => {
const med = { ...mockMedication, notes: null };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
it('works without takenBy', () => {
const med = { ...mockMedication, takenBy: [] };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
it('works without expiryDate', () => {
const med = { ...mockMedication, expiryDate: null };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MedicationAvatar } from '../../components/MedicationAvatar';
describe('MedicationAvatar', () => {
it('renders initials when no image provided', () => {
render(<MedicationAvatar name="Test Medication" />);
expect(screen.getByText('TM')).toBeInTheDocument();
});
it('uses first two initials from medication name', () => {
render(<MedicationAvatar name="Very Long Medication Name" />);
expect(screen.getByText('VL')).toBeInTheDocument();
});
it('handles single word names', () => {
render(<MedicationAvatar name="Aspirin" />);
expect(screen.getByText('A')).toBeInTheDocument();
});
it('renders image when imageUrl provided', () => {
render(<MedicationAvatar name="Test Med" imageUrl="test-image.jpg" />);
const img = screen.getByAltText('Test Med');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/api/images/test-image.jpg');
});
it('applies small size class by default', () => {
const { container } = render(<MedicationAvatar name="Test" />);
expect(container.querySelector('.med-avatar-sm')).toBeInTheDocument();
});
it('applies medium size class', () => {
const { container } = render(<MedicationAvatar name="Test" size="md" />);
expect(container.querySelector('.med-avatar-md')).toBeInTheDocument();
});
it('applies large size class', () => {
const { container } = render(<MedicationAvatar name="Test" size="lg" />);
expect(container.querySelector('.med-avatar-lg')).toBeInTheDocument();
});
it('handles empty name with fallback', () => {
render(<MedicationAvatar name="" />);
expect(screen.getByText('?')).toBeInTheDocument();
});
it('converts initials to uppercase', () => {
render(<MedicationAvatar name="lower case" />);
expect(screen.getByText('LC')).toBeInTheDocument();
});
it('adds initials class when no image', () => {
const { container } = render(<MedicationAvatar name="Test" />);
expect(container.querySelector('.med-avatar-initials')).toBeInTheDocument();
});
});
@@ -0,0 +1,271 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MobileEditModal } from '../../components/MobileEditModal';
import type { FormState, FormBlister } from '../../types';
const defaultForm: FormState = {
name: '',
genericName: '',
takenBy: [],
packCount: '1',
blistersPerPack: '1',
pillsPerBlister: '1',
looseTablets: '0',
pillWeightMg: '',
expiryDate: '',
notes: '',
intakeRemindersEnabled: false,
blisters: [{
usage: '1',
every: '1',
startDate: '2024-01-01',
startTime: '09:00'
}]
};
const defaultProps = {
show: true,
editingId: null,
form: defaultForm,
onFormChange: vi.fn(),
fieldErrors: {},
saving: false,
formSaved: false,
formChanged: false,
hasValidationErrors: false,
takenByInput: '',
onTakenByInputChange: vi.fn(),
existingPeople: [],
onAddTakenByPerson: vi.fn(),
onRemoveTakenByPerson: vi.fn(),
onTakenByKeyDown: vi.fn(),
onSetBlisterValue: vi.fn(),
onAddBlister: vi.fn(),
onRemoveBlister: vi.fn(),
onHandleValueChange: vi.fn(),
refillPacks: 0,
onRefillPacksChange: vi.fn(),
refillLoose: 0,
onRefillLooseChange: vi.fn(),
refillSaving: false,
onSubmitRefill: vi.fn(),
meds: [],
onUploadMedImage: vi.fn(),
onDeleteMedImage: vi.fn(),
onClose: vi.fn(),
onResetForm: vi.fn(),
onSaveMedication: vi.fn()
};
describe('MobileEditModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when show is false', () => {
render(<MobileEditModal {...defaultProps} show={false} />);
expect(screen.queryByText(/form\.newEntry/i)).not.toBeInTheDocument();
});
it('renders modal when show is true', () => {
render(<MobileEditModal {...defaultProps} />);
// Should render the modal overlay
const modal = document.querySelector('.modal-overlay');
expect(modal).toBeInTheDocument();
});
it('shows new entry title when not editing', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.newEntry/i)).toBeInTheDocument();
});
it('shows edit entry title when editing', () => {
render(<MobileEditModal {...defaultProps} editingId={1} />);
expect(screen.getByText(/form\.editEntry/i)).toBeInTheDocument();
});
it('renders close button', () => {
render(<MobileEditModal {...defaultProps} />);
const closeBtn = document.querySelector('.modal-close');
expect(closeBtn).toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
const onClose = vi.fn();
const onResetForm = vi.fn();
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
const closeBtn = document.querySelector('.modal-close');
if (closeBtn) {
fireEvent.click(closeBtn);
}
expect(onClose).toHaveBeenCalledTimes(1);
expect(onResetForm).toHaveBeenCalledTimes(1);
});
it('renders form element', () => {
render(<MobileEditModal {...defaultProps} />);
const form = document.querySelector('form');
expect(form).toBeInTheDocument();
});
it('renders name input', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.commercialName/i)).toBeInTheDocument();
});
it('renders generic name input', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
});
it('renders packs input', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.packs/i)).toBeInTheDocument();
});
it('renders blisters per pack input', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.blistersPerPack/i)).toBeInTheDocument();
});
it('renders pills per blister input', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.pillsPerBlister/i)).toBeInTheDocument();
});
it('renders loose tablets input', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.loose/i)).toBeInTheDocument();
});
it('renders intake schedules section', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.blisters\.title/i)).toBeInTheDocument();
});
it('renders save button', () => {
render(<MobileEditModal {...defaultProps} />);
const saveBtn = document.querySelector('button[type="submit"]');
expect(saveBtn).toBeInTheDocument();
});
it('disables save when saving', () => {
render(<MobileEditModal {...defaultProps} saving={true} />);
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(saveBtn).toBeDisabled();
});
it('disables save when has validation errors', () => {
render(<MobileEditModal {...defaultProps} hasValidationErrors={true} />);
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(saveBtn).toBeDisabled();
});
it('renders add intake button', () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
});
it('calls onAddBlister when add intake clicked', () => {
const onAddBlister = vi.fn();
render(<MobileEditModal {...defaultProps} onAddBlister={onAddBlister} />);
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
fireEvent.click(addBtn);
expect(onAddBlister).toHaveBeenCalledTimes(1);
});
it('renders modal content', () => {
render(<MobileEditModal {...defaultProps} />);
const content = document.querySelector('.modal-content.edit-modal');
expect(content).toBeInTheDocument();
});
it('renders edit modal header', () => {
render(<MobileEditModal {...defaultProps} />);
const header = document.querySelector('.edit-modal-header');
expect(header).toBeInTheDocument();
});
});
describe('MobileEditModal with existing people', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders modal with existing people prop', () => {
render(<MobileEditModal {...defaultProps} existingPeople={['John', 'Jane']} />);
// Should render the modal - suggestions shown on input focus
const modal = document.querySelector('.modal-overlay');
expect(modal).toBeInTheDocument();
});
});
describe('MobileEditModal with form errors', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows name error when present', () => {
render(<MobileEditModal {...defaultProps} fieldErrors={{ name: 'Name is required' }} />);
expect(screen.getByText('Name is required')).toBeInTheDocument();
});
it('shows notes error when present', () => {
render(<MobileEditModal {...defaultProps} fieldErrors={{ notes: 'Notes too long' }} />);
expect(screen.getByText('Notes too long')).toBeInTheDocument();
});
});
describe('MobileEditModal blister management', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders blister rows', () => {
render(<MobileEditModal {...defaultProps} />);
const blisterRows = document.querySelectorAll('.blister-row');
expect(blisterRows.length).toBe(1);
});
it('renders remove button for each blister', () => {
const form = {
...defaultForm,
blisters: [
{ usage: '1', every: '1', startDate: '2024-01-01', startTime: '09:00' },
{ usage: '2', every: '7', startDate: '2024-01-01', startTime: '10:00' }
]
};
render(<MobileEditModal {...defaultProps} form={form} />);
const blisterRows = document.querySelectorAll('.blister-row');
expect(blisterRows.length).toBe(2);
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ProfileModal from '../../components/ProfileModal';
// Mock Auth UserProfile component
vi.mock('../../components/Auth', () => ({
UserProfile: ({ onClose }: { onClose: () => void }) => (
<div data-testid="user-profile">User Profile Content</div>
)
}));
describe('ProfileModal', () => {
it('renders nothing when not open', () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={false} onClose={onClose} />);
expect(screen.queryByTestId('user-profile')).not.toBeInTheDocument();
});
it('renders modal when open', () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
it('renders close button', () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
const closeBtn = screen.getByText('×');
expect(closeBtn).toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
const closeBtn = screen.getByText('×');
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when overlay clicked', () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
const overlay = document.querySelector('.modal-overlay');
if (overlay) {
fireEvent.click(overlay);
}
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not call onClose when modal content clicked', () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
const content = document.querySelector('.modal-content');
if (content) {
fireEvent.click(content);
}
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ShareDialog } from '../../components/ShareDialog';
describe('ShareDialog', () => {
const defaultProps = {
show: true,
sharePeople: ['Alice', 'Bob'],
shareSelectedPerson: 'Alice',
onShareSelectedPersonChange: vi.fn(),
shareSelectedDays: 30,
onShareSelectedDaysChange: vi.fn(),
shareGenerating: false,
shareLink: null,
onShareLinkChange: vi.fn(),
shareCopied: false,
onShareCopiedChange: vi.fn(),
onClose: vi.fn(),
onGenerateShareLink: vi.fn(),
onCopyShareLink: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('returns null when show is false', () => {
const { container } = render(<ShareDialog {...defaultProps} show={false} />);
expect(container.firstChild).toBeNull();
});
it('renders dialog when show is true', () => {
render(<ShareDialog {...defaultProps} />);
expect(screen.getByText(/share\.title/i)).toBeInTheDocument();
});
it('renders no people message when sharePeople is empty', () => {
render(<ShareDialog {...defaultProps} sharePeople={[]} />);
expect(screen.getByText(/share\.noPeople/i)).toBeInTheDocument();
});
it('renders person selection dropdown', () => {
render(<ShareDialog {...defaultProps} />);
expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Bob' })).toBeInTheDocument();
});
it('renders period selection dropdown', () => {
render(<ShareDialog {...defaultProps} />);
// The dropdown renders with 3 options for time periods
const options = screen.getAllByRole('option');
expect(options.length).toBeGreaterThanOrEqual(3);
});
it('calls onClose when close button is clicked', () => {
render(<ShareDialog {...defaultProps} />);
fireEvent.click(screen.getByText('×'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when overlay is clicked', () => {
const { container } = render(<ShareDialog {...defaultProps} />);
const overlay = container.querySelector('.modal-overlay');
fireEvent.click(overlay!);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('shows generated link', () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('http://example.com/share/abc123');
});
it('calls onCopyShareLink when copy button is clicked', () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
fireEvent.click(screen.getByText('📋'));
expect(defaultProps.onCopyShareLink).toHaveBeenCalled();
});
it('shows copied indicator after copy', () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" shareCopied={true} />);
expect(screen.getByText('✓')).toBeInTheDocument();
});
it('selects link text when input is clicked', () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
const selectMock = vi.fn();
input.select = selectMock;
fireEvent.click(input);
expect(selectMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { SharedSchedule } from '../../components/SharedSchedule';
describe('SharedSchedule', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('shows loading state initially', () => {
render(
<MemoryRouter initialEntries={['/share/test-token']}>
<Routes>
<Route path="/share/:token" element={<SharedSchedule />} />
</Routes>
</MemoryRouter>
);
// Should show loading state - actual translation key is common.loading
expect(screen.getByText(/common\.loading/i)).toBeInTheDocument();
});
it('renders app title during loading', () => {
render(
<MemoryRouter initialEntries={['/share/test-token']}>
<Routes>
<Route path="/share/:token" element={<SharedSchedule />} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
});
it('renders shared schedule page container', () => {
render(
<MemoryRouter initialEntries={['/share/test-token']}>
<Routes>
<Route path="/share/:token" element={<SharedSchedule />} />
</Routes>
</MemoryRouter>
);
const container = document.querySelector('.shared-schedule-page');
expect(container).toBeInTheDocument();
});
it('renders loading state container', () => {
render(
<MemoryRouter initialEntries={['/share/test-token']}>
<Routes>
<Route path="/share/:token" element={<SharedSchedule />} />
</Routes>
</MemoryRouter>
);
const loading = document.querySelector('.shared-schedule-loading');
expect(loading).toBeInTheDocument();
});
it('has correct initial theme', () => {
render(
<MemoryRouter initialEntries={['/share/test-token']}>
<Routes>
<Route path="/share/:token" element={<SharedSchedule />} />
</Routes>
</MemoryRouter>
);
// Default theme should be dark
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TagInput } from '../../components/TagInput';
describe('TagInput', () => {
const defaultProps = {
tags: [] as string[],
inputValue: '',
onInputChange: vi.fn(),
onAddTag: vi.fn(),
onRemoveTag: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders input element', () => {
render(<TagInput {...defaultProps} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('renders existing tags', () => {
render(<TagInput {...defaultProps} tags={['Tag1', 'Tag2']} />);
expect(screen.getByText('Tag1')).toBeInTheDocument();
expect(screen.getByText('Tag2')).toBeInTheDocument();
});
it('calls onInputChange when typing', () => {
render(<TagInput {...defaultProps} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'new tag' } });
expect(defaultProps.onInputChange).toHaveBeenCalledWith('new tag');
});
it('calls onAddTag when Enter is pressed with value', () => {
render(<TagInput {...defaultProps} inputValue="new tag" />);
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: 'Enter' });
expect(defaultProps.onAddTag).toHaveBeenCalledWith('new tag');
});
it('calls onAddTag when comma is pressed with value', () => {
render(<TagInput {...defaultProps} inputValue="new tag" />);
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: ',' });
expect(defaultProps.onAddTag).toHaveBeenCalledWith('new tag');
});
it('does not call onAddTag when Enter pressed with empty value', () => {
render(<TagInput {...defaultProps} inputValue="" />);
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: 'Enter' });
expect(defaultProps.onAddTag).not.toHaveBeenCalled();
});
it('calls onRemoveTag when Backspace is pressed with empty input', () => {
render(<TagInput {...defaultProps} tags={['Tag1', 'Tag2']} inputValue="" />);
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: 'Backspace' });
expect(defaultProps.onRemoveTag).toHaveBeenCalledWith('Tag2');
});
it('does not call onRemoveTag when Backspace pressed with value', () => {
render(<TagInput {...defaultProps} tags={['Tag1']} inputValue="text" />);
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: 'Backspace' });
expect(defaultProps.onRemoveTag).not.toHaveBeenCalled();
});
it('calls onRemoveTag when tag remove button is clicked', () => {
render(<TagInput {...defaultProps} tags={['Tag1', 'Tag2']} />);
const removeButtons = screen.getAllByText('×');
fireEvent.click(removeButtons[0]);
expect(defaultProps.onRemoveTag).toHaveBeenCalledWith('Tag1');
});
it('calls onAddTag on blur when there is a value', () => {
render(<TagInput {...defaultProps} inputValue="pending tag" />);
const input = screen.getByRole('combobox');
fireEvent.blur(input);
expect(defaultProps.onAddTag).toHaveBeenCalledWith('pending tag');
});
it('shows placeholder when no tags', () => {
render(<TagInput {...defaultProps} placeholder="Enter tags" />);
expect(screen.getByPlaceholderText('Enter tags')).toBeInTheDocument();
});
it('shows addPlaceholder when tags exist', () => {
render(
<TagInput
{...defaultProps}
tags={['Tag1']}
placeholder="Enter tags"
addPlaceholder="Add more"
/>
);
expect(screen.getByPlaceholderText('Add more')).toBeInTheDocument();
});
it('applies maxLength attribute', () => {
render(<TagInput {...defaultProps} maxLength={50} />);
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute('maxLength', '50');
});
it('shows error message when provided', () => {
render(<TagInput {...defaultProps} error="This field is required" />);
expect(screen.getByText('This field is required')).toBeInTheDocument();
});
it('renders datalist for suggestions', () => {
const { container } = render(
<TagInput
{...defaultProps}
suggestions={['Option1', 'Option2']}
datalistId="test-datalist"
/>
);
const datalist = container.querySelector('#test-datalist');
expect(datalist).toBeInTheDocument();
expect(datalist?.querySelectorAll('option').length).toBe(2);
});
it('excludes already selected tags from suggestions', () => {
const { container } = render(
<TagInput
{...defaultProps}
tags={['Option1']}
suggestions={['Option1', 'Option2', 'Option3']}
datalistId="test-datalist"
/>
);
const datalist = container.querySelector('#test-datalist');
expect(datalist?.querySelectorAll('option').length).toBe(2);
});
});
@@ -0,0 +1,281 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { UserFilterModal } from '../../components/UserFilterModal';
import type { Medication, Coverage, StockThresholds } from '../../types';
const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90
};
const mockMedication: Medication = {
id: 1,
name: 'Test Med',
genericName: 'Generic Name',
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ['John'],
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00' }],
updatedAt: null
};
const mockCoverage: Coverage = {
name: 'Test Med',
medsLeft: 25,
daysLeft: 25,
depletionDate: null,
depletionTime: null,
nextDose: null
};
describe('UserFilterModal', () => {
it('renders nothing when selectedUser is null', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser={null}
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.queryByText(/modal\.userMedications/i)).not.toBeInTheDocument();
});
it('renders modal when selectedUser is provided', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText(/modal\.userMedications/i)).toBeInTheDocument();
});
it('displays user avatar', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
// Avatar should show first letter
expect(screen.getByText('J')).toBeInTheDocument();
});
it('displays medications for selected user', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText('Test Med')).toBeInTheDocument();
});
it('displays generic name when available', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText('Generic Name')).toBeInTheDocument();
});
it('shows empty message when user has no medications', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="Jane"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText(/modal\.noMedsForUser/i)).toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
const closeBtn = screen.getByText('×');
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when overlay clicked', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
const overlay = document.querySelector('.modal-overlay');
if (overlay) {
fireEvent.click(overlay);
}
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose and onOpenMedDetail when medication clicked', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
const medItem = document.querySelector('.user-med-item');
if (medItem) {
fireEvent.click(medItem);
}
expect(onClose).toHaveBeenCalledTimes(1);
expect(onOpenMedDetail).toHaveBeenCalledWith(mockMedication);
});
it('calls onClose when footer close button clicked', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
const footerCloseBtn = screen.getByText(/common\.close/i);
fireEvent.click(footerCloseBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not call onClose when modal content clicked', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
render(
<UserFilterModal
selectedUser="John"
meds={[mockMedication]}
coverage={{ all: [mockCoverage] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
const content = document.querySelector('.modal-content');
if (content) {
fireEvent.click(content);
}
expect(onClose).not.toHaveBeenCalled();
});
it('filters medications by takenBy correctly', () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
const meds: Medication[] = [
{ ...mockMedication, id: 1, name: 'Med1', takenBy: ['John'] },
{ ...mockMedication, id: 2, name: 'Med2', takenBy: ['Jane'] },
{ ...mockMedication, id: 3, name: 'Med3', takenBy: ['John', 'Jane'] }
];
render(
<UserFilterModal
selectedUser="John"
meds={meds}
coverage={{ all: [] }}
settings={defaultSettings}
onClose={onClose}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText('Med1')).toBeInTheDocument();
expect(screen.queryByText('Med2')).not.toBeInTheDocument();
expect(screen.getByText('Med3')).toBeInTheDocument();
});
});