Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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