Skip to content
Open
Show file tree
Hide file tree
Changes from all 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",
"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;
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);
});
});
});
45 changes: 45 additions & 0 deletions frontend/src/components/__tests__/EmptyStates.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EmptyGalleryState />);

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

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

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