Skip to content
Merged
178 changes: 178 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,172 @@ 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,
};
});

// Mock Global Fetch
const mockModelStatus = {
success: true,
data: {
object_detection_nano: {
feature: 'object_detection',
tier: 'nano',
installed: true,
},
face_detection_nano: {
feature: 'face_detection',
tier: 'nano',
installed: true,
},
object_detection_small: {
feature: 'object_detection',
tier: 'small',
installed: true,
},
face_detection_small: {
feature: 'face_detection',
tier: 'small',
installed: true,
},
object_detection_medium: {
feature: 'object_detection',
tier: 'medium',
installed: true,
},
face_detection_medium: {
feature: 'face_detection',
tier: 'medium',
installed: true,
},
},
};

const mockHardwareInfo = {
success: true,
data: {
ram_gb: 16,
gpu_detected: true,
gpu_names: ['NVIDIA GeForce RTX 4070'],
available_providers: ['CUDAExecutionProvider', 'CPUExecutionProvider'],
recommended_tier: 'small',
},
};

global.fetch = jest.fn().mockImplementation((url: string) => {
if (url.includes('/models/status')) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockModelStatus),
});
}
if (url.includes('/models/hardware')) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockHardwareInfo),
});
}
if (url.includes('/models/setup')) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true }),
});
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({}),
});
});
26 changes: 14 additions & 12 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 @@ -8,19 +8,21 @@ import infoDialogReducer from '@/features/infoDialogSlice';
import folderReducer from '@/features/folderSlice';
import memoriesReducer from '@/features/memoriesSlice';

export const rootReducer = combineReducers({
loader: loaderReducer,
onboarding: onboardingReducer,
images: imageReducer,
faceClusters: faceClustersReducer,
infoDialog: infoDialogReducer,
folders: folderReducer,
search: searchReducer,
memories: memoriesReducer,
});

export const store = configureStore({
reducer: {
loader: loaderReducer,
onboarding: onboardingReducer,
images: imageReducer,
faceClusters: faceClustersReducer,
infoDialog: infoDialogReducer,
folders: folderReducer,
search: searchReducer,
memories: memoriesReducer,
},
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;
69 changes: 69 additions & 0 deletions frontend/src/components/__tests__/DeleteImageDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DeleteImagesDialog {...defaultProps} />);

expect(
screen.getByText(
'Do you also want to delete these images from Device ?',
),
).toBeInTheDocument();
});

test('does not render content when closed', () => {
render(<DeleteImagesDialog {...defaultProps} isOpen={false} />);

expect(
screen.queryByText(
'Do you also want to delete these images from Device ?',
),
).not.toBeInTheDocument();
});

test('renders Yes and No buttons', () => {
render(<DeleteImagesDialog {...defaultProps} />);

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(<DeleteImagesDialog {...defaultProps} />);

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(<DeleteImagesDialog {...defaultProps} />);

await user.click(screen.getByRole('button', { name: /no/i }));

expect(mockExecuteDeleteImages).toHaveBeenCalledWith(false);
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
});
});
});
49 changes: 49 additions & 0 deletions frontend/src/components/__tests__/EmptyStates.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { render, screen } from '@/test-utils';
import { EmptyGalleryState } from '../EmptyStates/EmptyGalleryState';
import { EmptyAITaggingState } from '../EmptyStates/EmptyAITaggingState';

describe('EmptyGalleryState', () => {
test('renders heading', () => {
render(<EmptyGalleryState />);

expect(
screen.getByRole('heading', { name: /no images to display/i }),
).toBeInTheDocument();
});

test('renders gallery instructions', () => {
render(<EmptyGalleryState />);

expect(
screen.getByText(
(_, element) =>
element?.tagName === 'SPAN' &&
element?.textContent === 'Go to Settings to add folders.',
),
).toBeInTheDocument();
expect(
screen.getByText(/supports png, jpg, jpeg image formats/i),
).toBeInTheDocument();
});
});

describe('EmptyAITaggingState', () => {
test('renders heading', () => {
render(<EmptyAITaggingState />);

expect(
screen.getByRole('heading', { name: /no ai tagged images/i }),
).toBeInTheDocument();
});

test('renders AI tagging instructions', () => {
render(<EmptyAITaggingState />);

expect(
screen.getByText(/ai will automatically detect objects and people/i),
).toBeInTheDocument();
expect(
screen.getByText(/supports png, jpg, jpeg image formats/i),
).toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions frontend/src/components/__tests__/GlobalLoader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import { GlobalLoader } from '../Loader/GlobalLoader';

describe('GlobalLoader', () => {
test('renders loading message when loading is true', () => {
render(<GlobalLoader loading={true} message="Loading images..." />);

expect(screen.getByText('Loading images...')).toBeInTheDocument();
});

test('renders empty container when loading is false', () => {
const { container } = render(
<GlobalLoader loading={false} message="Loading images..." />,
);

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(<GlobalLoader loading={true} message={message} />);

expect(screen.getByText(message)).toBeInTheDocument();
});
});
Loading
Loading