From 1bdf7d0d192a78991418d8eff49ad56e69812365 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:29:52 +0530 Subject: [PATCH 1/6] feat(test): implement frontend sanity tests and update infrastructure --- frontend/jest.setup.ts | 92 +++++++++++++++++++ frontend/src/app/store.ts | 24 ++--- .../Sidebar/__tests__/Sidebar.test.tsx | 22 +++++ .../src/pages/__tests__/PageSanity.test.tsx | 31 +++++++ frontend/src/test-utils.tsx | 69 ++++++++++++++ 5 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx create mode 100644 frontend/src/pages/__tests__/PageSanity.test.tsx create mode 100644 frontend/src/test-utils.tsx diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 8e90c0a43..558bea276 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -16,3 +16,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/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/Navigation/Sidebar/__tests__/Sidebar.test.tsx b/frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx new file mode 100644 index 000000000..01b460543 --- /dev/null +++ b/frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@/test-utils'; +import { AppSidebar } from '../AppSidebar'; +import { SidebarProvider } from '@/components/ui/sidebar'; + +describe('Sidebar Sanity 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(); + }); +}); diff --git a/frontend/src/pages/__tests__/PageSanity.test.tsx b/frontend/src/pages/__tests__/PageSanity.test.tsx new file mode 100644 index 000000000..389e91426 --- /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(); + }); + }); +}); diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx new file mode 100644 index 000000000..6bde8c13d --- /dev/null +++ b/frontend/src/test-utils.tsx @@ -0,0 +1,69 @@ +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, + initialRoutes = ['/'], +}: { + children: React.ReactNode; + store: ReturnType; + initialRoutes?: string[]; +}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ( + + + + {children} + + + + ); +}; + +const customRender = (ui: ReactElement, options?: CustomRenderOptions) => { + const { store, initialRoutes, preloadedState, ...renderOptions } = + options || {}; + + const testStore = + store ?? + configureStore({ + reducer: rootReducer, + preloadedState, + }); + + return render(ui, { + wrapper: (props) => ( + + ), + ...renderOptions, + }); +}; + +// Re-export everything +export * from '@testing-library/react'; +export { customRender as render }; From 793bdba0327d4e0e877240e18c78e7f9ce206386 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:36:38 +0530 Subject: [PATCH 2/6] expand sanity tests with sidebar navigation interactions --- .../Sidebar/__tests__/Sidebar.test.tsx | 22 --- .../src/components/__tests__/Sidebar.test.tsx | 151 ++++++++++++++++++ 2 files changed, 151 insertions(+), 22 deletions(-) delete mode 100644 frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx create mode 100644 frontend/src/components/__tests__/Sidebar.test.tsx diff --git a/frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx b/frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx deleted file mode 100644 index 01b460543..000000000 --- a/frontend/src/components/Navigation/Sidebar/__tests__/Sidebar.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render, screen } from '@/test-utils'; -import { AppSidebar } from '../AppSidebar'; -import { SidebarProvider } from '@/components/ui/sidebar'; - -describe('Sidebar Sanity 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(); - }); -}); diff --git a/frontend/src/components/__tests__/Sidebar.test.tsx b/frontend/src/components/__tests__/Sidebar.test.tsx new file mode 100644 index 000000000..99221ead1 --- /dev/null +++ b/frontend/src/components/__tests__/Sidebar.test.tsx @@ -0,0 +1,151 @@ +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} /> + 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', () => { + test('clicking Settings link navigates to /settings', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/'] }); + + expect(screen.getByTestId('location-display')).toHaveTextContent('/'); // start location + + await user.click(screen.getByText('Settings')); // click settings + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/settings', + ); + expect(screen.getByText('Settings Page')).toBeInTheDocument(); + }); + + test('clicking Home link navigates to /home', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/settings'] }); + + // start location + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/settings', + ); + + await user.click(screen.getByText('Home')); // click home + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent('/home'); + expect(screen.getByText('Home Page')).toBeInTheDocument(); + }); + + test('clicking AI Tagging link navigates to /ai-tagging', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/'] }); + + await user.click(screen.getByText('AI Tagging')); // click ai-tagging + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/ai-tagging', + ); + expect(screen.getByText('AI Tagging Page')).toBeInTheDocument(); + }); + + test('clicking Favourites link navigates to /favourites', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/'] }); + + await user.click(screen.getByText('Favourites')); // click favourites + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/favourites', + ); + expect(screen.getByText('Favourites Page')).toBeInTheDocument(); + }); + + test('clicking Videos link navigates to /videos', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/'] }); + + await user.click(screen.getByText('Videos')); // click videos + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/videos', + ); + expect(screen.getByText('Videos Page')).toBeInTheDocument(); + }); + + test('clicking Albums link navigates to /albums', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/'] }); + + await user.click(screen.getByText('Albums')); // click albums + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/albums', + ); + expect(screen.getByText('Albums Page')).toBeInTheDocument(); + }); + + test('clicking Memories link navigates to /memories', async () => { + const user = userEvent.setup(); + render(, { initialRoutes: ['/'] }); + + await user.click(screen.getByText('Memories')); // click memories + + // verify + expect(screen.getByTestId('location-display')).toHaveTextContent( + '/memories', + ); + expect(screen.getByText('Memories Page')).toBeInTheDocument(); + }); + }); +}); From b6c84d336f87de21ffa0d5ddec2ca6dbb98b95e3 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:51:26 +0530 Subject: [PATCH 3/6] Update: assertions to reference ROUTES constants + minor nitpicks --- frontend/package-lock.json | 7 ++- frontend/package.json | 1 + .../src/components/__tests__/Sidebar.test.tsx | 56 +++++++++++++------ .../src/pages/__tests__/PageSanity.test.tsx | 2 +- frontend/src/test-utils.tsx | 19 ++++--- 5 files changed, 57 insertions(+), 28 deletions(-) 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/components/__tests__/Sidebar.test.tsx b/frontend/src/components/__tests__/Sidebar.test.tsx index 99221ead1..15d608799 100644 --- a/frontend/src/components/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/__tests__/Sidebar.test.tsx @@ -54,96 +54,120 @@ describe('Sidebar', () => { describe('Navigation Interaction Tests', () => { test('clicking Settings link navigates to /settings', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/'] }); + render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - expect(screen.getByTestId('location-display')).toHaveTextContent('/'); // start location + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); // start location await user.click(screen.getByText('Settings')); // click settings // verify expect(screen.getByTestId('location-display')).toHaveTextContent( - '/settings', + `/${ROUTES.SETTINGS}`, ); expect(screen.getByText('Settings Page')).toBeInTheDocument(); }); test('clicking Home link navigates to /home', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/settings'] }); + render(, { initialRoutes: [`/${ROUTES.SETTINGS}`] }); // start location expect(screen.getByTestId('location-display')).toHaveTextContent( - '/settings', + `/${ROUTES.SETTINGS}`, ); await user.click(screen.getByText('Home')); // click home // verify - expect(screen.getByTestId('location-display')).toHaveTextContent('/home'); + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); expect(screen.getByText('Home Page')).toBeInTheDocument(); }); test('clicking AI Tagging link navigates to /ai-tagging', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/'] }); + render(, { initialRoutes: [`/${ROUTES.HOME}`] }); + + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); await user.click(screen.getByText('AI Tagging')); // click ai-tagging // verify expect(screen.getByTestId('location-display')).toHaveTextContent( - '/ai-tagging', + `/${ROUTES.AI}`, ); expect(screen.getByText('AI Tagging Page')).toBeInTheDocument(); }); test('clicking Favourites link navigates to /favourites', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/'] }); + render(, { initialRoutes: [`/${ROUTES.HOME}`] }); + + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); await user.click(screen.getByText('Favourites')); // click favourites // verify expect(screen.getByTestId('location-display')).toHaveTextContent( - '/favourites', + `/${ROUTES.FAVOURITES}`, ); expect(screen.getByText('Favourites Page')).toBeInTheDocument(); }); test('clicking Videos link navigates to /videos', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/'] }); + render(, { initialRoutes: [`/${ROUTES.HOME}`] }); + + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); await user.click(screen.getByText('Videos')); // click videos // verify expect(screen.getByTestId('location-display')).toHaveTextContent( - '/videos', + `/${ROUTES.VIDEOS}`, ); expect(screen.getByText('Videos Page')).toBeInTheDocument(); }); test('clicking Albums link navigates to /albums', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/'] }); + render(, { initialRoutes: [`/${ROUTES.HOME}`] }); + + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); await user.click(screen.getByText('Albums')); // click albums // verify expect(screen.getByTestId('location-display')).toHaveTextContent( - '/albums', + `/${ROUTES.ALBUMS}`, ); expect(screen.getByText('Albums Page')).toBeInTheDocument(); }); test('clicking Memories link navigates to /memories', async () => { const user = userEvent.setup(); - render(, { initialRoutes: ['/'] }); + render(, { initialRoutes: [`/${ROUTES.HOME}`] }); + + expect(screen.getByTestId('location-display')).toHaveTextContent( + `/${ROUTES.HOME}`, + ); await user.click(screen.getByText('Memories')); // click memories // verify expect(screen.getByTestId('location-display')).toHaveTextContent( - '/memories', + `/${ROUTES.MEMORIES}`, ); expect(screen.getByText('Memories Page')).toBeInTheDocument(); }); diff --git a/frontend/src/pages/__tests__/PageSanity.test.tsx b/frontend/src/pages/__tests__/PageSanity.test.tsx index 389e91426..453daa63d 100644 --- a/frontend/src/pages/__tests__/PageSanity.test.tsx +++ b/frontend/src/pages/__tests__/PageSanity.test.tsx @@ -26,6 +26,6 @@ describe('Page Sanity Tests', () => { 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/test-utils.tsx b/frontend/src/test-utils.tsx index 6bde8c13d..ce3396b51 100644 --- a/frontend/src/test-utils.tsx +++ b/frontend/src/test-utils.tsx @@ -16,20 +16,14 @@ interface CustomRenderOptions extends Omit { const AllTheProviders = ({ children, store, + queryClient, initialRoutes = ['/'], }: { children: React.ReactNode; store: ReturnType; + queryClient: QueryClient; initialRoutes?: string[]; }) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - return ( @@ -52,11 +46,20 @@ const customRender = (ui: ReactElement, options?: CustomRenderOptions) => { preloadedState, }); + const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return render(ui, { wrapper: (props) => ( ), From dbecc0ea4ff9e906e437388b2489bd398f6b6808 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:51:06 +0530 Subject: [PATCH 4/6] Add theme toggle (component) test --- .../components/__tests__/ThemeToggle.test.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 frontend/src/components/__tests__/ThemeToggle.test.tsx diff --git a/frontend/src/components/__tests__/ThemeToggle.test.tsx b/frontend/src/components/__tests__/ThemeToggle.test.tsx new file mode 100644 index 000000000..86c22dec7 --- /dev/null +++ b/frontend/src/components/__tests__/ThemeToggle.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } 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 + expect(screen.getByText('Light')).toBeInTheDocument(); + expect(screen.getByText('Dark')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + }); + + test('selecting Dark theme option closes dropdown', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: /themes/i }); + await user.click(button); // open dropdown + + const darkOption = screen.getByText('Dark'); + await user.click(darkOption); // select dark + + // dropdown should close (options no longer visible) + expect(screen.queryByText('Light')).not.toBeInTheDocument(); + }); + + test('selecting Light theme option closes dropdown', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: /themes/i }); + await user.click(button); // open dropdown + + const lightOption = screen.getByText('Light'); + await user.click(lightOption); // select light + + // dropdown should close + expect(screen.queryByText('Dark')).not.toBeInTheDocument(); + }); + }); +}); From 38b1f330eb0447e9e96392583f457ba0dfff1873 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:03:30 +0530 Subject: [PATCH 5/6] Add SettingsPage unit tests + prior test optimizations Consolidates and stabilizes frontend tests: suppress console.log in jest.setup to reduce noise; replace repetitive Sidebar navigation specs with a parameterized test.each suite and fix route usage; refactor ThemeToggle tests to use parameterized cases, findByText/waitFor assertions for more reliability. Adds comprehensive SettingsPage unit tests covering interactions and state changes. Removes a redundant matchMedia mock from allPages tests. Updates test-utils to adjust react-query defaults (disable retries, set gc/stale times) and return the test store and queryClient alongside the render result for easier test introspection. --- frontend/jest.setup.ts | 9 + .../src/components/__tests__/Sidebar.test.tsx | 168 +++++------------- .../components/__tests__/ThemeToggle.test.tsx | 60 ++++--- .../src/pages/__tests__/SettingsPage.test.tsx | 126 +++++++++++++ .../src/pages/__tests__/allPages.test.tsx | 11 -- frontend/src/test-utils.tsx | 13 +- 6 files changed, 226 insertions(+), 161 deletions(-) create mode 100644 frontend/src/pages/__tests__/SettingsPage.test.tsx diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 558bea276..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; } diff --git a/frontend/src/components/__tests__/Sidebar.test.tsx b/frontend/src/components/__tests__/Sidebar.test.tsx index 15d608799..327e036bb 100644 --- a/frontend/src/components/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/__tests__/Sidebar.test.tsx @@ -18,7 +18,6 @@ const SidebarWithRoutes = () => (
- Home Page} /> Home Page} /> Settings Page} /> AI Tagging Page} /> @@ -52,124 +51,53 @@ describe('Sidebar', () => { }); describe('Navigation Interaction Tests', () => { - test('clicking Settings link navigates to /settings', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); // start location - - await user.click(screen.getByText('Settings')); // click settings - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.SETTINGS}`, - ); - expect(screen.getByText('Settings Page')).toBeInTheDocument(); - }); - - test('clicking Home link navigates to /home', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.SETTINGS}`] }); - - // start location - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.SETTINGS}`, - ); - - await user.click(screen.getByText('Home')); // click home - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); - expect(screen.getByText('Home Page')).toBeInTheDocument(); - }); - - test('clicking AI Tagging link navigates to /ai-tagging', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); - - await user.click(screen.getByText('AI Tagging')); // click ai-tagging - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.AI}`, - ); - expect(screen.getByText('AI Tagging Page')).toBeInTheDocument(); - }); - - test('clicking Favourites link navigates to /favourites', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); - - await user.click(screen.getByText('Favourites')); // click favourites - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.FAVOURITES}`, - ); - expect(screen.getByText('Favourites Page')).toBeInTheDocument(); - }); - - test('clicking Videos link navigates to /videos', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); - - await user.click(screen.getByText('Videos')); // click videos - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.VIDEOS}`, - ); - expect(screen.getByText('Videos Page')).toBeInTheDocument(); - }); - - test('clicking Albums link navigates to /albums', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); - - await user.click(screen.getByText('Albums')); // click albums - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.ALBUMS}`, - ); - expect(screen.getByText('Albums Page')).toBeInTheDocument(); - }); - - test('clicking Memories link navigates to /memories', async () => { - const user = userEvent.setup(); - render(, { initialRoutes: [`/${ROUTES.HOME}`] }); - - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.HOME}`, - ); - - await user.click(screen.getByText('Memories')); // click memories - - // verify - expect(screen.getByTestId('location-display')).toHaveTextContent( - `/${ROUTES.MEMORIES}`, - ); - expect(screen.getByText('Memories Page')).toBeInTheDocument(); - }); + 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 index 86c22dec7..1874c1a59 100644 --- a/frontend/src/components/__tests__/ThemeToggle.test.tsx +++ b/frontend/src/components/__tests__/ThemeToggle.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@/test-utils'; +import { render, screen, waitFor } from '@/test-utils'; import userEvent from '@testing-library/user-event'; import { ThemeSelector } from '../ThemeToggle'; @@ -22,37 +22,39 @@ describe('ThemeSelector', () => { await user.click(button); // open dropdown // verify dropdown options are visible - expect(screen.getByText('Light')).toBeInTheDocument(); + await screen.findByText('Light'); expect(screen.getByText('Dark')).toBeInTheDocument(); expect(screen.getByText('System')).toBeInTheDocument(); }); - test('selecting Dark theme option closes dropdown', async () => { - const user = userEvent.setup(); - render(); - - const button = screen.getByRole('button', { name: /themes/i }); - await user.click(button); // open dropdown - - const darkOption = screen.getByText('Dark'); - await user.click(darkOption); // select dark - - // dropdown should close (options no longer visible) - expect(screen.queryByText('Light')).not.toBeInTheDocument(); - }); - - test('selecting Light theme option closes dropdown', async () => { - const user = userEvent.setup(); - render(); - - const button = screen.getByRole('button', { name: /themes/i }); - await user.click(button); // open dropdown - - const lightOption = screen.getByText('Light'); - await user.click(lightOption); // select light - - // dropdown should close - expect(screen.queryByText('Dark')).not.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/pages/__tests__/SettingsPage.test.tsx b/frontend/src/pages/__tests__/SettingsPage.test.tsx new file mode 100644 index 000000000..ecb6f2572 --- /dev/null +++ b/frontend/src/pages/__tests__/SettingsPage.test.tsx @@ -0,0 +1,126 @@ +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'); + }); + }); + }); + + /** + * TODO: 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 index ce3396b51..4e09856d5 100644 --- a/frontend/src/test-utils.tsx +++ b/frontend/src/test-utils.tsx @@ -50,11 +50,16 @@ const customRender = (ui: ReactElement, options?: CustomRenderOptions) => { defaultOptions: { queries: { retry: false, + gcTime: Infinity, + staleTime: Infinity, + }, + mutations: { + retry: false, }, }, }); - return render(ui, { + const renderResult = render(ui, { wrapper: (props) => ( { ), ...renderOptions, }); + + return { + ...renderResult, + store: testStore, + queryClient: testQueryClient, + }; }; // Re-export everything From 96bede0caa53e8ce5ce9b1c6d51995738e9d850c Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:48:12 +0530 Subject: [PATCH 6/6] Add unit tests for components and utils Add comprehensive Jest tests for several frontend pieces: new tests for DeleteImageDialog (rendering and yes/no interactions), EmptyStates (gallery and AI tagging), GlobalLoader (conditional rendering and messages), and InfoDialog (rendering, variants, and close action updating store). Also add lib/utils tests for cn and getErrorMessage (including Axios-like errors) and dateUtils tests for getTimeAgo (uses fake timers) and groupImagesByYearMonthFromMetadata (grouping and edge cases). Minor comment wording tweak in SettingsPage.test.tsx. --- .../__tests__/DeleteImageDialog.test.tsx | 69 +++++++++++ .../components/__tests__/EmptyStates.test.tsx | 45 +++++++ .../__tests__/GlobalLoader.test.tsx | 35 ++++++ .../components/__tests__/InfoDialog.test.tsx | 71 +++++++++++ frontend/src/lib/__tests__/utils.test.ts | 76 ++++++++++++ .../src/pages/__tests__/SettingsPage.test.tsx | 3 +- frontend/src/test-utils.tsx | 6 + .../src/utils/__tests__/dateUtils.test.ts | 110 ++++++++++++++++++ 8 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/__tests__/DeleteImageDialog.test.tsx create mode 100644 frontend/src/components/__tests__/EmptyStates.test.tsx create mode 100644 frontend/src/components/__tests__/GlobalLoader.test.tsx create mode 100644 frontend/src/components/__tests__/InfoDialog.test.tsx create mode 100644 frontend/src/lib/__tests__/utils.test.ts create mode 100644 frontend/src/utils/__tests__/dateUtils.test.ts 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/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__/SettingsPage.test.tsx b/frontend/src/pages/__tests__/SettingsPage.test.tsx index ecb6f2572..8f61cc689 100644 --- a/frontend/src/pages/__tests__/SettingsPage.test.tsx +++ b/frontend/src/pages/__tests__/SettingsPage.test.tsx @@ -116,8 +116,9 @@ describe('Settings Page', () => { }); }); +// eslint-disable-next-line no-warning-comments /** - * TODO: System integrations (future scope) + * 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 diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx index 4e09856d5..981209272 100644 --- a/frontend/src/test-utils.tsx +++ b/frontend/src/test-utils.tsx @@ -39,6 +39,12 @@ 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({ 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); + }); +});