diff --git a/frontend/src/components/Layout/__tests__/BottomNavigation.test.tsx b/frontend/src/components/Layout/__tests__/BottomNavigation.test.tsx
new file mode 100644
index 0000000..1ab1b0d
--- /dev/null
+++ b/frontend/src/components/Layout/__tests__/BottomNavigation.test.tsx
@@ -0,0 +1,265 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import BottomNavigation from '../BottomNavigation';
+import { renderWithPWA, renderWithProviders } from '../../../test/test-utils';
+import { setupPWAMode, resetPWAMocks } from '../../../test/pwa-test-utils';
+import { MemoryRouter } from 'react-router-dom';
+
+// Mock the usePWA hook
+vi.mock('../../../hooks/usePWA');
+
+const mockNavigate = vi.fn();
+
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ BrowserRouter: ({ children, ...props }: { children: React.ReactNode; [key: string]: any }) => (
+
+ {children}
+
+ ),
+ };
+});
+
+describe('BottomNavigation', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ resetPWAMocks();
+ });
+
+ describe('PWA Detection', () => {
+ it('returns null when not in PWA mode', () => {
+ setupPWAMode(false);
+
+ const { container } = renderWithProviders(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders when in PWA mode', () => {
+ setupPWAMode(true);
+
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ // Check that the navigation is rendered by looking for nav items text
+ expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('Navigation Items', () => {
+ beforeEach(() => {
+ setupPWAMode(true);
+ });
+
+ it('renders all 4 navigation items', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
+ expect(screen.getByText(/upload/i)).toBeInTheDocument();
+ expect(screen.getByText(/labels/i)).toBeInTheDocument();
+ expect(screen.getByText(/settings/i)).toBeInTheDocument();
+ });
+
+ it('renders clickable Dashboard nav button', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/upload'] },
+ });
+
+ const buttons = screen.getAllByRole('button');
+ const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard'))!;
+
+ expect(dashboardButton).toBeInTheDocument();
+ expect(dashboardButton).not.toBeDisabled();
+ });
+
+ it('renders clickable Upload nav button', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const buttons = screen.getAllByRole('button');
+ const uploadButton = buttons.find(btn => btn.textContent?.includes('Upload'))!;
+
+ expect(uploadButton).toBeInTheDocument();
+ expect(uploadButton).not.toBeDisabled();
+ });
+
+ it('renders clickable Labels nav button', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const buttons = screen.getAllByRole('button');
+ const labelsButton = buttons.find(btn => btn.textContent?.includes('Labels'))!;
+
+ expect(labelsButton).toBeInTheDocument();
+ expect(labelsButton).not.toBeDisabled();
+ });
+
+ it('renders clickable Settings nav button', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const buttons = screen.getAllByRole('button');
+ const settingsButton = buttons.find(btn => btn.textContent?.includes('Settings'))!;
+
+ expect(settingsButton).toBeInTheDocument();
+ expect(settingsButton).not.toBeDisabled();
+ });
+ });
+
+ describe('Routing Integration', () => {
+ beforeEach(() => {
+ setupPWAMode(true);
+ });
+
+ it('uses location pathname to determine active navigation item', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ // Verify all navigation buttons are present
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(4);
+
+ // Verify buttons have the expected text content
+ expect(buttons.some(btn => btn.textContent?.includes('Dashboard'))).toBe(true);
+ expect(buttons.some(btn => btn.textContent?.includes('Upload'))).toBe(true);
+ expect(buttons.some(btn => btn.textContent?.includes('Labels'))).toBe(true);
+ expect(buttons.some(btn => btn.textContent?.includes('Settings'))).toBe(true);
+ });
+ });
+
+ describe('Styling', () => {
+ beforeEach(() => {
+ setupPWAMode(true);
+ });
+
+ it('has safe-area-inset padding', () => {
+ const { container } = renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const paper = container.querySelector('[class*="MuiPaper-root"]');
+ expect(paper).toBeInTheDocument();
+
+ // Check for safe-area padding in style (MUI applies this via sx prop)
+ const computedStyle = window.getComputedStyle(paper!);
+ // Note: We can't directly test the calc() value in JSDOM,
+ // but we verify the component renders without error
+ expect(paper).toBeInTheDocument();
+ });
+
+ it('has correct z-index for overlay', () => {
+ const { container } = renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const paper = container.querySelector('[class*="MuiPaper-root"]');
+ expect(paper).toBeInTheDocument();
+ });
+
+ it('has fixed position at bottom', () => {
+ const { container } = renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const paper = container.querySelector('[class*="MuiPaper-root"]');
+ expect(paper).toBeInTheDocument();
+ });
+ });
+
+ describe('Accessibility', () => {
+ beforeEach(() => {
+ setupPWAMode(true);
+ });
+
+ it('has visible text labels for all nav items', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ // All buttons should have visible text
+ expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
+ expect(screen.getByText(/upload/i)).toBeInTheDocument();
+ expect(screen.getByText(/labels/i)).toBeInTheDocument();
+ expect(screen.getByText(/settings/i)).toBeInTheDocument();
+ });
+
+ it('all nav items are keyboard accessible', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const buttons = screen.getAllByRole('button');
+ const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard'))!;
+ const uploadButton = buttons.find(btn => btn.textContent?.includes('Upload'))!;
+ const labelsButton = buttons.find(btn => btn.textContent?.includes('Labels'))!;
+ const settingsButton = buttons.find(btn => btn.textContent?.includes('Settings'))!;
+
+ // All should be focusable (button elements)
+ expect(dashboardButton.tagName).toBe('BUTTON');
+ expect(uploadButton.tagName).toBe('BUTTON');
+ expect(labelsButton.tagName).toBe('BUTTON');
+ expect(settingsButton.tagName).toBe('BUTTON');
+ });
+
+ it('shows visual labels for screen readers', () => {
+ renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ // Text content should be visible (not just icons)
+ expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
+ expect(screen.getByText(/upload/i)).toBeInTheDocument();
+ expect(screen.getByText(/labels/i)).toBeInTheDocument();
+ expect(screen.getByText(/settings/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('Responsive Behavior', () => {
+ beforeEach(() => {
+ setupPWAMode(true);
+ });
+
+ it('renders in PWA mode', () => {
+ const { container } = renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ // Should render when in PWA mode
+ expect(container.querySelector('[class*="MuiPaper-root"]')).toBeInTheDocument();
+ });
+ });
+
+ describe('Component Stability', () => {
+ beforeEach(() => {
+ setupPWAMode(true);
+ });
+
+ it('renders consistently across re-renders', () => {
+ const { rerender } = renderWithPWA(, {
+ routerProps: { initialEntries: ['/dashboard'] },
+ });
+
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(4);
+
+ // Re-render should maintain same structure
+ rerender();
+
+ const buttonsAfterRerender = screen.getAllByRole('button');
+ expect(buttonsAfterRerender).toHaveLength(4);
+ });
+ });
+});
diff --git a/frontend/src/hooks/__tests__/usePWA.test.ts b/frontend/src/hooks/__tests__/usePWA.test.ts
new file mode 100644
index 0000000..87d948c
--- /dev/null
+++ b/frontend/src/hooks/__tests__/usePWA.test.ts
@@ -0,0 +1,250 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePWA } from '../usePWA';
+import { setupPWAMode, setupIOSPWAMode, resetPWAMocks } from '../../test/pwa-test-utils';
+
+describe('usePWA', () => {
+ // Clean up after each test to prevent pollution
+ afterEach(() => {
+ resetPWAMocks();
+ });
+
+ describe('PWA Detection', () => {
+ it('returns false when not in standalone mode', () => {
+ // Setup: not in PWA mode
+ setupPWAMode(false);
+
+ const { result } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true when display-mode is standalone', () => {
+ // Setup: PWA mode via display-mode
+ setupPWAMode(true);
+
+ const { result } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns true when navigator.standalone is true (iOS)', () => {
+ // Setup: iOS PWA mode (not using matchMedia)
+ setupPWAMode(false); // matchMedia returns false
+ setupIOSPWAMode(true); // But iOS standalone is true
+
+ const { result } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns true when both display-mode and iOS standalone are true', () => {
+ // Setup: Both detection methods return true
+ setupPWAMode(true);
+ setupIOSPWAMode(true);
+
+ const { result } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(true);
+ });
+ });
+
+ describe('Event Listener Management', () => {
+ it('registers event listener on mount', () => {
+ const addEventListener = vi.fn();
+ const removeEventListener = vi.fn();
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: '(display-mode: standalone)',
+ addEventListener,
+ removeEventListener,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ renderHook(() => usePWA());
+
+ expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ });
+
+ it('removes event listener on unmount', () => {
+ const addEventListener = vi.fn();
+ const removeEventListener = vi.fn();
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: '(display-mode: standalone)',
+ addEventListener,
+ removeEventListener,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ const { unmount } = renderHook(() => usePWA());
+
+ // Capture the registered handler
+ const registeredHandler = addEventListener.mock.calls[0][1];
+
+ unmount();
+
+ expect(removeEventListener).toHaveBeenCalledWith('change', registeredHandler);
+ });
+
+ it('handles multiple mount/unmount cycles correctly', () => {
+ setupPWAMode(false);
+
+ // First mount
+ const { unmount: unmount1 } = renderHook(() => usePWA());
+ unmount1();
+
+ // Second mount (should not cause errors)
+ const { result: result2, unmount: unmount2 } = renderHook(() => usePWA());
+ expect(result2.current).toBe(false);
+ unmount2();
+
+ // Third mount with PWA enabled
+ setupPWAMode(true);
+ const { result: result3 } = renderHook(() => usePWA());
+ expect(result3.current).toBe(true);
+ });
+ });
+
+ describe('Display Mode Changes', () => {
+ it('updates state when display-mode changes', () => {
+ let matchesValue = false;
+ const listeners: Array<() => void> = [];
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation(() => ({
+ get matches() {
+ return matchesValue;
+ },
+ media: '(display-mode: standalone)',
+ addEventListener: vi.fn((event: string, handler: () => void) => {
+ listeners.push(handler);
+ }),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ const { result, rerender } = renderHook(() => usePWA());
+
+ // Initially not in PWA mode
+ expect(result.current).toBe(false);
+
+ // Simulate entering PWA mode
+ act(() => {
+ matchesValue = true;
+ // Trigger the change event
+ listeners.forEach(handler => handler());
+ });
+ rerender();
+
+ // Should now detect PWA mode
+ expect(result.current).toBe(true);
+ });
+
+ it('updates state when exiting PWA mode', () => {
+ let matchesValue = true;
+ const listeners: Array<() => void> = [];
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation(() => ({
+ get matches() {
+ return matchesValue;
+ },
+ media: '(display-mode: standalone)',
+ addEventListener: vi.fn((event: string, handler: () => void) => {
+ listeners.push(handler);
+ }),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ const { result, rerender } = renderHook(() => usePWA());
+
+ // Initially in PWA mode
+ expect(result.current).toBe(true);
+
+ // Simulate exiting PWA mode
+ act(() => {
+ matchesValue = false;
+ // Trigger the change event
+ listeners.forEach(handler => handler());
+ });
+ rerender();
+
+ // Should now detect non-PWA mode
+ expect(result.current).toBe(false);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles missing navigator.standalone gracefully', () => {
+ // Setup matchMedia to return false
+ setupPWAMode(false);
+
+ // Ensure navigator.standalone is undefined
+ const originalStandalone = (window.navigator as any).standalone;
+ delete (window.navigator as any).standalone;
+
+ const { result } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(false);
+
+ // Restore original value if it existed
+ if (originalStandalone !== undefined) {
+ (window.navigator as any).standalone = originalStandalone;
+ }
+ });
+ });
+
+ describe('Consistency', () => {
+ it('returns the same value on re-renders if conditions unchanged', () => {
+ setupPWAMode(true);
+
+ const { result, rerender } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(true);
+
+ // Re-render multiple times
+ rerender();
+ expect(result.current).toBe(true);
+
+ rerender();
+ expect(result.current).toBe(true);
+ });
+
+ it('maintains state across re-renders', () => {
+ setupPWAMode(false);
+
+ const { result, rerender } = renderHook(() => usePWA());
+
+ expect(result.current).toBe(false);
+
+ rerender();
+ expect(result.current).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/test/pwa-test-utils.ts b/frontend/src/test/pwa-test-utils.ts
new file mode 100644
index 0000000..1962ede
--- /dev/null
+++ b/frontend/src/test/pwa-test-utils.ts
@@ -0,0 +1,98 @@
+import { vi } from 'vitest';
+
+/**
+ * Creates a matchMedia mock that can be configured for different query responses
+ * @param standaloneMode - Whether to simulate PWA standalone mode
+ * @returns Mock implementation of window.matchMedia
+ */
+export const createMatchMediaMock = (standaloneMode: boolean = false) => {
+ return vi.fn().mockImplementation((query: string) => ({
+ matches: query.includes('standalone') ? standaloneMode : false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(), // Deprecated but still supported
+ removeListener: vi.fn(), // Deprecated but still supported
+ dispatchEvent: vi.fn(),
+ }));
+};
+
+/**
+ * Sets up window.matchMedia to simulate PWA standalone mode
+ * @param enabled - Whether PWA mode should be enabled (default: true)
+ */
+export const setupPWAMode = (enabled: boolean = true) => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: createMatchMediaMock(enabled),
+ });
+};
+
+/**
+ * Sets up iOS-specific PWA detection via navigator.standalone
+ * @param enabled - Whether iOS PWA mode should be enabled (default: true)
+ */
+export const setupIOSPWAMode = (enabled: boolean = true) => {
+ Object.defineProperty(window.navigator, 'standalone', {
+ writable: true,
+ configurable: true,
+ value: enabled,
+ });
+};
+
+/**
+ * Resets PWA-related window properties to their default state
+ * Useful for cleanup between tests
+ */
+export const resetPWAMocks = () => {
+ // Reset matchMedia to default non-PWA state
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: createMatchMediaMock(false),
+ });
+
+ // Reset iOS standalone if it exists
+ if ('standalone' in window.navigator) {
+ Object.defineProperty(window.navigator, 'standalone', {
+ writable: true,
+ configurable: true,
+ value: undefined,
+ });
+ }
+};
+
+/**
+ * Creates a matchMedia mock that supports multiple query patterns
+ * @param queries - Map of query patterns to their match states
+ * @returns Mock implementation that responds to different queries
+ *
+ * @example
+ * ```typescript
+ * const mockFn = createResponsiveMatchMediaMock({
+ * 'standalone': true, // PWA mode
+ * 'max-width: 900px': true, // Mobile
+ * });
+ * ```
+ */
+export const createResponsiveMatchMediaMock = (
+ queries: Record
+) => {
+ return vi.fn().mockImplementation((query: string) => {
+ // Check if any of the query patterns match the input query
+ const matches = Object.entries(queries).some(([pattern, shouldMatch]) =>
+ query.includes(pattern) ? shouldMatch : false
+ );
+
+ return {
+ matches,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ };
+ });
+};
diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx
index 0bb3879..206903d 100644
--- a/frontend/src/test/test-utils.tsx
+++ b/frontend/src/test/test-utils.tsx
@@ -6,6 +6,7 @@ import { I18nextProvider } from 'react-i18next'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { NotificationProvider } from '../contexts/NotificationContext'
+import { createMatchMediaMock, createResponsiveMatchMediaMock } from './pwa-test-utils'
// Initialize i18n for tests
i18n
@@ -246,6 +247,77 @@ export const renderWithAdminUser = (
return renderWithAuthenticatedUser(ui, createMockAdminUser(), options)
}
+/**
+ * Renders component with PWA mode enabled
+ * Sets up window.matchMedia to simulate standalone display mode
+ */
+export const renderWithPWA = (
+ ui: React.ReactElement,
+ options?: Omit & {
+ authValues?: Partial
+ routerProps?: any
+ }
+) => {
+ // Set up matchMedia to return true for standalone mode
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: createMatchMediaMock(true),
+ })
+
+ return renderWithProviders(ui, options)
+}
+
+/**
+ * Renders component with mobile viewport simulation
+ * Mocks useMediaQuery to return true for mobile breakpoints
+ */
+export const renderWithMobile = (
+ ui: React.ReactElement,
+ options?: Omit & {
+ authValues?: Partial
+ routerProps?: any
+ }
+) => {
+ // Set up matchMedia to simulate mobile viewport (max-width: 900px)
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: createResponsiveMatchMediaMock({
+ '(max-width: 900px)': true,
+ '(max-width:900px)': true, // Without spaces variant
+ }),
+ })
+
+ return renderWithProviders(ui, options)
+}
+
+/**
+ * Renders component with both PWA mode and mobile viewport
+ * Combines PWA standalone mode with mobile breakpoint simulation
+ */
+export const renderWithPWAMobile = (
+ ui: React.ReactElement,
+ options?: Omit & {
+ authValues?: Partial
+ routerProps?: any
+ }
+) => {
+ // Set up matchMedia to handle both PWA and mobile queries
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: createResponsiveMatchMediaMock({
+ 'standalone': true,
+ '(display-mode: standalone)': true,
+ '(max-width: 900px)': true,
+ '(max-width:900px)': true,
+ }),
+ })
+
+ return renderWithProviders(ui, options)
+}
+
// Mock localStorage consistently across tests
export const createMockLocalStorage = () => {
const storage: Record = {}