diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 8e90c0a43..a0968625e 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -1,6 +1,15 @@ import '@testing-library/jest-dom'; import { TextEncoder, TextDecoder } from 'util'; +// Suppress console.log during tests +const originalConsoleLog = console.log; +beforeAll(() => { + console.log = jest.fn(); +}); +afterAll(() => { + console.log = originalConsoleLog; +}); + if (typeof global.TextEncoder === 'undefined') { global.TextEncoder = TextEncoder; } @@ -16,3 +25,95 @@ class ResizeObserver { } (global as any).ResizeObserver = ResizeObserver; + +// --- Tauri Mocks --- + +// Mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +// Mock Tauri Internals +(window as any).__TAURI_INTERNALS__ = { + invoke: jest.fn().mockResolvedValue(null), + transformCallback: jest.fn(), + metadata: {}, +}; + +// Mock the module imports +jest.mock('@tauri-apps/api/core', () => ({ + invoke: jest.fn().mockResolvedValue(null), +})); + +jest.mock('@tauri-apps/api/app', () => ({ + getVersion: jest.fn().mockResolvedValue('1.0.0'), + getName: jest.fn().mockResolvedValue('PictoPy'), + getTauriVersion: jest.fn().mockResolvedValue('2.0.0'), +})); + +jest.mock('@tauri-apps/plugin-updater', () => ({ + check: jest.fn().mockResolvedValue(null), +})); + +jest.mock('@tauri-apps/plugin-dialog', () => ({ + save: jest.fn().mockResolvedValue(null), + open: jest.fn().mockResolvedValue(null), + ask: jest.fn().mockResolvedValue(false), +})); + +jest.mock('@tauri-apps/plugin-fs', () => ({ + readDir: jest.fn().mockResolvedValue([]), + createDir: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@tauri-apps/plugin-shell', () => ({ + open: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@tauri-apps/plugin-store', () => ({ + Store: jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockResolvedValue(undefined), + load: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(true), + has: jest.fn().mockResolvedValue(false), + clear: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockResolvedValue([]), + values: jest.fn().mockResolvedValue([]), + entries: jest.fn().mockResolvedValue([]), + length: jest.fn().mockResolvedValue(0), + onKeyChange: jest.fn().mockResolvedValue(() => {}), // Returns unlisten function + onChange: jest.fn().mockResolvedValue(() => {}), // Returns unlisten function + })), +})); + +// Mock Axios +jest.mock('axios', () => { + const mockAxiosInstance = { + get: jest.fn().mockResolvedValue({ data: [] }), + post: jest.fn().mockResolvedValue({ data: {} }), + put: jest.fn().mockResolvedValue({ data: {} }), + patch: jest.fn().mockResolvedValue({ data: {} }), + delete: jest.fn().mockResolvedValue({ data: {} }), + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { + default: mockAxiosInstance, + create: jest.fn(() => mockAxiosInstance), + ...mockAxiosInstance, + }; +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab218ecaf..f35425737 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -73,6 +73,7 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", + "baseline-browser-mapping": "^2.9.19", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-config-react-app": "^7.0.1", @@ -6588,9 +6589,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", - "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/frontend/package.json b/frontend/package.json index 0a53f1b8d..660ef7b5e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,6 +88,7 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", + "baseline-browser-mapping": "^2.9.19", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-config-react-app": "^7.0.1", diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 7252274a6..e22a62edf 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -1,4 +1,4 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { configureStore, combineReducers } from '@reduxjs/toolkit'; import loaderReducer from '@/features/loaderSlice'; import onboardingReducer from '@/features/onboardingSlice'; import searchReducer from '@/features/searchSlice'; @@ -7,18 +7,20 @@ import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; import folderReducer from '@/features/folderSlice'; +export const rootReducer = combineReducers({ + loader: loaderReducer, + onboarding: onboardingReducer, + images: imageReducer, + faceClusters: faceClustersReducer, + infoDialog: infoDialogReducer, + folders: folderReducer, + search: searchReducer, +}); + export const store = configureStore({ - reducer: { - loader: loaderReducer, - onboarding: onboardingReducer, - images: imageReducer, - faceClusters: faceClustersReducer, - infoDialog: infoDialogReducer, - folders: folderReducer, - search: searchReducer, - }, + reducer: rootReducer, }); // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType; -// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} +// Inferred type: {loader: LoaderState, onboarding: OnboardingState, images: ImageState, ...} export type AppDispatch = typeof store.dispatch; diff --git a/frontend/src/components/__tests__/DeleteImageDialog.test.tsx b/frontend/src/components/__tests__/DeleteImageDialog.test.tsx new file mode 100644 index 000000000..b5ff7aca8 --- /dev/null +++ b/frontend/src/components/__tests__/DeleteImageDialog.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; +import DeleteImagesDialog from '../FolderPicker/DeleteImageDialog'; + +describe('DeleteImagesDialog', () => { + const mockSetIsOpen = jest.fn(); + const mockExecuteDeleteImages = jest.fn(); + + const defaultProps = { + isOpen: true, + setIsOpen: mockSetIsOpen, + executeDeleteImages: mockExecuteDeleteImages, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders confirmation text when open', () => { + render(); + + expect( + screen.getByText( + 'Do you also want to delete these images from Device ?', + ), + ).toBeInTheDocument(); + }); + + test('does not render content when closed', () => { + render(); + + expect( + screen.queryByText( + 'Do you also want to delete these images from Device ?', + ), + ).not.toBeInTheDocument(); + }); + + test('renders Yes and No buttons', () => { + render(); + + expect(screen.getByRole('button', { name: /yes/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /no/i })).toBeInTheDocument(); + }); + }); + + describe('Interactions', () => { + test('clicking Yes calls executeDeleteImages(true) and closes dialog', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /yes/i })); + + expect(mockExecuteDeleteImages).toHaveBeenCalledWith(true); + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + }); + + test('clicking No calls executeDeleteImages(false) and closes dialog', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /no/i })); + + expect(mockExecuteDeleteImages).toHaveBeenCalledWith(false); + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/frontend/src/components/__tests__/EmptyStates.test.tsx b/frontend/src/components/__tests__/EmptyStates.test.tsx new file mode 100644 index 000000000..c1254838a --- /dev/null +++ b/frontend/src/components/__tests__/EmptyStates.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react'; +import { EmptyGalleryState } from '../EmptyStates/EmptyGalleryState'; +import { EmptyAITaggingState } from '../EmptyStates/EmptyAITaggingState'; + +describe('EmptyGalleryState', () => { + test('renders heading', () => { + render(); + + expect( + screen.getByRole('heading', { name: /no images to display/i }), + ).toBeInTheDocument(); + }); + + test('renders gallery instructions', () => { + render(); + + expect( + screen.getByText(/go to settings to add folders/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/supports png, jpg, jpeg image formats/i), + ).toBeInTheDocument(); + }); +}); + +describe('EmptyAITaggingState', () => { + test('renders heading', () => { + render(); + + expect( + screen.getByRole('heading', { name: /no ai tagged images/i }), + ).toBeInTheDocument(); + }); + + test('renders AI tagging instructions', () => { + render(); + + expect( + screen.getByText(/ai will automatically detect objects and people/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/supports png, jpg, jpeg image formats/i), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/GlobalLoader.test.tsx b/frontend/src/components/__tests__/GlobalLoader.test.tsx new file mode 100644 index 000000000..f8f9332ee --- /dev/null +++ b/frontend/src/components/__tests__/GlobalLoader.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { GlobalLoader } from '../Loader/GlobalLoader'; + +describe('GlobalLoader', () => { + test('renders loading message when loading is true', () => { + render(); + + expect(screen.getByText('Loading images...')).toBeInTheDocument(); + }); + + test('renders empty container when loading is false', () => { + const { container } = render( + , + ); + + expect(screen.queryByText('Loading images...')).not.toBeInTheDocument(); + // renders an empty div + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + const loadingMessages = [ + { message: 'Checking for updates...' }, + { message: 'Starting global face reclustering...' }, + { message: 'Adding folder...' }, + ]; + + test.each(loadingMessages)( + 'displays "$message" correctly', + ({ message }) => { + render(); + + expect(screen.getByText(message)).toBeInTheDocument(); + }, + ); +}); diff --git a/frontend/src/components/__tests__/InfoDialog.test.tsx b/frontend/src/components/__tests__/InfoDialog.test.tsx new file mode 100644 index 000000000..8bd7d1b40 --- /dev/null +++ b/frontend/src/components/__tests__/InfoDialog.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; +import { InfoDialog } from '../Dialog/InfoDialog'; + +describe('InfoDialog', () => { + const defaultProps = { + isOpen: true, + title: 'Test Title', + message: 'Test message content', + variant: 'info' as const, + showCloseButton: true, + }; + + describe('Rendering', () => { + test('renders title and message when open', () => { + render(); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test message content')).toBeInTheDocument(); + }); + + test('does not render content when closed', () => { + render(); + + expect(screen.queryByText('Test Title')).not.toBeInTheDocument(); + }); + + const variantCases = [ + { variant: 'info' as const, label: 'info' }, + { variant: 'error' as const, label: 'error' }, + ]; + + test.each(variantCases)( + 'renders with $label variant without crashing', + ({ variant }) => { + render( + , + ); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test message content')).toBeInTheDocument(); + }, + ); + }); + + describe('Interactions', () => { + test('close button is rendered when showCloseButton is true', () => { + render(); + + const closeButtons = screen.getAllByRole('button', { name: /close/i }); + // action button + dialog X button + expect(closeButtons.length).toBeGreaterThanOrEqual(1); + }); + + test('clicking close button dispatches hideInfoDialog', async () => { + const user = userEvent.setup(); + const { store } = render(); + + // Target the explicit "Close" action button (data-slot="button"), + // not the dialog's built-in X close button (data-slot="dialog-close") + const actionButton = screen + .getAllByRole('button', { name: /close/i }) + .find((btn) => btn.getAttribute('data-slot') === 'button')!; + await user.click(actionButton); + + // verify store updated - dialog should be closed + const state = (store.getState() as any).infoDialog; + expect(state.isOpen).toBe(false); + }); + }); +}); diff --git a/frontend/src/components/__tests__/Sidebar.test.tsx b/frontend/src/components/__tests__/Sidebar.test.tsx new file mode 100644 index 000000000..327e036bb --- /dev/null +++ b/frontend/src/components/__tests__/Sidebar.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router'; +import { AppSidebar } from '../Navigation/Sidebar/AppSidebar'; +import { SidebarProvider } from '@/components/ui/sidebar'; +import { ROUTES } from '@/constants/routes'; + +// Display current routes +const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; +}; + +// Sidebar + routes display +const SidebarWithRoutes = () => ( + + +
+ + + Home Page} /> + Settings Page} /> + AI Tagging Page} /> + Favourites Page} /> + Videos Page} /> + Albums Page} /> + Memories Page} /> + +
+
+); + +describe('Sidebar', () => { + describe('Structure Tests', () => { + test('renders all main navigation links', () => { + render( + + + , + ); + + // Verify key navigation items exist + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('AI Tagging')).toBeInTheDocument(); + expect(screen.getByText('Favourites')).toBeInTheDocument(); + expect(screen.getByText('Videos')).toBeInTheDocument(); + expect(screen.getByText('Albums')).toBeInTheDocument(); + expect(screen.getByText('Memories')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + }); + + describe('Navigation Interaction Tests', () => { + const navigationCases = [ + { + linkText: 'Home', + route: ROUTES.HOME, + pageText: 'Home Page', + startRoute: ROUTES.SETTINGS, + }, + { linkText: 'AI Tagging', route: ROUTES.AI, pageText: 'AI Tagging Page' }, + { + linkText: 'Favourites', + route: ROUTES.FAVOURITES, + pageText: 'Favourites Page', + }, + { linkText: 'Videos', route: ROUTES.VIDEOS, pageText: 'Videos Page' }, + { linkText: 'Albums', route: ROUTES.ALBUMS, pageText: 'Albums Page' }, + { + linkText: 'Memories', + route: ROUTES.MEMORIES, + pageText: 'Memories Page', + }, + { + linkText: 'Settings', + route: ROUTES.SETTINGS, + pageText: 'Settings Page', + }, + ]; + + test.each(navigationCases)( + 'clicking $linkText link navigates to /$route', + async ({ linkText, route, pageText, startRoute = ROUTES.HOME }) => { + const user = userEvent.setup(); + render(, { initialRoutes: [`/${startRoute}`] }); + + // verify start location + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${startRoute}`, + ); + + // click nav link + await user.click(screen.getByText(linkText)); + + // verify navigation + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${route}`, + ); + expect(screen.getByText(pageText)).toBeInTheDocument(); + }, + ); + }); +}); diff --git a/frontend/src/components/__tests__/ThemeToggle.test.tsx b/frontend/src/components/__tests__/ThemeToggle.test.tsx new file mode 100644 index 000000000..1874c1a59 --- /dev/null +++ b/frontend/src/components/__tests__/ThemeToggle.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, waitFor } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; +import { ThemeSelector } from '../ThemeToggle'; + +describe('ThemeSelector', () => { + describe('Structure Tests', () => { + test('renders theme toggle button', () => { + render(); + + // button should be accessible + const button = screen.getByRole('button', { name: /themes/i }); + expect(button).toBeInTheDocument(); + }); + }); + + describe('Interaction Tests', () => { + test('clicking toggle button opens theme dropdown', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: /themes/i }); + await user.click(button); // open dropdown + + // verify dropdown options are visible + await screen.findByText('Light'); + expect(screen.getByText('Dark')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + }); + + const themeSelectionCases = [ + { theme: 'Light', expectedClass: 'light', hiddenOption: 'Dark' }, + { theme: 'Dark', expectedClass: 'dark', hiddenOption: 'Light' }, + { theme: 'System', expectedClass: 'light', hiddenOption: 'Dark' }, // system resolves to light (matchMedia mock returns false) + ]; + + test.each(themeSelectionCases)( + 'selecting $theme theme applies class and closes dropdown', + async ({ theme, expectedClass, hiddenOption }) => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: /themes/i }); + await user.click(button); // open dropdown + + const themeOption = screen.getByText(theme); + await user.click(themeOption); // select theme + + // verify theme class is applied to document + await waitFor(() => + expect(document.documentElement).toHaveClass(expectedClass), + ); + + // verify dropdown closed + await waitFor(() => + expect(screen.queryByText(hiddenOption)).not.toBeInTheDocument(), + ); + }, + ); + }); +}); diff --git a/frontend/src/lib/__tests__/utils.test.ts b/frontend/src/lib/__tests__/utils.test.ts new file mode 100644 index 000000000..20e5b4217 --- /dev/null +++ b/frontend/src/lib/__tests__/utils.test.ts @@ -0,0 +1,76 @@ +import { cn, getErrorMessage } from '../utils'; + +// Axios is globally mocked, so we build AxiosError-like objects manually. +function fakeAxiosError( + message: string, + code: string, + responseData?: Record, +) { + return { + isAxiosError: true, + message, + code, + response: responseData ? { data: responseData } : undefined, + }; +} + +/* ------------------------------------------------------------------ */ +/* cn (classname merge) */ +/* ------------------------------------------------------------------ */ + +describe('cn', () => { + test('merges multiple class names', () => { + expect(cn('px-4', 'py-2')).toBe('px-4 py-2'); + }); + + test('resolves Tailwind conflicts (last wins)', () => { + expect(cn('px-4', 'px-8')).toBe('px-8'); + }); + + test('handles conditional classes', () => { + const isActive = true; + expect(cn('base', isActive && 'active')).toBe('base active'); + }); + + test('filters falsy values', () => { + expect(cn('base', false, null, undefined, 'extra')).toBe('base extra'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* getErrorMessage */ +/* ------------------------------------------------------------------ */ + +describe('getErrorMessage', () => { + test('returns default message for null / undefined', () => { + expect(getErrorMessage(null)).toBe('Something went wrong'); + expect(getErrorMessage(undefined)).toBe('Something went wrong'); + }); + + test('extracts message from plain Error', () => { + expect(getErrorMessage(new Error('disk full'))).toBe('disk full'); + }); + + test('extracts response data from AxiosError', () => { + const axiosErr = fakeAxiosError('Request failed', 'ERR_BAD_REQUEST', { + error: 'Not Found', + message: 'Image does not exist', + }); + + expect(getErrorMessage(axiosErr)).toBe( + 'Not Found - Image does not exist', + ); + }); + + test('falls back to code + message when response data is empty', () => { + const axiosErr = fakeAxiosError('Network Error', 'ERR_NETWORK', {}); + + expect(getErrorMessage(axiosErr)).toBe('ERR_NETWORK: Network Error'); + }); + + test('handles AxiosError with no response at all', () => { + const axiosErr = fakeAxiosError('timeout', 'ECONNABORTED'); + + expect(getErrorMessage(axiosErr)).toBe('ECONNABORTED: timeout'); + }); +}); diff --git a/frontend/src/pages/__tests__/PageSanity.test.tsx b/frontend/src/pages/__tests__/PageSanity.test.tsx new file mode 100644 index 000000000..453daa63d --- /dev/null +++ b/frontend/src/pages/__tests__/PageSanity.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@/test-utils'; +import { Home } from '../Home/Home'; +import Settings from '../SettingsPage/Settings'; + +describe('Page Sanity Tests', () => { + describe('Home Page', () => { + test('renders home page structure', async () => { + render(); + expect( + await screen.findByText( + /Image Gallery|No Images to Display|Loading images/i, + ), + ).toBeInTheDocument(); + }); + }); + + describe('Settings Page', () => { + test('renders settings page sections', () => { + render(); + + expect(screen.getByText('Folder Management')).toBeInTheDocument(); + expect(screen.getByText('User Preferences')).toBeInTheDocument(); + expect(screen.getByText('Application Controls')).toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /Check for Updates/i }), + ).toBeInTheDocument(); + expect(screen.getByText('GPU Acceleration')).toBeInTheDocument(); + }); // Settings is expected to render synchronously. + }); +}); diff --git a/frontend/src/pages/__tests__/SettingsPage.test.tsx b/frontend/src/pages/__tests__/SettingsPage.test.tsx new file mode 100644 index 000000000..8f61cc689 --- /dev/null +++ b/frontend/src/pages/__tests__/SettingsPage.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; +import Settings from '../SettingsPage/Settings'; + +describe('Settings Page', () => { + // shared setup for all tests + const setupTest = () => { + const user = userEvent.setup(); + render(); + return { user }; + }; + + describe('Interaction Sanity', () => { + describe('User Preferences Section', () => { + test('YOLO model dropdown opens and shows options', async () => { + const { user } = setupTest(); + + const dropdownTrigger = screen.getByRole('button', { + name: /nano|small|medium/i, + }); + await user.click(dropdownTrigger); + + const menuItems = screen.getAllByRole('menuitem'); + expect(menuItems).toHaveLength(3); + expect(menuItems[0]).toHaveTextContent('Nano'); + expect(menuItems[1]).toHaveTextContent('Small'); + expect(menuItems[2]).toHaveTextContent('Medium'); + }); + + test('GPU Acceleration toggle changes state on click', async () => { + const { user } = setupTest(); + + const gpuSwitch = screen.getByRole('switch'); + expect(gpuSwitch).toHaveAttribute('aria-checked', 'false'); + + await user.click(gpuSwitch); + + expect(gpuSwitch).toHaveAttribute('aria-checked', 'true'); + }); + }); + + describe('Action Buttons', () => { + const buttonCases = [ + { name: /add folders/i, label: 'Add Folders' }, + { name: /check for updates/i, label: 'Check for Updates' }, + { name: /recluster faces/i, label: 'Recluster Faces' }, + ]; + + test.each(buttonCases)( + '$label button does not crash when clicked', + async ({ name }) => { + const { user } = setupTest(); + + const button = screen.getByRole('button', { name }); + + await user.click(button); + + expect(button).toBeEnabled(); + }, + ); + }); + }); + + describe('State-Level Verification', () => { + describe('YOLO Model Selection', () => { + const yoloSelectionCases = [ + { selectOption: 'small', expectedText: 'Small' }, + { selectOption: 'medium', expectedText: 'Medium' }, + ]; + + test.each(yoloSelectionCases)( + 'selecting $expectedText updates dropdown display', + async ({ selectOption, expectedText }) => { + const { user } = setupTest(); + + const dropdownTrigger = screen.getByRole('button', { name: /nano/i }); + expect(dropdownTrigger).toHaveTextContent('Nano'); + + await user.click(dropdownTrigger); + await user.click( + screen.getByRole('menuitem', { + name: new RegExp(selectOption, 'i'), + }), + ); + + expect(dropdownTrigger).toHaveTextContent(expectedText); + }, + ); + + test('dropdown can be reopened after selection', async () => { + const { user } = setupTest(); + + const dropdownTrigger = screen.getByRole('button', { name: /nano/i }); + await user.click(dropdownTrigger); + await user.click(screen.getByRole('menuitem', { name: /small/i })); + + // reopen and verify options still available + await user.click(dropdownTrigger); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + }); + }); + + describe('GPU Acceleration Toggle', () => { + test('toggle cycles through ON/OFF states', async () => { + const { user } = setupTest(); + + const gpuSwitch = screen.getByRole('switch'); + expect(gpuSwitch).toHaveAttribute('aria-checked', 'false'); + + await user.click(gpuSwitch); + expect(gpuSwitch).toHaveAttribute('aria-checked', 'true'); + + await user.click(gpuSwitch); + expect(gpuSwitch).toHaveAttribute('aria-checked', 'false'); + }); + }); + }); + +// eslint-disable-next-line no-warning-comments + /** + * FUTURE: System integrations (future scope) + * Belongs in E2E tests (Playwright/Cypress) rather than Jest + * - Full user flows with mocked/real backend + * - Update preferences API verification + * - Check for updates flow + */ +}); diff --git a/frontend/src/pages/__tests__/allPages.test.tsx b/frontend/src/pages/__tests__/allPages.test.tsx index 2f1e2d241..9de439f6d 100644 --- a/frontend/src/pages/__tests__/allPages.test.tsx +++ b/frontend/src/pages/__tests__/allPages.test.tsx @@ -11,17 +11,6 @@ import { BrowserRouter } from 'react-router'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { Provider } from 'react-redux'; import { store } from '@/app/store'; -beforeAll(() => { - window.matchMedia = - window.matchMedia || - function () { - return { - matches: false, - addListener: () => {}, // deprecated - removeListener: () => {}, // deprecated - }; - }; -}); const pages = [ { path: ROUTES.HOME, Component: Home }, diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx new file mode 100644 index 000000000..981209272 --- /dev/null +++ b/frontend/src/test-utils.tsx @@ -0,0 +1,89 @@ +import React, { ReactElement } from 'react'; +import { render, RenderOptions } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { ThemeProvider } from '@/contexts/ThemeContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import { configureStore } from '@reduxjs/toolkit'; +import { rootReducer, RootState } from '@/app/store'; + +interface CustomRenderOptions extends Omit { + preloadedState?: Partial; + store?: ReturnType; + initialRoutes?: string[]; +} + +const AllTheProviders = ({ + children, + store, + queryClient, + initialRoutes = ['/'], +}: { + children: React.ReactNode; + store: ReturnType; + queryClient: QueryClient; + initialRoutes?: string[]; +}) => { + return ( + + + + {children} + + + + ); +}; + +const customRender = (ui: ReactElement, options?: CustomRenderOptions) => { + const { store, initialRoutes, preloadedState, ...renderOptions } = + options || {}; + + if (store && preloadedState) { + console.warn( + 'test-utils: Both store and preloadedState were provided. preloadedState will be ignored.', + ); + } + + const testStore = + store ?? + configureStore({ + reducer: rootReducer, + preloadedState, + }); + + const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Infinity, + staleTime: Infinity, + }, + mutations: { + retry: false, + }, + }, + }); + + const renderResult = render(ui, { + wrapper: (props) => ( + + ), + ...renderOptions, + }); + + return { + ...renderResult, + store: testStore, + queryClient: testQueryClient, + }; +}; + +// Re-export everything +export * from '@testing-library/react'; +export { customRender as render }; diff --git a/frontend/src/utils/__tests__/dateUtils.test.ts b/frontend/src/utils/__tests__/dateUtils.test.ts new file mode 100644 index 000000000..20f50110b --- /dev/null +++ b/frontend/src/utils/__tests__/dateUtils.test.ts @@ -0,0 +1,110 @@ +import { getTimeAgo, groupImagesByYearMonthFromMetadata } from '../dateUtils'; +import { Image, ImageMetadata } from '@/types/Media'; + +/* ------------------------------------------------------------------ */ +/* getTimeAgo */ +/* ------------------------------------------------------------------ */ + +describe('getTimeAgo', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-15T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('returns "just now" for the current timestamp', () => { + expect(getTimeAgo('2025-01-15T12:00:00Z')).toBe('just now'); + }); + + const timeAgoCases: [string, string, string][] = [ + ['30 seconds ago', '2025-01-15T11:59:30Z', '30 seconds ago'], + ['1 minute ago', '2025-01-15T11:59:00Z', '1 minute ago'], + ['45 minutes ago', '2025-01-15T11:15:00Z', '45 minutes ago'], + ['1 hour ago', '2025-01-15T11:00:00Z', '1 hour ago'], + ['1 day ago', '2025-01-14T12:00:00Z', '1 day ago'], + ['3 days ago', '2025-01-12T12:00:00Z', '3 days ago'], + ['2 weeks ago', '2025-01-01T12:00:00Z', '2 weeks ago'], + ['2 months ago', '2024-11-15T12:00:00Z', '2 months ago'], + ['1 year ago', '2024-01-15T12:00:00Z', '1 year ago'], + ]; + + test.each(timeAgoCases)('%s', (_label, input, expected) => { + expect(getTimeAgo(input)).toBe(expected); + }); +}); + +/* ------------------------------------------------------------------ */ +/* groupImagesByYearMonthFromMetadata */ +/* ------------------------------------------------------------------ */ + +const makeImage = ( + id: string, + dateCreated: string | null, +): Image => ({ + id, + path: `/photos/${id}.jpg`, + thumbnailPath: `/thumbs/${id}.jpg`, + folder_id: 'folder-1', + isTagged: false, + metadata: { + name: `${id}.jpg`, + date_created: dateCreated, + width: 1920, + height: 1080, + file_location: `/photos/${id}.jpg`, + file_size: 1024, + item_type: 'image/jpeg', + } as ImageMetadata, +}); + +describe('groupImagesByYearMonthFromMetadata', () => { + test('returns empty object for empty array', () => { + expect(groupImagesByYearMonthFromMetadata([])).toEqual({}); + }); + + test('groups images by year and month', () => { + const images = [ + makeImage('a', '2024-03-10T10:00:00Z'), + makeImage('b', '2024-03-20T10:00:00Z'), + makeImage('c', '2024-11-05T10:00:00Z'), + makeImage('d', '2023-01-01T10:00:00Z'), + ]; + + const result = groupImagesByYearMonthFromMetadata(images); + + expect(Object.keys(result)).toEqual( + expect.arrayContaining(['2024', '2023']), + ); + expect(result['2024']['03']).toHaveLength(2); + expect(result['2024']['11']).toHaveLength(1); + expect(result['2023']['01']).toHaveLength(1); + }); + + test('skips images with null date_created', () => { + const images = [ + makeImage('a', '2024-06-15T10:00:00Z'), + makeImage('b', null), + ]; + + const result = groupImagesByYearMonthFromMetadata(images); + + expect(result['2024']['06']).toHaveLength(1); + }); + + test('skips images with invalid date strings', () => { + const images = [ + makeImage('a', '2024-06-15T10:00:00Z'), + makeImage('b', 'not-a-date'), + ]; + + const result = groupImagesByYearMonthFromMetadata(images); + + const totalImages = Object.values(result).flatMap((months) => + Object.values(months).flat(), + ); + expect(totalImages).toHaveLength(1); + }); +});