Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Test

on:
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test-ci
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
69 changes: 69 additions & 0 deletions components/__tests__/NoteForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { act, fireEvent, render } from '@testing-library/react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { NoteForm } from '@/components/NoteForm';

jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: jest.fn(),
}));

// `components/ui/text` imports `@rn-primitives/slot` even though NoteForm uses `asChild=false`.
// The real `slot` package ships JSX in its distributed JS, which Jest may fail to parse in this environment.
jest.mock('@rn-primitives/slot', () => ({
Text: () => null,
}));

const mockedUseSafeAreaInsets = useSafeAreaInsets as unknown as jest.Mock;

describe('components/NoteForm', () => {
beforeEach(() => {
mockedUseSafeAreaInsets.mockReturnValue({
top: 0,
right: 0,
bottom: 0,
left: 0,
});
});

it('calls onSave with trimmed title and content', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);

const { getByPlaceholderText, getAllByRole } = render(
<NoteForm onSave={onSave} submitLabel="Save" />
);

const titleInput = getByPlaceholderText('Title');
const contentInput = getByPlaceholderText('Note content');

fireEvent.changeText(titleInput, ' Hello ');
fireEvent.changeText(contentInput, ' World ');

// NoteForm renders a single Pressable button (role="button") via components/ui/button.
const saveButton = getAllByRole('button')[0];
await act(async () => {
fireEvent.press(saveButton);
});

expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith('Hello', 'World');
});

it('does not call onSave when title is empty/whitespace', () => {
const onSave = jest.fn().mockResolvedValue(undefined);

const { getByPlaceholderText, getAllByRole } = render(
<NoteForm onSave={onSave} submitLabel="Save" />
);

const titleInput = getByPlaceholderText('Title');
const contentInput = getByPlaceholderText('Note content');

fireEvent.changeText(titleInput, ' ');
fireEvent.changeText(contentInput, 'Something');

const saveButton = getAllByRole('button')[0];
fireEvent.press(saveButton);

expect(onSave).not.toHaveBeenCalled();
});
});
70 changes: 70 additions & 0 deletions hooks/__tests__/useParsedNumericRouteParam.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render } from '@testing-library/react-native';
import { useLocalSearchParams } from 'expo-router';
import { Text } from 'react-native';

import { useParsedNumericRouteParam } from '@/hooks/useParsedNumericRouteParam';

jest.mock('expo-router', () => ({
useLocalSearchParams: jest.fn(),
}));

const mockedUseLocalSearchParams = useLocalSearchParams as unknown as jest.Mock;

function HookConsumer({ paramKey }: { paramKey: string }) {
const { rawValue, value, isValid } = useParsedNumericRouteParam(paramKey);

return (
<>
<Text testID="raw">{rawValue === undefined ? 'undefined' : String(rawValue)}</Text>
<Text testID="value">{Number.isNaN(value) ? 'NaN' : String(value)}</Text>
<Text testID="isValid">{String(isValid)}</Text>
</>
);
}

describe('hooks/useParsedNumericRouteParam', () => {
afterEach(() => {
mockedUseLocalSearchParams.mockReset();
});

it('returns invalid when param is missing', () => {
mockedUseLocalSearchParams.mockReturnValue({});

const { getByTestId } = render(<HookConsumer paramKey="id" />);

expect(getByTestId('raw').props.children).toBe('undefined');
expect(getByTestId('value').props.children).toBe('NaN');
expect(getByTestId('isValid').props.children).toBe('false');
});

it('parses a numeric string param', () => {
mockedUseLocalSearchParams.mockReturnValue({ id: '12' });

const { getByTestId } = render(<HookConsumer paramKey="id" />);

expect(getByTestId('raw').props.children).toBe('12');
expect(getByTestId('value').props.children).toBe('12');
expect(getByTestId('isValid').props.children).toBe('true');
});

it('uses the first value when param is a string array', () => {
mockedUseLocalSearchParams.mockReturnValue({ id: ['5', '6'] });

const { getByTestId } = render(<HookConsumer paramKey="id" />);

expect(getByTestId('raw').props.children).toBe('5');
expect(getByTestId('value').props.children).toBe('5');
expect(getByTestId('isValid').props.children).toBe('true');
});

it('returns invalid for non-numeric values', () => {
mockedUseLocalSearchParams.mockReturnValue({ id: 'abc' });

const { getByTestId } = render(<HookConsumer paramKey="id" />);

expect(getByTestId('raw').props.children).toBe('abc');
expect(getByTestId('value').props.children).toBe('NaN');
expect(getByTestId('isValid').props.children).toBe('false');
});
});

14 changes: 14 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'jest-expo',
testEnvironment: 'node',
transform: {
'^.+\\.(ts|tsx)$': 'babel-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: ['**/__tests__/**/*.test.(ts|tsx)', '**/?(*.)+(spec|test).(ts|tsx)'],
clearMocks: true,
};

Loading
Loading