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
2 changes: 1 addition & 1 deletion __tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test('boots to the onboarding destination when no wallet is stored', async () =>
// set) inside act so its final phase update is act-wrapped, not a warning.
await new Promise<void>((resolve) => setImmediate(resolve));
});
expect(JSON.stringify(tree?.toJSON())).toContain('Onboarding');
expect(JSON.stringify(tree?.toJSON())).toContain('RUST');
await act(async () => {
tree?.unmount();
});
Expand Down
92 changes: 92 additions & 0 deletions src/components/PinKeypad.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* PinKeypad — dots + numeric keypad, the visual language of the flipbook's
* "Вход — Locked" screen (`design-handoff/prototypes/Rustok Flipbook.dc.html`,
* :228-261). Controlled and purpose-agnostic: it collects exactly `length`
* digits and calls `onComplete` — it knows nothing about create/confirm/unlock
* (A2's CreatePin/ConfirmPin and A3's UnlockPin all render this one component).
*
* `error` triggers a visual reset (dots clear) without touching internal
* timing — the caller owns any shake/haptics choreography.
*/

import { useEffect, useRef, useState } from 'react';
import { Pressable, Text, View } from 'react-native';

interface PinKeypadProps {
readonly length: number;
readonly onComplete: (pin: string) => void;
/** When true, clears the entered digits (e.g. after a mismatch). */
readonly error?: boolean;
}

const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', '⌫'] as const;

export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) {
const [digits, setDigits] = useState('');
// Mirrors `digits` synchronously. `press` reads/writes this instead of the
// `digits` state closure so a same-tick double-tap (multi-touch) sees the
// PRIOR press's result, not a stale render-time value — without that, two
// presses landing before a re-render both compute off the same base and
// `onComplete` can fire twice with different strings (Gate-2 MINOR #2).
const digitsRef = useRef('');

useEffect(() => {
if (error) {
digitsRef.current = '';
setDigits('');
}
}, [error]);

function press(key: string) {
if (key === '') {
return;
}
if (key === '⌫') {
digitsRef.current = digitsRef.current.slice(0, -1);
setDigits(digitsRef.current);
return;
}
const next = digitsRef.current + key;
digitsRef.current = next;
setDigits(next);
if (next.length === length) {
// Fired from the Pressable's event handler, not from a state updater —
// `onComplete` (which drives an onboardingStore/pinAttemptsStore
// `set()`) must never run during React's render phase.
onComplete(next);
digitsRef.current = '';
setDigits('');
}
}

return (
<View className="items-center gap-8" testID="pin-keypad">
<View className="flex-row gap-3">
{Array.from({ length }, (_, i) => (
<View
key={i}
testID={`pin-dot-${i}`}
className={
i < digits.length
? 'h-[11px] w-[11px] rounded-full bg-accent'
: 'h-[11px] w-[11px] rounded-full border border-borderDisabled'
}
/>
))}
</View>
<View className="w-full max-w-[280px] flex-row flex-wrap justify-center gap-4">
{KEYS.map((key, i) => (
<Pressable
key={i}
testID={key === '' ? undefined : `pin-key-${key === '⌫' ? 'backspace' : key}`}
disabled={key === ''}
onPress={() => press(key)}
className="aspect-square w-[62px] items-center justify-center rounded-full border border-borderDefault"
>
<Text className="font-mono text-xl text-textPrimary">{key}</Text>
</Pressable>
))}
</View>
</View>
);
}
123 changes: 123 additions & 0 deletions src/components/__tests__/PinKeypad.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* PinKeypad tests — driven via `testID`s (no @testing-library/react-native in
* this project; TestRenderer + Pressable.props.onPress(), matching the
* render-smoke convention used elsewhere — RootNavigator.test.tsx).
*/

import TestRenderer, { act } from 'react-test-renderer';

import { PinKeypad } from '../PinKeypad';

function pressKey(tree: TestRenderer.ReactTestRenderer, key: string) {
const testID = key === '⌫' ? 'pin-key-backspace' : `pin-key-${key}`;
act(() => {
tree.root.findByProps({ testID }).props.onPress();
});
}

function litDots(tree: TestRenderer.ReactTestRenderer, length: number): number {
let lit = 0;
for (let i = 0; i < length; i += 1) {
const dot = tree.root.findByProps({ testID: `pin-dot-${i}` });
if (JSON.stringify(dot.props.className).includes('bg-accent')) {
lit += 1;
}
}
return lit;
}

describe('PinKeypad', () => {
it('calls onComplete with the digits once length is reached, then clears', () => {
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={4} onComplete={onComplete} />);
});

for (const key of ['1', '2', '3']) {
pressKey(tree!, key);
expect(onComplete).not.toHaveBeenCalled();
}
pressKey(tree!, '4');

expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith('1234');
expect(litDots(tree!, 4)).toBe(0); // cleared after completion
});

it('backspace removes the last digit', () => {
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={4} onComplete={onComplete} />);
});

pressKey(tree!, '1');
pressKey(tree!, '2');
expect(litDots(tree!, 4)).toBe(2);

pressKey(tree!, '⌫');
expect(litDots(tree!, 4)).toBe(1);

pressKey(tree!, '3');
pressKey(tree!, '4');
expect(onComplete).not.toHaveBeenCalled(); // only 3 digits entered net of the backspace
});

it('starts a fresh entry after completion, not accumulating onto the finished one', () => {
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={2} onComplete={onComplete} />);
});

pressKey(tree!, '1');
pressKey(tree!, '2');
expect(onComplete).toHaveBeenCalledWith('12');
onComplete.mockClear();

pressKey(tree!, '3'); // a new entry, not a 3rd digit glued onto the completed one
expect(onComplete).not.toHaveBeenCalled();
expect(litDots(tree!, 2)).toBe(1);
});

it('a same-tick double-tap (multi-touch) fires onComplete at most once (Gate-2 MINOR #2)', () => {
// Two presses dispatched inside ONE act() — no re-render happens between
// them, so a version reading `digits` off the render-time closure sees
// the SAME stale value for both and can complete twice (e.g. '12' then
// '13'). The functional setState update fixes this: each queued updater
// sees the prior updater's result, not a stale closure.
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={2} onComplete={onComplete} />);
});
pressKey(tree!, '1'); // settle to digits='1' via its own render

act(() => {
tree.root.findByProps({ testID: 'pin-key-2' }).props.onPress();
tree.root.findByProps({ testID: 'pin-key-3' }).props.onPress();
});

expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith('12');
});

it('the error prop clears any entered digits', () => {
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={4} onComplete={onComplete} error={false} />);
});

pressKey(tree!, '1');
pressKey(tree!, '2');
expect(litDots(tree!, 4)).toBe(2);

act(() => {
tree.update(<PinKeypad length={4} onComplete={onComplete} error />);
});

expect(litDots(tree!, 4)).toBe(0);
});
});
Loading