Skip to content
Merged
101 changes: 101 additions & 0 deletions frontend/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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,
};
});
7 changes: 4 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1",
Expand Down
24 changes: 13 additions & 11 deletions frontend/src/app/store.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
// Inferred type: {loader: LoaderState, onboarding: OnboardingState, images: ImageState, ...}
export type AppDispatch = typeof store.dispatch;
103 changes: 103 additions & 0 deletions frontend/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="location-display">{location.pathname}</div>;
};

// Sidebar + routes display
const SidebarWithRoutes = () => (
<SidebarProvider>
<AppSidebar />
<main>
<LocationDisplay />
<Routes>
<Route path={ROUTES.HOME} element={<div>Home Page</div>} />
<Route path={ROUTES.SETTINGS} element={<div>Settings Page</div>} />
<Route path={ROUTES.AI} element={<div>AI Tagging Page</div>} />
<Route path={ROUTES.FAVOURITES} element={<div>Favourites Page</div>} />
<Route path={ROUTES.VIDEOS} element={<div>Videos Page</div>} />
<Route path={ROUTES.ALBUMS} element={<div>Albums Page</div>} />
<Route path={ROUTES.MEMORIES} element={<div>Memories Page</div>} />
</Routes>
</main>
</SidebarProvider>
);

describe('Sidebar', () => {
describe('Structure Tests', () => {
test('renders all main navigation links', () => {
render(
<SidebarProvider>
<AppSidebar />
</SidebarProvider>,
);

// 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(<SidebarWithRoutes />, { 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();
},
);
});
});
60 changes: 60 additions & 0 deletions frontend/src/components/__tests__/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ThemeSelector />);

// 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(<ThemeSelector />);

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(<ThemeSelector />);

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(),
);
},
);
});
});
31 changes: 31 additions & 0 deletions frontend/src/pages/__tests__/PageSanity.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Home />);
expect(
await screen.findByText(
/Image Gallery|No Images to Display|Loading images/i,
),
).toBeInTheDocument();
});
});

describe('Settings Page', () => {
test('renders settings page sections', () => {
render(<Settings />);

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.
});
});
Loading
Loading