Skip to content
Merged
92 changes: 92 additions & 0 deletions frontend/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
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;
175 changes: 175 additions & 0 deletions frontend/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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="/" element={<div>Home Page</div>} />
<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>} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</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', () => {
test('clicking Settings link navigates to /settings', async () => {
const user = userEvent.setup();
render(<SidebarWithRoutes />, { 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(<SidebarWithRoutes />, { 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(<SidebarWithRoutes />, { 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(<SidebarWithRoutes />, { 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(<SidebarWithRoutes />, { 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(<SidebarWithRoutes />, { 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(<SidebarWithRoutes />, { 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();
});
});
});
58 changes: 58 additions & 0 deletions frontend/src/components/__tests__/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from '@/test-utils';
import userEvent from '@testing-library/user-event';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
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(<ThemeSelector />);

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

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