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
27 changes: 27 additions & 0 deletions redux/PlayersSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import playersReducer, {
playerAdd,
playerRoundScoreIncrement,
playerRoundScoreSet,
selectAllPlayerNames,
selectAllPlayers,
selectPlayerGrandTotalScore,
selectPlayerRoundStats,
Expand Down Expand Up @@ -230,6 +231,32 @@ describe('player sort order', () => {
});
});

describe('selectAllPlayerNames', () => {
it('should return unique player names with a stable memoized reference', () => {
const players = playersReducer(undefined, playerAdd({
id: '1',
playerName: 'Alice',
scores: [],
}));
const withDuplicateName = playersReducer(players, playerAdd({
id: '2',
playerName: 'Alice',
scores: [],
}));
const withOtherName = playersReducer(withDuplicateName, playerAdd({
id: '3',
playerName: 'Bob',
scores: [],
}));
const state = { players: withOtherName } as RootState;

const result = selectAllPlayerNames(state);

expect(result).toEqual(['Alice', 'Bob']);
expect(selectAllPlayerNames(state)).toBe(result);
});
});

describe('selectPlayerScoreByRound', () => {
it('should return the score for the requested round', () => {
expect(selectPlayerScoreByRound(stateWithScores([10, 20, 30]), '1', 1)).toEqual(20);
Expand Down
11 changes: 11 additions & 0 deletions redux/PlayersSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ export const selectPlayerNameById = createSelector(
(player) => player?.playerName
);

export const selectAllPlayerNames = createSelector(
[(state: RootState) => state.players.entities],
(entities) => {
const names: string[] = [];
for (const player of Object.values(entities)) {
if (player) names.push(player.playerName);
}
return [...new Set(names)];
}
);

export const selectPlayerScoreByRound = createSelector(
[
(state: RootState) => state.players.entities,
Expand Down
132 changes: 109 additions & 23 deletions src/components/EditGame.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render } from '@testing-library/react-native';
import { Platform } from 'react-native';
import { Provider } from 'react-redux';

import gamesReducer from '../../redux/GamesSlice';
Expand All @@ -11,28 +12,45 @@ import settingsReducer from '../../redux/SettingsSlice';
import EditGame from './EditGame';

// Mock react-native-elements
jest.mock('react-native-elements', () => ({
Input: ({ defaultValue, onChangeText, onEndEditing, onBlur, placeholder, testID }: {
defaultValue: string;
onChangeText: (text: string) => void;
onEndEditing: (e: { nativeEvent: { text: string } }) => void;
onBlur: (e: { nativeEvent: { text: string } }) => void;
placeholder: string;
testID?: string;
}) => {
const { TextInput } = jest.requireActual('react-native');
return (
<TextInput
defaultValue={defaultValue}
onChangeText={onChangeText}
onEndEditing={(e: { nativeEvent: { text: string } }) => onEndEditing({ nativeEvent: { text: e.nativeEvent.text } })}
onBlur={(e: { nativeEvent: { text: string } }) => onBlur({ nativeEvent: { text: e.nativeEvent.text } })}
placeholder={placeholder}
testID={testID || 'game-title-input'}
/>
);
},
}));
jest.mock('react-native-elements', () => {
const React = jest.requireActual('react');
const { Pressable, TextInput } = jest.requireActual('react-native');

return {
Input: React.forwardRef(({ value, onChangeText, onEndEditing, onSubmitEditing, onBlur, placeholder, testID, rightIcon, ...props }: {
value: string;
onChangeText: (text: string) => void;
onEndEditing: (e: { nativeEvent: { text: string } }) => void;
onSubmitEditing: (e: { nativeEvent: { text: string } }) => void;
onBlur: () => void;
placeholder: string;
rightIcon?: { onPress: () => void; name: string };
testID?: string;
}, ref: React.Ref<{ focus: () => void }>) => {
React.useImperativeHandle(ref, () => ({
focus: jest.fn(),
}));

return (
<>
<TextInput
onBlur={onBlur}
onChangeText={onChangeText}
onEndEditing={(e: { nativeEvent: { text: string } }) => onEndEditing({ nativeEvent: { text: e.nativeEvent.text } })}
onSubmitEditing={(e: { nativeEvent: { text: string } }) => onSubmitEditing({ nativeEvent: { text: e.nativeEvent.text } })}
placeholder={placeholder}
testID={testID || 'game-title-input'}
value={value}
{...props}
/>
{rightIcon != null && (
<Pressable onPress={rightIcon.onPress} testID="game-title-clear-button" />
)}
</>
);
}),
};
});

// Mock @react-navigation/native
jest.mock('@react-navigation/native', () => ({
Expand Down Expand Up @@ -89,6 +107,10 @@ describe('EditGame', () => {

beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(Platform, 'OS', {
configurable: true,
value: 'ios',
});
});

it('should render null when no current game is set', () => {
Expand Down Expand Up @@ -427,7 +449,71 @@ describe('EditGame', () => {
);

const input = getByTestId('game-title-input');
expect(input.props.defaultValue).toBe('');
expect(input.props.value).toBe('');
});

it('should use native replacement affordances', () => {
const store = createMockStore({
settings: {
currentGameId: 'game-1',
},
games: {
entities: {
'game-1': mockGame,
},
ids: ['game-1'],
},
players: {
entities: mockPlayers,
ids: ['player-1', 'player-2'],
},
});

const { getByTestId } = render(
<Provider store={store}>
<EditGame />
</Provider>
);

const input = getByTestId('game-title-input');

expect(input.props.clearButtonMode).toBe('while-editing');
expect(input.props.returnKeyType).toBe('done');
expect(input.props.selectTextOnFocus).toBe(true);
});

it('should clear the game title locally with the Android clear button', () => {
Object.defineProperty(Platform, 'OS', {
configurable: true,
value: 'android',
});

const store = createMockStore({
settings: {
currentGameId: 'game-1',
},
games: {
entities: {
'game-1': mockGame,
},
ids: ['game-1'],
},
players: {
entities: mockPlayers,
ids: ['player-1', 'player-2'],
},
});

const { getByDisplayValue, getByTestId } = render(
<Provider store={store}>
<EditGame />
</Provider>
);

fireEvent.press(getByTestId('game-title-clear-button'));

expect(getByDisplayValue('')).toBeTruthy();
expect(store.getState().games.entities['game-1']?.title).toBe('Test Game');
});

it('should handle very long titles within character limit', () => {
Expand Down
38 changes: 36 additions & 2 deletions src/components/EditGame.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import React, { useEffect, useState } from 'react';

import { useNavigation } from '@react-navigation/native';
import { NativeSyntheticEvent, StyleSheet, Text, TextInputEndEditingEventData, View } from 'react-native';
import {
NativeSyntheticEvent,
Platform,
StyleSheet,
Text,
TextInput,
TextInputEndEditingEventData,
TextInputSubmitEditingEventData,
View
} from 'react-native';
import { Input } from 'react-native-elements';

import { updateGame } from '../../redux/GamesSlice';
Expand All @@ -18,6 +27,7 @@ const EditGame = ({ }) => {
const dispatch = useAppDispatch();
const currentGame = useAppSelector(selectCurrentGame);
const [localTitle, setLocalTitle] = useState(currentGame?.title ?? '');
const inputRef = React.useRef<TextInput>(null);

const navigation = useNavigation();

Expand Down Expand Up @@ -48,27 +58,51 @@ const EditGame = ({ }) => {
commitTitle(text);
};

const onSubmitEditingHandler = (e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
const text = e.nativeEvent.text;
commitTitle(text);
};

const onChangeTextHandler = (text: string) => {
setLocalTitle(text);
};

const clearTitle = () => {
setLocalTitle('');
inputRef.current?.focus();
};

return (
<>
<View style={[styles.inputContainer, { backgroundColor: theme.inputBackground }]}>
<Input
defaultValue={localTitle}
ref={inputRef}
clearButtonMode="while-editing"
maxLength={30}
onChangeText={onChangeTextHandler}
onEndEditing={onEndEditingHandler}
onSubmitEditing={onSubmitEditingHandler}
onBlur={() => {
if (localTitle == '') {
commitTitle(UNTITLED);
}
}}
placeholder={UNTITLED}
renderErrorMessage={false}
returnKeyType="done"
rightIcon={Platform.OS === 'ios' ? undefined : {
style: { padding: 8 },
disabled: localTitle == '',
disabledStyle: { display: 'none' },
color: theme.textTertiary,
size: 15,
name: 'close',
onPress: clearTitle,
}}
selectTextOnFocus={true}
style={{ color: theme.inputText }}
inputContainerStyle={{ borderBottomWidth: 0 }}
value={localTitle}
/>
</View>
<View style={{ marginHorizontal: 20 }}>
Expand Down
Loading
Loading