-
Notifications
You must be signed in to change notification settings - Fork 26
feat: Automated test suite — unit, component #863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tuliomir
wants to merge
20
commits into
master
Choose a base branch
from
feat/automated-test-suite-layers-1-3
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
ecd05ec
feat: add automated test suite PoC — 57 tests across 3 layers
tuliomir 8f9eeb2
fix: make entire test suite pass — fix pre-existing test failures
tuliomir 67c4b44
chore: allow-scripts entry for jest-circus's unrs-resolver
tuliomir 4f4e9d3
fix: align reown.test.js with rejectAll saga's actual yield order
tuliomir bdbf958
test: add slice-refactor safety net + testing guide
tuliomir 49a4e57
chore: address review nits + pin jest-circus
tuliomir ec3b546
docs: annotate review findings that were intentionally not fixed
tuliomir 940f684
fix(ci): regenerate package-lock.json with Node 22
tuliomir 0864921
fix(lint): import jest globals in reducer test files
tuliomir d19358e
chore: address second review pass — lint hygiene + duplicate test
tuliomir 26638c6
test: address third-pass review — strengthen contracts, dedupe helper
tuliomir ee6848a
docs: add AGENTS.md, CLAUDE.md, and writing-tests skill
tuliomir 7a20c5f
docs: refine test docs per QA audit — gotchas first, less filler
tuliomir d5ff9f0
fix(test): address CodeRabbit lint findings on PR #863
tuliomir 86e8352
test: restore nanoContract coverage lost in 8f9eeb2
tuliomir 037b6e8
fix(test): import jest globals in utils + InitWallet tests
tuliomir 1c6dc07
fix(test): address CodeRabbit lint findings on screen tests
tuliomir adeff1a
test: tighten screen test contracts per CodeRabbit nitpicks
tuliomir 1e114e7
fix: address Copilot review on PR #863
tuliomir a5ae036
docs: break circular AGENTS.md ↔ CLAUDE.md pointer
tuliomir File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| /** | ||
| * Test helper: mock navigation objects for React Navigation 7. | ||
| * | ||
| * Provides mock implementations of useNavigation and useRoute that | ||
| * components can call without a real NavigationContainer. | ||
| */ | ||
| import { jest } from '@jest/globals'; | ||
|
|
||
| export function createMockNavigation() { | ||
| return { | ||
| navigate: jest.fn(), | ||
| push: jest.fn(), | ||
| replace: jest.fn(), | ||
| goBack: jest.fn(), | ||
| reset: jest.fn(), | ||
| setOptions: jest.fn(), | ||
| setParams: jest.fn(), | ||
| dispatch: jest.fn(), | ||
| addListener: jest.fn(() => jest.fn()), // returns unsubscribe | ||
| canGoBack: jest.fn(() => true), | ||
| isFocused: jest.fn(() => true), | ||
| getParent: jest.fn(), | ||
| getState: jest.fn(() => ({ | ||
| index: 0, | ||
| routes: [{ name: 'MockRoute', key: 'mock-key' }], | ||
| })), | ||
| }; | ||
| } | ||
|
|
||
| export function createMockRoute(params: Record<string, unknown> = {}) { | ||
| return { | ||
| key: 'mock-route-key', | ||
| name: 'MockRoute', | ||
| params, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Setup jest mocks for both React Navigation's native hooks | ||
| * and the app's custom BigInt-aware hooks in src/hooks/navigation.js. | ||
| * | ||
| * Call this in a beforeEach or at module scope in your test file. | ||
| * | ||
| * @returns Object with references to the mocks for assertion. | ||
| */ | ||
| export function setupNavigationMocks(routeParams: Record<string, unknown> = {}) { | ||
| const mockNav = createMockNavigation(); | ||
| const mockRoute = createMockRoute(routeParams); | ||
|
|
||
| // Mock React Navigation's native hooks | ||
| jest.mock('@react-navigation/native', () => { | ||
| const actual = jest.requireActual('@react-navigation/native') as any; | ||
| return { | ||
| ...actual, | ||
| useNavigation: () => mockNav, | ||
| useRoute: () => mockRoute, | ||
| }; | ||
| }); | ||
|
|
||
| // Mock the app's custom hooks (which wrap the above with BigInt handling) | ||
| jest.mock('../../src/hooks/navigation', () => ({ | ||
| useNavigation: () => mockNav, | ||
| useParams: () => mockRoute.params, | ||
| })); | ||
|
|
||
| return { navigation: mockNav, route: mockRoute }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| /** | ||
| * Test helper: creates a Redux store with optional preloaded state. | ||
| * | ||
| * Uses the real reducer from the app so that dispatched actions | ||
| * produce the same state transitions as production code. | ||
| */ | ||
| import { legacy_createStore as createStore } from 'redux'; | ||
| import { reducer } from '../../src/reducers/reducer'; | ||
|
|
||
| /** | ||
| * Create a Redux store for testing with optional state overrides. | ||
| * | ||
| * @param preloadedState - Partial state merged on top of the reducer's | ||
| * built-in initialState. Pass only the keys you care about. | ||
| */ | ||
| export function createTestStore(preloadedState: Record<string, unknown> = {}) { | ||
| // Dispatching an unknown action returns initialState from the reducer, | ||
| // which we then merge with the caller's overrides. | ||
| const baseState = reducer(undefined, { type: '@@INIT' }); | ||
| return createStore(reducer, { ...baseState, ...preloadedState } as any); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| /** | ||
| * Test helper: renders a React component wrapped in all the providers | ||
| * that the Hathor Wallet app expects (Redux, Navigation). | ||
| * | ||
| * Usage: | ||
| * const { getByText } = renderWithProviders(<MyScreen />, { | ||
| * preloadedState: { isOnline: true }, | ||
| * }); | ||
| */ | ||
| import React from 'react'; | ||
| import { render, RenderOptions } from '@testing-library/react-native'; | ||
| import { Provider } from 'react-redux'; | ||
| import { NavigationContainer } from '@react-navigation/native'; | ||
| import { createTestStore } from './mockStore'; | ||
|
|
||
| interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> { | ||
| /** Partial Redux state merged on top of initialState. */ | ||
| preloadedState?: Record<string, unknown>; | ||
| /** Supply your own store (e.g. if you need to dispatch in the test). */ | ||
| store?: ReturnType<typeof createTestStore>; | ||
| /** If false, skip wrapping in NavigationContainer (default: true). */ | ||
| withNavigation?: boolean; | ||
| } | ||
|
|
||
| export function renderWithProviders( | ||
| ui: React.ReactElement, | ||
| { | ||
| preloadedState = {}, | ||
| store = createTestStore(preloadedState), | ||
| withNavigation = true, | ||
| ...renderOptions | ||
| }: ExtendedRenderOptions = {}, | ||
| ) { | ||
| function Wrapper({ children }: { children: React.ReactNode }) { | ||
| const content = <Provider store={store}>{children}</Provider>; | ||
| if (withNavigation) { | ||
| return <NavigationContainer>{content}</NavigationContainer>; | ||
| } | ||
| return content; | ||
| } | ||
|
|
||
| return { | ||
| store, | ||
| ...render(ui, { wrapper: Wrapper, ...renderOptions }), | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| /** | ||
| * Unit tests for the wallet lifecycle reducer actions. | ||
| * | ||
| * Tests the full start → ready/failed cycle and the reset cycle, | ||
| * verifying that Redux state transitions match expectations. | ||
| */ | ||
| import { reducer } from '../../src/reducers/reducer'; | ||
| import { | ||
| startWalletRequested, | ||
| startWalletSuccess, | ||
| startWalletFailed, | ||
| setWallet, | ||
| resetData, | ||
| resetWalletSuccess, | ||
| newToken, | ||
| setTokens, | ||
| updateSelectedToken, | ||
| } from '../../src/actions'; | ||
| import { WALLET_STATUS } from '../../src/sagas/wallet'; | ||
| import { DEFAULT_TOKEN, INITIAL_TOKENS } from '../../src/constants'; | ||
|
|
||
| // Helper: get initial state from the reducer | ||
| const getInitialState = () => reducer(undefined, { type: '@@INIT' }); | ||
|
|
||
| // ─── Wallet Start Lifecycle ──────────────────────────────────────────────── | ||
| describe('wallet start lifecycle', () => { | ||
| it('starts in NOT_STARTED state', () => { | ||
| const state = getInitialState(); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.NOT_STARTED); | ||
| }); | ||
|
|
||
| it('transitions to LOADING on START_WALLET_REQUESTED', () => { | ||
| const state = getInitialState(); | ||
| const next = reducer(state, startWalletRequested({ words: 'test', pin: '123456' })); | ||
| expect(next.walletStartState).toBe(WALLET_STATUS.LOADING); | ||
| }); | ||
|
|
||
| it('transitions to READY on START_WALLET_SUCCESS', () => { | ||
| // Start from LOADING state | ||
| let state = reducer(getInitialState(), startWalletRequested({ words: 'test', pin: '123456' })); | ||
| state = reducer(state, startWalletSuccess()); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.READY); | ||
| }); | ||
|
|
||
| it('transitions to FAILED on START_WALLET_FAILED', () => { | ||
| let state = reducer(getInitialState(), startWalletRequested({ words: 'test', pin: '123456' })); | ||
| state = reducer(state, startWalletFailed()); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.FAILED); | ||
| }); | ||
|
|
||
| it('full lifecycle: NOT_STARTED → LOADING → READY', () => { | ||
| let state = getInitialState(); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.NOT_STARTED); | ||
|
|
||
| state = reducer(state, startWalletRequested({ words: 'test', pin: '123456' })); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.LOADING); | ||
|
|
||
| state = reducer(state, startWalletSuccess()); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.READY); | ||
| }); | ||
| }); | ||
|
|
||
| // ─── SET_WALLET ──────────────────────────────────────────────────────────── | ||
| describe('SET_WALLET', () => { | ||
| it('sets wallet instance in state', () => { | ||
| const mockWallet = { id: 'test-wallet' }; | ||
| const state = reducer(getInitialState(), setWallet(mockWallet)); | ||
| expect(state.wallet).toBe(mockWallet); | ||
| }); | ||
|
|
||
| it('can set wallet to null', () => { | ||
| const state = reducer( | ||
| { ...getInitialState(), wallet: { id: 'existing' } }, | ||
| setWallet(null), | ||
| ); | ||
| expect(state.wallet).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // ─── Token Operations (populated during wallet start) ────────────────────── | ||
| describe('token operations', () => { | ||
| it('adds a new token via NEW_TOKEN', () => { | ||
| const token = { uid: 'token123', name: 'TestCoin', symbol: 'TST' }; | ||
| const state = reducer(getInitialState(), newToken(token)); | ||
| expect(state.tokens['token123']).toEqual(token); | ||
| // Initial default token should still be present | ||
| expect(state.tokens[DEFAULT_TOKEN.uid]).toBeDefined(); | ||
| }); | ||
|
|
||
| it('sets full token map via SET_TOKENS', () => { | ||
| const tokens = { | ||
| [DEFAULT_TOKEN.uid]: DEFAULT_TOKEN, | ||
| token123: { uid: 'token123', name: 'TestCoin', symbol: 'TST' }, | ||
| }; | ||
| const state = reducer(getInitialState(), setTokens(tokens)); | ||
| expect(Object.keys(state.tokens)).toHaveLength(2); | ||
| expect(state.tokens['token123'].name).toBe('TestCoin'); | ||
| }); | ||
|
|
||
| it('resets selectedToken to DEFAULT_TOKEN if unregistered via SET_TOKENS', () => { | ||
| // Select a non-default token first | ||
| const customToken = { uid: 'custom', name: 'Custom', symbol: 'CUS' }; | ||
| let state = reducer(getInitialState(), updateSelectedToken(customToken)); | ||
| expect(state.selectedToken.uid).toBe('custom'); | ||
|
|
||
| // Now set tokens without the custom one → should reset to default | ||
| state = reducer(state, setTokens({ [DEFAULT_TOKEN.uid]: DEFAULT_TOKEN })); | ||
| expect(state.selectedToken.uid).toBe(DEFAULT_TOKEN.uid); | ||
| }); | ||
|
|
||
| it('keeps selectedToken if still present in SET_TOKENS', () => { | ||
| const customToken = { uid: 'custom', name: 'Custom', symbol: 'CUS' }; | ||
| let state = reducer(getInitialState(), updateSelectedToken(customToken)); | ||
| state = reducer(state, setTokens({ | ||
| [DEFAULT_TOKEN.uid]: DEFAULT_TOKEN, | ||
| custom: customToken, | ||
| })); | ||
| expect(state.selectedToken.uid).toBe('custom'); | ||
| }); | ||
| }); | ||
|
|
||
| // ─── Reset Cycle ─────────────────────────────────────────────────────────── | ||
| describe('wallet reset', () => { | ||
| it('RESET_DATA returns full initial state', () => { | ||
| // Build up a modified state | ||
| let state = reducer(getInitialState(), startWalletRequested({ words: 'test', pin: '123456' })); | ||
| state = reducer(state, startWalletSuccess()); | ||
| state = reducer(state, setWallet({ id: 'wallet' })); | ||
| state = reducer(state, newToken({ uid: 'token123', name: 'Test', symbol: 'TST' })); | ||
| expect(state.walletStartState).toBe(WALLET_STATUS.READY); | ||
|
|
||
| // Nuclear reset | ||
| const resetState = reducer(state, resetData()); | ||
| expect(resetState.walletStartState).toBe(WALLET_STATUS.NOT_STARTED); | ||
| expect(resetState.wallet).toBeNull(); | ||
| expect(resetState.tokens).toEqual(INITIAL_TOKENS); | ||
| expect(resetState.selectedToken).toEqual(DEFAULT_TOKEN); | ||
| expect(resetState.isOnline).toBe(false); | ||
| }); | ||
|
|
||
| it('RESET_WALLET_SUCCESS resets state but preserves feature toggles', () => { | ||
| // Build up state with feature toggles set | ||
| let state = getInitialState(); | ||
| // Simulate feature toggles being initialized | ||
| state = { | ||
| ...state, | ||
| unleashClient: { mock: true }, | ||
| featureTogglesInitialized: true, | ||
| featureToggles: { someFlag: true }, | ||
| wallet: { id: 'wallet' }, | ||
| walletStartState: WALLET_STATUS.READY, | ||
| }; | ||
|
|
||
| const resetState = reducer(state, resetWalletSuccess()); | ||
|
|
||
| // Wallet state should be reset | ||
| expect(resetState.walletStartState).toBe(WALLET_STATUS.NOT_STARTED); | ||
| expect(resetState.wallet).toBeNull(); | ||
| expect(resetState.tokens).toEqual(INITIAL_TOKENS); | ||
|
|
||
| // Feature toggles should be preserved | ||
| expect(resetState.unleashClient).toEqual({ mock: true }); | ||
| expect(resetState.featureTogglesInitialized).toBe(true); | ||
| expect(resetState.featureToggles).toEqual({ someFlag: true }); | ||
| }); | ||
|
|
||
| it('RESET_WALLET_SUCCESS clears tokens and balances', () => { | ||
| let state = getInitialState(); | ||
| state = reducer(state, newToken({ uid: 'tok1', name: 'A', symbol: 'A' })); | ||
| state = { ...state, tokensBalance: { tok1: { data: { available: 100n } } } }; | ||
|
|
||
| const resetState = reducer(state, resetWalletSuccess()); | ||
| expect(resetState.tokens).toEqual(INITIAL_TOKENS); | ||
| expect(resetState.tokensBalance).toEqual({}); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.