feat: add initial setup for coverage checking, ESLint configuration, and error boundary component

- Introduced `bunfig.toml` for test preload configuration.
- Added ESLint configuration file for code linting.
- Implemented a coverage checking script to ensure test coverage meets the specified threshold.
- Created `AppErrorBoundary` component to handle errors gracefully in the application.
- Defined game constants for player requirements and magic number constraints.
- Developed `useProvisionalLoser` hook to compute the provisional loser based on game state.
- Implemented services for parsing dice payloads, managing leaderboard entries, and ranking players.
- Added utility functions for score formatting and pot calculations.
- Created setup service for validating game start inputs and player profiles.
- Wrote comprehensive tests for components, hooks, and services to ensure functionality and reliability.
This commit is contained in:
nessar committed 2026-04-04 20:08:59 +02:00
1 parent 37af018378
commit 9e9f81ba57
34 files changed
+2833 -449

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Example environment variables
# Copy this file to `.env` and fill with non-sensitive local values.
EXPO_PUBLIC_APP_ENV=development
+4
View File
@@ -31,8 +31,12 @@ yarn-error.*
*.pem
# local env files
.env
.env*.local
# test coverage
coverage/
# typescript
*.tsbuildinfo
+11 -8
View File
@@ -3,6 +3,7 @@ import { StatusBar } from 'expo-status-bar';
import { StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppErrorBoundary } from './src/components/AppErrorBoundary';
import { AppNavigator } from './src/navigation/AppNavigator';
import { ui } from './src/styles/ui';
@@ -10,14 +11,16 @@ export default function App() {
return (
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider>
<NavigationContainer>
<StatusBar
style="light"
translucent={false}
backgroundColor={ui.color.bg}
/>
<AppNavigator />
</NavigationContainer>
<AppErrorBoundary>
<NavigationContainer>
<StatusBar
style="light"
translucent={false}
backgroundColor={ui.color.bg}
/>
<AppNavigator />
</NavigationContainer>
</AppErrorBoundary>
</SafeAreaProvider>
</GestureHandlerRootView>
);
+53
View File
@@ -0,0 +1,53 @@
# Jeu du 21 (Expo + React Native)
Application mobile du jeu du 21, structurée pour un usage production avec une stratégie de non-régression via `bun:test`.
## Prérequis
- Bun `>= 1.3`
- Node (pour Expo CLI), sinon installer via `nvm install latest`
## Installation
```bash
bun install
```
## Lancer l'app
```bash
bun run start
bun run android
```
## Qualité logicielle
```bash
bun run lint
bun run typecheck
bun run test
bun run test:coverage
```
Le script `test:coverage` applique un seuil global de couverture minimal à `90%` (lignes, branches, fonctions).
## Architecture
- `src/screens`: écrans de navigation
- `src/components`: composants UI réutilisables
- `src/services`: logique métier pure (testable)
- `src/hooks`: hooks applicatifs isolés
- `src/constants`: constantes de domaine
- `src/store`: état global de jeu (`zustand`)
## Build production (EAS)
```bash
bun x eas build --platform android --profile production
```
## Sécurité & config
- Ne pas committer de secrets dans `.env`
- Utiliser `.env.example` comme référence
- Exécuter `bun run ci:quality` avant toute release
+1301 -41
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
+10
View File
@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);
+17 -1
View File
@@ -4,7 +4,13 @@
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android"
"android": "expo start --android",
"lint": "bun x expo lint",
"typecheck": "bun x tsc --noEmit",
"test": "bun test",
"coverage:check": "bun run scripts/check-coverage.ts",
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov && bun run coverage:check",
"ci:quality": "bun run lint && bun run typecheck && bun run test:coverage"
},
"dependencies": {
"@3d-dice/dice-box": "^1.1.4",
@@ -26,7 +32,17 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "~19.2.14",
"bun-types": "^1.3.11",
"eslint": "^9.0.0",
"eslint-config-expo": "^55.0.0",
"jest": "^30.3.0",
"jest-expo": "^55.0.13",
"prettier": "^3.8.1",
"react-test-renderer": "^19.2.4",
"typescript": "~5.9.2"
},
"private": true
+117
View File
@@ -0,0 +1,117 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
type CoverageMetric = {
hit: number;
found: number;
percent: number;
};
const threshold = Number(process.env.COVERAGE_THRESHOLD ?? 90);
const lcovFile = resolve(process.cwd(), 'coverage/lcov.info');
if (!existsSync(lcovFile)) {
console.error('[coverage] coverage/lcov.info not found. Run `bun test --coverage` first.');
process.exit(1);
}
const content = readFileSync(lcovFile, 'utf8');
const records = content.split('end_of_record');
const isTestFile = (sourceFilePath: string): boolean => {
return (
sourceFilePath.startsWith('test/') ||
sourceFilePath.includes('/test/') ||
sourceFilePath.includes('\\test\\')
);
};
let linesFound = 0;
let linesHit = 0;
let branchesFound = 0;
let branchesHit = 0;
let functionsFound = 0;
let functionsHit = 0;
for (const record of records) {
const lines = record
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
continue;
}
const sourceFileLine = lines.find((line) => line.startsWith('SF:'));
if (!sourceFileLine) {
continue;
}
const sourceFilePath = sourceFileLine.slice(3);
if (isTestFile(sourceFilePath)) {
continue;
}
for (const line of lines) {
if (line.startsWith('LF:')) {
linesFound += Number(line.slice(3));
continue;
}
if (line.startsWith('LH:')) {
linesHit += Number(line.slice(3));
continue;
}
if (line.startsWith('BRF:')) {
branchesFound += Number(line.slice(4));
continue;
}
if (line.startsWith('BRH:')) {
branchesHit += Number(line.slice(4));
continue;
}
if (line.startsWith('FNF:')) {
functionsFound += Number(line.slice(4));
continue;
}
if (line.startsWith('FNH:')) {
functionsHit += Number(line.slice(4));
}
}
}
const toMetric = (hit: number, found: number): CoverageMetric => ({
hit,
found,
percent: found === 0 ? 100 : (hit / found) * 100,
});
const metrics = {
lines: toMetric(linesHit, linesFound),
branches: toMetric(branchesHit, branchesFound),
functions: toMetric(functionsHit, functionsFound),
};
const failing = Object.entries(metrics).filter(([, metric]) => metric.percent < threshold);
for (const [name, metric] of Object.entries(metrics)) {
console.log(
`[coverage] ${name}: ${metric.percent.toFixed(2)}% (${metric.hit}/${metric.found})`,
);
}
if (failing.length > 0) {
console.error(
`[coverage] Minimum ${threshold}% not reached for: ${failing
.map(([name]) => name)
.join(', ')}`,
);
process.exit(1);
}
console.log(`[coverage] ✅ Global threshold ${threshold}% reached.`);
+79
View File
@@ -0,0 +1,79 @@
import { Component, ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { presets, textWeight, ui } from '../styles/ui';
type AppErrorBoundaryProps = {
children: ReactNode;
};
type AppErrorBoundaryState = {
hasError: boolean;
};
export class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
public state: AppErrorBoundaryState = {
hasError: false,
};
public static getDerivedStateFromError(): AppErrorBoundaryState {
return { hasError: true };
}
public componentDidCatch(error: Error): void {
console.error('[AppErrorBoundary]', error);
}
private retry = (): void => {
this.setState({ hasError: false });
};
public render() {
if (!this.state.hasError) {
return this.props.children;
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>Une erreur est survenue</Text>
<Text style={styles.subtitle}>
Relance la vue pour continuer la partie sans fermer l’application.
</Text>
<Pressable style={styles.retryButton} onPress={this.retry}>
<Text style={styles.retryLabel}>Réessayer</Text>
</Pressable>
</View>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
...presets.screen.container,
alignItems: 'center',
justifyContent: 'center',
padding: ui.space.md,
},
card: {
...presets.card.base,
width: '100%',
maxWidth: 460,
},
title: {
...presets.text.sectionTitle,
marginBottom: ui.space.xs,
},
subtitle: {
...presets.text.body,
marginBottom: ui.space.md,
},
retryButton: {
...presets.button.prominent,
...presets.button.tonePrimary,
},
retryLabel: {
...presets.text.button,
fontWeight: textWeight.bold,
},
});
+1 -22
View File
@@ -2,6 +2,7 @@ import { Asset } from 'expo-asset';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, LayoutChangeEvent, StyleSheet, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { parseDicePayload } from '../services/dicePayloadService';
import { ui } from '../styles/ui';
type Dice3DViewerProps = {
@@ -10,28 +11,6 @@ type Dice3DViewerProps = {
onReadyChange?: (ready: boolean) => void;
};
const parseDicePayload = (payload: unknown): [number, number] | null => {
if (!Array.isArray(payload) || payload.length < 2) {
return null;
}
const first = Number(payload[0]);
const second = Number(payload[1]);
if (
!Number.isInteger(first) ||
!Number.isInteger(second) ||
first < 1 ||
first > 6 ||
second < 1 ||
second > 6
) {
return null;
}
return [first, second];
};
export function Dice3DViewer({
rollSignal,
onRollResult,
+5
View File
@@ -0,0 +1,5 @@
export const MIN_PLAYERS = 2;
export const MAGIC_NUMBER_MIN = 11;
export const MAGIC_NUMBER_MAX = 66;
export const MAGIC_NUMBER_FORBIDDEN = 31;
+104
View File
@@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { rankEntries } from '../services/rankingService';
import type {
GamePhase,
GameSettings,
Player,
RoundSummary,
TurnResult,
} from '../store/gameStore';
type ProvisionalLoser = {
name: string;
score: number;
};
type ProvisionalLoserInput = {
phase: GamePhase;
roundSummary: RoundSummary | null;
players: Player[];
turns: TurnResult[];
settings: GameSettings;
currentScore: number | null;
currentRollCount: number;
currentPlayer: Player | undefined;
};
type PreviewTurn = Pick<TurnResult, 'playerId' | 'playerName' | 'score'>;
const createMagicMap = (players: Player[]): Record<string, number | null> => {
return players.reduce<Record<string, number | null>>((accumulator, player) => {
accumulator[player.id] = player.magicNumber;
return accumulator;
}, {});
};
export const computeProvisionalLoser = ({
phase,
roundSummary,
players,
turns,
settings,
currentScore,
currentRollCount,
currentPlayer,
}: ProvisionalLoserInput): ProvisionalLoser | null => {
if (phase === 'round-summary' && roundSummary) {
const loserStanding = roundSummary.standings[roundSummary.standings.length - 1];
if (!loserStanding) {
return null;
}
return {
name: loserStanding.playerName,
score: loserStanding.score,
};
}
if (phase !== 'playing') {
return null;
}
const previewTurns: PreviewTurn[] = turns.map((turn) => ({
playerId: turn.playerId,
playerName: turn.playerName,
score: turn.score,
}));
if (currentScore !== null && currentRollCount > 0 && currentPlayer) {
const hasCurrentPlayerAlready = previewTurns.some(
(turn) => turn.playerId === currentPlayer.id,
);
if (!hasCurrentPlayerAlready) {
previewTurns.push({
playerId: currentPlayer.id,
playerName: currentPlayer.name,
score: currentScore,
});
}
}
if (previewTurns.length === 0) {
return null;
}
const rankedPreview = rankEntries(previewTurns, {
enableMagicNumber: settings.enableMagicNumber,
magicNumberByPlayerId: createMagicMap(players),
});
const loserPreview = rankedPreview[rankedPreview.length - 1];
if (!loserPreview) {
return null;
}
return {
name: loserPreview.playerName,
score: loserPreview.score,
};
};
export const useProvisionalLoser = (input: ProvisionalLoserInput): ProvisionalLoser | null => {
return useMemo(() => computeProvisionalLoser(input), [input]);
};
+11 -126
View File
@@ -16,8 +16,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Dice3DViewer } from '../components/Dice3DViewer';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { RulesModal } from '../components/RulesModal';
import { useProvisionalLoser } from '../hooks/useProvisionalLoser';
import { RootStackParamList } from '../navigation/types';
import { RollSource, TurnResult, useGameStore } from '../store/gameStore';
import { RollSource, useGameStore } from '../store/gameStore';
import { presets, textWeight, ui } from '../styles/ui';
import { getRareToastMessage, isVeryRareRoll } from '../utils/probability';
@@ -37,52 +38,6 @@ const createRubTracker = (): RubTracker => ({
switches: 0,
});
const getDoubleRankTier = (score: number): number | null => {
switch (score) {
case 66:
return 7;
case 55:
return 6;
case 44:
return 5;
case 33:
return 4;
case 22:
return 3;
case 11:
return 2;
default:
return null;
}
};
const getPreviewRankTier = (
score: number,
enableMagicNumber: boolean,
playerMagicNumber: number | null,
): number => {
if (enableMagicNumber && playerMagicNumber !== null && score === playerMagicNumber) {
return 9;
}
if (score === 21) {
return 8;
}
const doubleTier = getDoubleRankTier(score);
if (doubleTier !== null) {
return doubleTier;
}
if (score === 31) {
return 0;
}
return 1;
};
type PreviewTurn = Pick<TurnResult, 'playerId' | 'playerName' | 'score'>;
export function GameScreen({ navigation }: GameScreenProps) {
const {
phase,
@@ -128,86 +83,16 @@ export function GameScreen({ navigation }: GameScreenProps) {
const canReRoll = !isTieBreak && awaitingDecision && currentRollCount < maxRolls && !pendingRoll;
const canStop = !isTieBreak && awaitingDecision && currentRollCount > 0 && !pendingRoll;
const provisionalLoser = useMemo<{ name: string; score: number } | null>(() => {
if (roundSummaryReady && roundSummary) {
const loserStanding = roundSummary.standings[roundSummary.standings.length - 1];
if (!loserStanding) {
return null;
}
return {
name: loserStanding.playerName,
score: loserStanding.score,
};
}
if (phase !== 'playing') {
return null;
}
const previewTurns: PreviewTurn[] = turns.map((turn) => ({
playerId: turn.playerId,
playerName: turn.playerName,
score: turn.score,
}));
if (currentScore !== null && currentRollCount > 0 && currentPlayer) {
const hasCurrentPlayerAlready = previewTurns.some(
(turn) => turn.playerId === currentPlayer.id,
);
if (!hasCurrentPlayerAlready) {
previewTurns.push({
playerId: currentPlayer.id,
playerName: currentPlayer.name,
score: currentScore,
});
}
}
if (previewTurns.length === 0) {
return null;
}
const rankedPreview = previewTurns
.map((turn) => {
const playerMagicNumber =
players.find((player) => player.id === turn.playerId)?.magicNumber ?? null;
const rankTier = getPreviewRankTier(
turn.score,
settings.enableMagicNumber,
playerMagicNumber,
);
const rankValue = rankTier === 0 ? -100 : turn.score;
return { ...turn, rankTier, rankValue };
})
.sort((left, right) => {
if (left.rankTier !== right.rankTier) {
return right.rankTier - left.rankTier;
}
return right.rankValue - left.rankValue;
});
const loserPreview = rankedPreview[rankedPreview.length - 1];
if (!loserPreview) {
return null;
}
return {
name: loserPreview.playerName,
score: loserPreview.score,
};
}, [
currentPlayer,
currentRollCount,
currentScore,
const provisionalLoser = useProvisionalLoser({
phase,
players,
roundSummary,
roundSummaryReady,
settings.enableMagicNumber,
players,
turns,
]);
settings,
currentScore,
currentRollCount,
currentPlayer,
});
const nextPlayerName = useMemo(() => {
if (!awaitingNextPlayer) {
@@ -479,7 +364,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
onPress={openRoundSummary}
>
<Text style={styles.actionLabel} numberOfLines={1} ellipsizeMode="tail">
Voir l'écran de fin du tour
Voir l’écran de fin du tour
</Text>
</Pressable>
) : awaitingNextPlayer ? (
@@ -508,7 +393,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
{canStop && (
<Pressable style={styles.actionButtonAlt} onPress={stopCurrentTurn}>
<Text style={styles.actionLabel}>S'arrêter</Text>
<Text style={styles.actionLabel}>S’arrêter</Text>
</Pressable>
)}
</>
+2 -9
View File
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { RootStackParamList } from '../navigation/types';
import { buildLeaderboardRanking } from '../services/leaderboardService';
import { useGameStore } from '../store';
import { presets, textWeight, ui } from '../styles/ui';
@@ -13,15 +14,7 @@ export function LeaderboardScreen({ navigation }: LeaderboardScreenProps) {
const resetAllTimePlayer = useGameStore((state) => state.resetAllTimePlayer);
const ranking = useMemo(() => {
return Object.entries(allTimeLeaderboard)
.map(([name, drinksConsumed]) => ({ name, drinksConsumed }))
.sort((left, right) => {
if (left.drinksConsumed !== right.drinksConsumed) {
return right.drinksConsumed - left.drinksConsumed;
}
return left.name.localeCompare(right.name, 'fr');
});
return buildLeaderboardRanking(allTimeLeaderboard);
}, [allTimeLeaderboard]);
const goBackToSetup = () => {
+2 -34
View File
@@ -13,28 +13,12 @@ import Animated, {
} from 'react-native-reanimated';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { RootStackParamList } from '../navigation/types';
import { buildPotFormula, formatDrink } from '../services/scoreService';
import { useGameStore } from '../store/gameStore';
import { effects, presets, textWeight, ui } from '../styles/ui';
type ScoreScreenProps = NativeStackScreenProps<RootStackParamList, 'Score'>;
const toRoundedDrink = (value: number): number => Math.round(value * 100) / 100;
const isIntegerLike = (value: number): boolean => {
return Math.abs(value - Math.round(value)) < 0.0000001;
};
const formatDrink = (value: number): string => {
const rounded = toRoundedDrink(value);
if (Number.isInteger(rounded)) {
return `${rounded}`;
}
return rounded.toFixed(2).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
};
const isFactorOne = (value: number): boolean => Math.abs(value - 1) < 0.0001;
export function ScoreScreen({ navigation }: ScoreScreenProps) {
const { phase, roundSummary, players, startNextRound, resetGame } = useGameStore();
const pulse = useSharedValue(1);
@@ -75,23 +59,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
}, [players]);
const potFormula = useMemo(() => {
if (!roundSummary || isFactorOne(roundSummary.appliedPotFactor)) {
return null;
}
const base = formatDrink(roundSummary.basePotValue);
const result = formatDrink(roundSummary.potValue);
if (roundSummary.appliedPotFactor < 1) {
const divisorValue = 1 / roundSummary.appliedPotFactor;
const divisor = formatDrink(divisorValue);
const isExactDivision = isIntegerLike(roundSummary.basePotValue / divisorValue);
const equalsSymbol = isExactDivision ? '=' : '≈';
return `${base} / ${divisor} ${equalsSymbol} ${result}`;
}
const multiplier = formatDrink(roundSummary.appliedPotFactor);
return `${base} x ${multiplier} = ${result}`;
return buildPotFormula(roundSummary);
}, [roundSummary]);
if (!roundSummary) {
+45 -123
View File
@@ -13,83 +13,23 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { RulesModal } from '../components/RulesModal';
import { MIN_PLAYERS } from '../constants/game';
import { RootStackParamList } from '../navigation/types';
import {
buildEndRoundPotFactorOptions,
END_ROUND_POT_FACTOR_CENTER_INDEX,
END_ROUND_POT_FACTOR_ITEM_WIDTH,
sanitizeNames,
type EndRoundPotFactorOption,
validateProfileInput,
validateStartGameInput,
} from '../services/setupService';
import { useGameStore } from '../store';
import type { GameSettings } from '../store';
import { effects, presets, textWeight, ui } from '../styles/ui';
type SetupScreenProps = NativeStackScreenProps<RootStackParamList, 'Setup'>;
const MIN_PLAYERS = 2;
type EndRoundPotFactorOption = {
id: string;
label: string;
value: number;
step: number;
};
const END_ROUND_POT_FACTOR_SIDE_ITEMS = 5000;
const END_ROUND_POT_FACTOR_CENTER_INDEX = END_ROUND_POT_FACTOR_SIDE_ITEMS;
const END_ROUND_POT_FACTOR_TOTAL_ITEMS = END_ROUND_POT_FACTOR_SIDE_ITEMS * 2 + 1;
const END_ROUND_POT_FACTOR_ITEM_WIDTH = 64;
const buildEndRoundPotFactorOption = (step: number): EndRoundPotFactorOption => {
if (step === 0) {
return {
id: 'x1',
label: 'x1',
value: 1,
step,
};
}
if (step > 0) {
const multiplier = step + 1;
return {
id: `x${multiplier}`,
label: `x${multiplier}`,
value: multiplier,
step,
};
}
const divisor = Math.abs(step) + 1;
return {
id: `d${divisor}`,
label: `/${divisor}`,
value: 1 / divisor,
step,
};
};
const sanitizeNames = (names: string[]): string[] => {
return names.map((name) => name.trim()).filter(Boolean);
};
const parseMagic = (raw: string): number | null => {
const trimmed = raw.trim();
if (!/^\d{2}$/.test(trimmed)) {
return null;
}
const value = Number.parseInt(trimmed, 10);
if (!Number.isInteger(value)) {
return null;
}
return value;
};
const isValidMagicNumber = (value: number): boolean => {
if (value < 11 || value > 66 || value === 31) {
return false;
}
const tens = Math.floor(value / 10);
const units = value % 10;
return tens >= units;
};
export function SetupScreen({ navigation }: SetupScreenProps) {
const setupGame = useGameStore((state) => state.setupGame);
const savedProfiles = useGameStore((state) => state.savedProfiles);
@@ -117,10 +57,7 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
const endRoundPotFactorListRef = useRef<FlatList<EndRoundPotFactorOption>>(null);
const endRoundPotFactorOptions = useMemo(
() =>
Array.from({ length: END_ROUND_POT_FACTOR_TOTAL_ITEMS }, (_, index) =>
buildEndRoundPotFactorOption(index - END_ROUND_POT_FACTOR_CENTER_INDEX),
),
() => buildEndRoundPotFactorOptions(),
[],
);
@@ -207,14 +144,14 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
};
const saveNewProfile = () => {
const cleanName = profileName.trim();
if (!cleanName) {
const profileValidation = validateProfileInput(profileName, profileMagicNumber);
if (!profileValidation.ok && profileValidation.reason === 'missing-name') {
Alert.alert('Profil invalide', 'Le nom du profil est obligatoire.');
return;
}
const parsedMagic = parseMagic(profileMagicNumber);
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
if (!profileValidation.ok && profileValidation.reason === 'invalid-magic') {
Alert.alert(
'Chiffre magique invalide',
'Le profil doit avoir un chiffre magique valide entre 11 et 66, avec dizaine ≥ unité, et différent de 31.',
@@ -222,7 +159,11 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
return;
}
saveProfile(cleanName, parsedMagic);
if (!profileValidation.ok) {
return;
}
saveProfile(profileValidation.cleanName, profileValidation.magicNumber);
setProfileName('');
setProfileMagicNumber('22');
};
@@ -233,56 +174,37 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
};
const startGame = () => {
const playerEntries = playerNames
.map((name, index) => ({
name: name.trim(),
magicInput: magicNumbersByPlayer[index] ?? '',
}))
.filter((entry) => entry.name.length > 0);
const startValidation = validateStartGameInput({
playerNames,
magicNumbersByPlayer,
enableMagicNumber,
minPlayers: MIN_PLAYERS,
});
const cleanNames = playerEntries.map((entry) => entry.name);
if (cleanNames.length < MIN_PLAYERS) {
if (!startValidation.ok && startValidation.reason === 'insufficient-players') {
Alert.alert('Joueurs insuffisants', 'Ajoute au moins 2 joueurs.');
return;
}
if (enableMagicNumber) {
const playersWithMissingMagic = playerEntries
.filter((entry) => entry.magicInput.trim().length === 0)
.map((entry) => entry.name);
if (playersWithMissingMagic.length > 0) {
const playersList = playersWithMissingMagic.join(', ');
Alert.alert(
'Chiffres magiques manquants',
`Renseigne le chiffre magique avant de démarrer pour: ${playersList}.`,
);
return;
}
if (!startValidation.ok && startValidation.reason === 'missing-magic') {
const playersList = (startValidation.players ?? []).join(', ');
Alert.alert(
'Chiffres magiques manquants',
`Renseigne le chiffre magique avant de démarrer pour: ${playersList}.`,
);
return;
}
const parsedMagicNumbers = playerEntries.map((entry) => {
if (!enableMagicNumber) {
return null;
}
if (!startValidation.ok && startValidation.reason === 'invalid-magic') {
Alert.alert(
'Chiffre magique invalide',
'Chaque joueur doit avoir 2 chiffres entre 11 et 66, avec dizaine ≥ unité, et différent de 31.',
);
return;
}
const parsedMagic = parseMagic(entry.magicInput);
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
return null;
}
return parsedMagic;
});
if (enableMagicNumber) {
const hasInvalidEntry = parsedMagicNumbers.some((value) => value === null);
if (hasInvalidEntry) {
Alert.alert(
'Chiffre magique invalide',
'Chaque joueur doit avoir 2 chiffres entre 11 et 66, avec dizaine ≥ unité, et différent de 31.',
);
return;
}
if (!startValidation.ok) {
return;
}
const settings: GameSettings = {
@@ -293,8 +215,8 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
};
setupGame({
playerNames: cleanNames,
magicNumbers: parsedMagicNumbers,
playerNames: startValidation.cleanNames,
magicNumbers: startValidation.parsedMagicNumbers,
settings,
});
+21
View File
@@ -0,0 +1,21 @@
export const parseDicePayload = (payload: unknown): [number, number] | null => {
if (!Array.isArray(payload) || payload.length < 2) {
return null;
}
const first = Number(payload[0]);
const second = Number(payload[1]);
if (
!Number.isInteger(first) ||
!Number.isInteger(second) ||
first < 1 ||
first > 6 ||
second < 1 ||
second > 6
) {
return null;
}
return [first, second];
};
+18
View File
@@ -0,0 +1,18 @@
export type LeaderboardEntry = {
name: string;
drinksConsumed: number;
};
export const buildLeaderboardRanking = (
allTimeLeaderboard: Record<string, number>,
): LeaderboardEntry[] => {
return Object.entries(allTimeLeaderboard)
.map(([name, drinksConsumed]) => ({ name, drinksConsumed }))
.sort((left, right) => {
if (left.drinksConsumed !== right.drinksConsumed) {
return right.drinksConsumed - left.drinksConsumed;
}
return left.name.localeCompare(right.name, 'fr');
});
};
+116
View File
@@ -0,0 +1,116 @@
type ScoreEntry = {
playerId: string;
score: number;
};
export type RankedEntry<T extends ScoreEntry> = T & {
rankTier: number;
rankValue: number;
};
export type RankingOptions = {
enableMagicNumber: boolean;
magicNumberByPlayerId: Record<string, number | null | undefined>;
};
export const getDoubleRankTier = (score: number): number | null => {
switch (score) {
case 66:
return 7;
case 55:
return 6;
case 44:
return 5;
case 33:
return 4;
case 22:
return 3;
case 11:
return 2;
default:
return null;
}
};
export const getRankTierForScore = (
score: number,
enableMagicNumber: boolean,
playerMagicNumber: number | null | undefined,
): number => {
if (enableMagicNumber && playerMagicNumber !== null && playerMagicNumber !== undefined) {
if (score === playerMagicNumber) {
return 9;
}
}
if (score === 21) {
return 8;
}
const doubleTier = getDoubleRankTier(score);
if (doubleTier !== null) {
return doubleTier;
}
if (score === 31) {
return 0;
}
return 1;
};
export const getRankValue = (score: number, tier: number): number => {
if (tier === 0) {
return -100;
}
return score;
};
export const compareRankedEntries = <T extends ScoreEntry>(
left: RankedEntry<T>,
right: RankedEntry<T>,
): number => {
if (left.rankTier !== right.rankTier) {
return right.rankTier - left.rankTier;
}
return right.rankValue - left.rankValue;
};
export const rankEntries = <T extends ScoreEntry>(
entries: T[],
options: RankingOptions,
): RankedEntry<T>[] => {
return entries
.map((entry) => {
const playerMagicNumber = options.magicNumberByPlayerId[entry.playerId] ?? null;
const rankTier = getRankTierForScore(
entry.score,
options.enableMagicNumber,
playerMagicNumber,
);
const rankValue = getRankValue(entry.score, rankTier);
return {
...entry,
rankTier,
rankValue,
};
})
.sort(compareRankedEntries);
};
export const getLowestRankedPlayerIds = (
standings: { playerId: string; rankTier: number; rankValue: number }[],
): string[] => {
if (standings.length === 0) {
return [];
}
const lowest = standings[standings.length - 1];
return standings
.filter((entry) => entry.rankTier === lowest.rankTier && entry.rankValue === lowest.rankValue)
.map((entry) => entry.playerId);
};
+43
View File
@@ -0,0 +1,43 @@
import type { RoundSummary } from '../store/gameStore';
export const toRoundedDrink = (value: number): number => {
return Math.round(value * 100) / 100;
};
export const isIntegerLike = (value: number): boolean => {
return Math.abs(value - Math.round(value)) < 0.0000001;
};
export const formatDrink = (value: number): string => {
const rounded = toRoundedDrink(value);
if (Number.isInteger(rounded)) {
return `${rounded}`;
}
return rounded.toFixed(2).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
};
export const isFactorOne = (value: number): boolean => {
return Math.abs(value - 1) < 0.0001;
};
export const buildPotFormula = (roundSummary: RoundSummary | null): string | null => {
if (!roundSummary || isFactorOne(roundSummary.appliedPotFactor)) {
return null;
}
const base = formatDrink(roundSummary.basePotValue);
const result = formatDrink(roundSummary.potValue);
if (roundSummary.appliedPotFactor < 1) {
const divisorValue = 1 / roundSummary.appliedPotFactor;
const divisor = formatDrink(divisorValue);
const isExactDivision = isIntegerLike(roundSummary.basePotValue / divisorValue);
const equalsSymbol = isExactDivision ? '=' : '≈';
return `${base} / ${divisor} ${equalsSymbol} ${result}`;
}
const multiplier = formatDrink(roundSummary.appliedPotFactor);
return `${base} x ${multiplier} = ${result}`;
};
+212
View File
@@ -0,0 +1,212 @@
import {
MAGIC_NUMBER_FORBIDDEN,
MAGIC_NUMBER_MAX,
MAGIC_NUMBER_MIN,
MIN_PLAYERS,
} from '../constants/game';
export type EndRoundPotFactorOption = {
id: string;
label: string;
value: number;
step: number;
};
export const END_ROUND_POT_FACTOR_SIDE_ITEMS = 5000;
export const END_ROUND_POT_FACTOR_CENTER_INDEX = END_ROUND_POT_FACTOR_SIDE_ITEMS;
export const END_ROUND_POT_FACTOR_TOTAL_ITEMS = END_ROUND_POT_FACTOR_SIDE_ITEMS * 2 + 1;
export const END_ROUND_POT_FACTOR_ITEM_WIDTH = 64;
export const buildEndRoundPotFactorOption = (step: number): EndRoundPotFactorOption => {
if (step === 0) {
return {
id: 'x1',
label: 'x1',
value: 1,
step,
};
}
if (step > 0) {
const multiplier = step + 1;
return {
id: `x${multiplier}`,
label: `x${multiplier}`,
value: multiplier,
step,
};
}
const divisor = Math.abs(step) + 1;
return {
id: `d${divisor}`,
label: `/${divisor}`,
value: 1 / divisor,
step,
};
};
export const buildEndRoundPotFactorOptions = (): EndRoundPotFactorOption[] => {
return Array.from({ length: END_ROUND_POT_FACTOR_TOTAL_ITEMS }, (_, index) =>
buildEndRoundPotFactorOption(index - END_ROUND_POT_FACTOR_CENTER_INDEX),
);
};
export const sanitizeNames = (names: string[]): string[] => {
return names.map((name) => name.trim()).filter(Boolean);
};
export const parseMagicNumber = (raw: string): number | null => {
const trimmed = raw.trim();
if (!/^\d{2}$/.test(trimmed)) {
return null;
}
const value = Number.parseInt(trimmed, 10);
if (!Number.isInteger(value)) {
return null;
}
return value;
};
export const isValidMagicNumber = (value: number): boolean => {
if (value < MAGIC_NUMBER_MIN || value > MAGIC_NUMBER_MAX || value === MAGIC_NUMBER_FORBIDDEN) {
return false;
}
const tens = Math.floor(value / 10);
const units = value % 10;
return tens >= units;
};
type StartGameValidationInput = {
playerNames: string[];
magicNumbersByPlayer: string[];
enableMagicNumber: boolean;
minPlayers?: number;
};
type StartGameValidationSuccess = {
ok: true;
cleanNames: string[];
parsedMagicNumbers: (number | null)[];
};
type StartGameValidationFailure = {
ok: false;
reason: 'insufficient-players' | 'missing-magic' | 'invalid-magic';
players?: string[];
};
export type StartGameValidationResult =
| StartGameValidationSuccess
| StartGameValidationFailure;
export const validateStartGameInput = ({
playerNames,
magicNumbersByPlayer,
enableMagicNumber,
minPlayers = MIN_PLAYERS,
}: StartGameValidationInput): StartGameValidationResult => {
const playerEntries = playerNames
.map((name, index) => ({
name: name.trim(),
magicInput: magicNumbersByPlayer[index] ?? '',
}))
.filter((entry) => entry.name.length > 0);
const cleanNames = playerEntries.map((entry) => entry.name);
if (cleanNames.length < minPlayers) {
return {
ok: false,
reason: 'insufficient-players',
};
}
if (enableMagicNumber) {
const playersWithMissingMagic = playerEntries
.filter((entry) => entry.magicInput.trim().length === 0)
.map((entry) => entry.name);
if (playersWithMissingMagic.length > 0) {
return {
ok: false,
reason: 'missing-magic',
players: playersWithMissingMagic,
};
}
}
const parsedMagicNumbers = playerEntries.map((entry) => {
if (!enableMagicNumber) {
return null;
}
const parsedMagic = parseMagicNumber(entry.magicInput);
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
return null;
}
return parsedMagic;
});
if (enableMagicNumber) {
const hasInvalidEntry = parsedMagicNumbers.some((value) => value === null);
if (hasInvalidEntry) {
return {
ok: false,
reason: 'invalid-magic',
};
}
}
return {
ok: true,
cleanNames,
parsedMagicNumbers,
};
};
type ProfileValidationSuccess = {
ok: true;
cleanName: string;
magicNumber: number;
};
type ProfileValidationFailure = {
ok: false;
reason: 'missing-name' | 'invalid-magic';
};
export type ProfileValidationResult = ProfileValidationSuccess | ProfileValidationFailure;
export const validateProfileInput = (
profileName: string,
rawMagicNumber: string,
): ProfileValidationResult => {
const cleanName = profileName.trim();
if (!cleanName) {
return {
ok: false,
reason: 'missing-name',
};
}
const parsedMagic = parseMagicNumber(rawMagicNumber);
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
return {
ok: false,
reason: 'invalid-magic',
};
}
return {
ok: true,
cleanName,
magicNumber: parsedMagic,
};
};
+15 -80
View File
@@ -1,6 +1,7 @@
import * as FileSystem from 'expo-file-system/legacy';
import { create } from 'zustand';
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware';
import { getLowestRankedPlayerIds, rankEntries } from '../services/rankingService';
import { getRollProbability } from '../utils/probability';
export type DicePair = [number, number];
@@ -104,7 +105,7 @@ export type SavedProfile = {
type SetupPayload = {
playerNames: string[];
magicNumbers: Array<number | null>;
magicNumbers: (number | null)[];
settings: GameSettings;
};
@@ -254,93 +255,27 @@ const isDoubleScore = (score: number): boolean => {
return tens === units;
};
const getDoubleRankTier = (score: number): number | null => {
switch (score) {
case 66:
return 7;
case 55:
return 6;
case 44:
return 5;
case 33:
return 4;
case 22:
return 3;
case 11:
return 2;
default:
return null;
}
};
const getRankTier = (
turn: TurnResult,
settings: GameSettings,
playerMagicNumber: number | null,
): number => {
if (
settings.enableMagicNumber &&
playerMagicNumber !== null &&
turn.score === playerMagicNumber
) {
return 9;
}
if (turn.score === 21) {
return 8;
}
const doubleTier = getDoubleRankTier(turn.score);
if (doubleTier !== null) {
return doubleTier;
}
if (turn.score === 31) {
return 0;
}
return 1;
};
const getRankValue = (turn: TurnResult, tier: number): number => {
if (tier === 0) {
return -100;
}
return turn.score;
};
const compareRankedTurns = (left: RankedTurn, right: RankedTurn): number => {
if (left.rankTier !== right.rankTier) {
return right.rankTier - left.rankTier;
}
return right.rankValue - left.rankValue;
};
const buildStandings = (
turns: TurnResult[],
players: Player[],
settings: GameSettings,
): RankedTurn[] => {
return turns
.map((turn) => {
const playerMagicNumber =
players.find((player) => player.id === turn.playerId)?.magicNumber ?? null;
const rankTier = getRankTier(turn, settings, playerMagicNumber);
const rankValue = getRankValue(turn, rankTier);
return { ...turn, rankTier, rankValue };
})
.sort(compareRankedTurns);
const magicNumberByPlayerId = players.reduce<Record<string, number | null>>(
(accumulator, player) => {
accumulator[player.id] = player.magicNumber;
return accumulator;
},
{},
);
return rankEntries(turns, {
enableMagicNumber: settings.enableMagicNumber,
magicNumberByPlayerId,
}) as RankedTurn[];
};
const getLowestTiePlayerIds = (standings: RankedTurn[]): string[] => {
if (standings.length === 0) {
return [];
}
const lowest = standings[standings.length - 1];
return standings
.filter((entry) => entry.rankTier === lowest.rankTier && entry.rankValue === lowest.rankValue)
.map((entry) => entry.playerId);
return getLowestRankedPlayerIds(standings);
};
const getInitialState = (): Omit<
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { Text } from 'react-native';
import { act, create } from 'react-test-renderer';
import { AppErrorBoundary } from '../../src/components/AppErrorBoundary';
const originalConsoleError = console.error;
beforeEach(() => {
console.error = () => undefined;
});
afterEach(() => {
console.error = originalConsoleError;
});
describe('AppErrorBoundary', () => {
it('renders children when no error happens', () => {
let tree = create(<></>);
act(() => {
tree = create(
<AppErrorBoundary>
<Text>OK</Text>
</AppErrorBoundary>,
);
});
expect(JSON.stringify(tree.toJSON())).toContain('OK');
});
it('derives error state from runtime exceptions', () => {
expect(AppErrorBoundary.getDerivedStateFromError()).toEqual({
hasError: true,
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, mock } from 'bun:test';
import { Pressable } from 'react-native';
import { act, create } from 'react-test-renderer';
import { PlayerAvatar } from '../../src/components/PlayerAvatar';
describe('PlayerAvatar', () => {
it('renders primary player details', () => {
let tree = create(<></>);
act(() => {
tree = create(
<PlayerAvatar
name="Alice"
drinks={3}
lastScore={54}
roundScore={54}
rollsUsed={2}
badgesText="21 (+10 pot)"
rank={1}
/>,
);
});
const snapshot = JSON.stringify(tree.toJSON());
expect(snapshot).toContain('Alice');
expect(snapshot).toContain('🍻 ');
expect(snapshot).toContain('gorgées');
expect(snapshot).toContain('🎯 ');
expect(snapshot).toContain('21 (+10 pot)');
});
it('calls reset callback when reset button is pressed', () => {
const onReset = mock(() => { });
let tree = create(<></>);
act(() => {
tree = create(
<PlayerAvatar
name="Alice"
drinks={0}
lastScore={null}
onReset={onReset}
showReset
/>,
);
});
const resetButton = tree.root.findByType(Pressable);
act(() => {
resetButton.props.onPress();
});
expect(onReset).toHaveBeenCalledTimes(1);
});
it('does not render reset button when disabled', () => {
let tree = create(<></>);
act(() => {
tree = create(
<PlayerAvatar
name="Alice"
drinks={0}
lastScore={null}
showReset={false}
onReset={() => undefined}
/>,
);
});
expect(tree.root.findAllByType(Pressable)).toHaveLength(0);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'bun:test';
import { act, create } from 'react-test-renderer';
import {
computeProvisionalLoser,
useProvisionalLoser,
} from '../../src/hooks/useProvisionalLoser';
import type { GameSettings, Player, RoundSummary, TurnResult } from '../../src/store/gameStore';
const settings: GameSettings = {
enableShake: true,
enableRub: true,
enableMagicNumber: false,
endRoundPotFactor: 1,
};
const players: Player[] = [
{
id: 'a',
name: 'Alice',
magicNumber: null,
drinksConsumed: 0,
lastScore: null,
lastDice: null,
},
{
id: 'b',
name: 'Bob',
magicNumber: null,
drinksConsumed: 0,
lastScore: null,
lastDice: null,
},
];
const turns: TurnResult[] = [
{
playerId: 'a',
playerName: 'Alice',
score: 54,
dice: [5, 4],
rollsUsed: 1,
probability: 2 / 36,
badges: [],
},
];
describe('useProvisionalLoser', () => {
it('computes provisional loser from preview turns in playing phase', () => {
const loser = computeProvisionalLoser({
phase: 'playing',
roundSummary: null,
players,
turns,
settings,
currentScore: 31,
currentRollCount: 1,
currentPlayer: players[1],
});
expect(loser).toEqual({ name: 'Bob', score: 31 });
});
it('prefers round summary standings when available', () => {
const roundSummary: RoundSummary = {
winnerName: 'Alice',
loserName: 'Bob',
basePotValue: 2,
potValue: 2,
appliedPotFactor: 1,
endedBy31: false,
tieBreakRounds: 0,
standings: [
{
...turns[0],
rankTier: 1,
rankValue: 54,
},
{
playerId: 'b',
playerName: 'Bob',
score: 31,
dice: [3, 1],
rollsUsed: 1,
probability: 2 / 36,
badges: [],
rankTier: 0,
rankValue: -100,
},
],
};
const loser = computeProvisionalLoser({
phase: 'round-summary',
roundSummary,
players,
turns,
settings,
currentScore: null,
currentRollCount: 0,
currentPlayer: undefined,
});
expect(loser).toEqual({ name: 'Bob', score: 31 });
});
it('works as a hook inside a component tree', () => {
let observed: { name: string; score: number } | null = null;
const Probe = () => {
observed = useProvisionalLoser({
phase: 'playing',
roundSummary: null,
players,
turns,
settings,
currentScore: 31,
currentRollCount: 1,
currentPlayer: players[1],
});
return null;
};
act(() => {
create(<Probe />);
});
expect(observed).not.toBeNull();
expect(observed).toMatchObject({ name: 'Bob', score: 31 });
});
});
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { mock } from 'bun:test';
const createPrimitive = (name: string) => {
const Primitive = ({ children, ...props }: Record<string, unknown>) => {
const resolvedChildren =
typeof children === 'function' ? (children as (state: { pressed: boolean }) => unknown)({ pressed: false }) : children;
return React.createElement(name, props, resolvedChildren as React.ReactNode);
};
Primitive.displayName = name;
return Primitive;
};
const View = createPrimitive('View');
const Text = createPrimitive('Text');
const ScrollView = createPrimitive('ScrollView');
const ActivityIndicator = createPrimitive('ActivityIndicator');
const Pressable = ({ children, ...props }: Record<string, unknown>) => {
const resolvedChildren =
typeof children === 'function' ? (children as (state: { pressed: boolean }) => unknown)({ pressed: false }) : children;
return React.createElement('Pressable', props, resolvedChildren as React.ReactNode);
};
const Modal = ({ visible, children, ...props }: Record<string, unknown>) => {
if (!visible) {
return null;
}
return React.createElement('Modal', props, children as React.ReactNode);
};
mock.module('react-native', () => ({
View,
Text,
Pressable,
ScrollView,
ActivityIndicator,
Modal,
StyleSheet: {
create: (styles: Record<string, unknown>) => styles,
flatten: (style: unknown) => style,
absoluteFillObject: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
},
},
Platform: {
OS: 'android',
select: (value: Record<string, unknown>) => value.android ?? value.default,
},
ToastAndroid: {
SHORT: 0,
LONG: 1,
show: () => undefined,
},
}));
mock.module('react-native-safe-area-context', () => ({
SafeAreaView: createPrimitive('SafeAreaView'),
SafeAreaProvider: createPrimitive('SafeAreaProvider'),
}));
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'bun:test';
import { parseDicePayload } from '../../src/services/dicePayloadService';
describe('dicePayloadService', () => {
it('accepts valid dice payload', () => {
expect(parseDicePayload([1, 6])).toEqual([1, 6]);
expect(parseDicePayload(['2', 3])).toEqual([2, 3]);
});
it('rejects invalid payloads', () => {
expect(parseDicePayload([])).toBeNull();
expect(parseDicePayload([1])).toBeNull();
expect(parseDicePayload([0, 6])).toBeNull();
expect(parseDicePayload([1, 7])).toBeNull();
expect(parseDicePayload([1.4, 2])).toBeNull();
expect(parseDicePayload('1,2')).toBeNull();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'bun:test';
import { buildLeaderboardRanking } from '../../src/services/leaderboardService';
describe('leaderboardService', () => {
it('sorts by drinks descending then name ascending', () => {
const ranking = buildLeaderboardRanking({
Bob: 6,
Alice: 6,
Charlie: 2,
});
expect(ranking).toEqual([
{ name: 'Alice', drinksConsumed: 6 },
{ name: 'Bob', drinksConsumed: 6 },
{ name: 'Charlie', drinksConsumed: 2 },
]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'bun:test';
import {
getDoubleRankTier,
getLowestRankedPlayerIds,
getRankTierForScore,
rankEntries,
} from '../../src/services/rankingService';
describe('rankingService', () => {
it('maps double scores to their rank tier', () => {
expect(getDoubleRankTier(66)).toBe(7);
expect(getDoubleRankTier(55)).toBe(6);
expect(getDoubleRankTier(44)).toBe(5);
expect(getDoubleRankTier(11)).toBe(2);
expect(getDoubleRankTier(54)).toBeNull();
});
it('applies score priorities including magic number and 31', () => {
expect(getRankTierForScore(54, true, 54)).toBe(9);
expect(getRankTierForScore(21, false, null)).toBe(8);
expect(getRankTierForScore(66, false, null)).toBe(7);
expect(getRankTierForScore(31, false, null)).toBe(0);
expect(getRankTierForScore(54, false, null)).toBe(1);
});
it('sorts ranked entries by tier then value descending', () => {
const standings = rankEntries(
[
{ playerId: 'a', score: 31 },
{ playerId: 'b', score: 54 },
{ playerId: 'c', score: 21 },
{ playerId: 'd', score: 66 },
],
{
enableMagicNumber: true,
magicNumberByPlayerId: {
a: null,
b: 54,
c: null,
d: null,
},
},
);
expect(standings.map((entry) => entry.playerId)).toEqual(['b', 'c', 'd', 'a']);
});
it('returns all tied lowest player ids', () => {
const lowest = getLowestRankedPlayerIds([
{ playerId: 'winner', rankTier: 8, rankValue: 21 },
{ playerId: 'tie-a', rankTier: 1, rankValue: 41 },
{ playerId: 'tie-b', rankTier: 1, rankValue: 41 },
]);
expect(lowest).toEqual(['tie-a', 'tie-b']);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'bun:test';
import { buildPotFormula, formatDrink, isFactorOne } from '../../src/services/scoreService';
describe('scoreService', () => {
it('formats drink values with trimming', () => {
expect(formatDrink(2)).toBe('2');
expect(formatDrink(2.5)).toBe('2.5');
expect(formatDrink(2.333)).toBe('2.33');
});
it('detects factor equal to one with tolerance', () => {
expect(isFactorOne(1)).toBe(true);
expect(isFactorOne(1.00001)).toBe(true);
expect(isFactorOne(1.01)).toBe(false);
});
it('returns null formula when factor is one or summary is absent', () => {
expect(buildPotFormula(null)).toBeNull();
expect(
buildPotFormula({
winnerName: 'Alice',
loserName: 'Bob',
basePotValue: 5,
potValue: 5,
appliedPotFactor: 1,
endedBy31: false,
tieBreakRounds: 0,
standings: [],
}),
).toBeNull();
});
it('builds multiplication formula when factor is greater than one', () => {
expect(
buildPotFormula({
winnerName: 'Alice',
loserName: 'Bob',
basePotValue: 5,
potValue: 10,
appliedPotFactor: 2,
endedBy31: false,
tieBreakRounds: 0,
standings: [],
}),
).toBe('5 x 2 = 10');
});
it('builds division formula with exact and approximate symbols', () => {
expect(
buildPotFormula({
winnerName: 'Alice',
loserName: 'Bob',
basePotValue: 6,
potValue: 2,
appliedPotFactor: 1 / 3,
endedBy31: false,
tieBreakRounds: 0,
standings: [],
}),
).toBe('6 / 3 = 2');
expect(
buildPotFormula({
winnerName: 'Alice',
loserName: 'Bob',
basePotValue: 5,
potValue: 1.67,
appliedPotFactor: 1 / 3,
endedBy31: false,
tieBreakRounds: 0,
standings: [],
}),
).toBe('5 / 3 ≈ 1.67');
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'bun:test';
import { MIN_PLAYERS } from '../../src/constants/game';
import {
buildEndRoundPotFactorOption,
isValidMagicNumber,
parseMagicNumber,
sanitizeNames,
validateProfileInput,
validateStartGameInput,
} from '../../src/services/setupService';
describe('setupService', () => {
it('builds end round pot factor options correctly', () => {
expect(buildEndRoundPotFactorOption(0)).toEqual({
id: 'x1',
label: 'x1',
value: 1,
step: 0,
});
expect(buildEndRoundPotFactorOption(2)).toEqual({
id: 'x3',
label: 'x3',
value: 3,
step: 2,
});
expect(buildEndRoundPotFactorOption(-1)).toEqual({
id: 'd2',
label: '/2',
value: 0.5,
step: -1,
});
});
it('sanitizes player names', () => {
expect(sanitizeNames([' Alice ', '', ' Bob ', ' '])).toEqual(['Alice', 'Bob']);
});
it('parses and validates magic numbers', () => {
expect(parseMagicNumber('22')).toBe(22);
expect(parseMagicNumber('2')).toBeNull();
expect(parseMagicNumber('ab')).toBeNull();
expect(isValidMagicNumber(22)).toBe(true);
expect(isValidMagicNumber(31)).toBe(false);
expect(isValidMagicNumber(12)).toBe(false);
expect(isValidMagicNumber(70)).toBe(false);
});
it('validates profile input', () => {
expect(validateProfileInput(' ', '22')).toEqual({ ok: false, reason: 'missing-name' });
expect(validateProfileInput('Alice', '31')).toEqual({ ok: false, reason: 'invalid-magic' });
expect(validateProfileInput('Alice', '66')).toEqual({
ok: true,
cleanName: 'Alice',
magicNumber: 66,
});
});
it('rejects start when not enough players', () => {
const result = validateStartGameInput({
playerNames: ['Alice', ' '],
magicNumbersByPlayer: ['', ''],
enableMagicNumber: false,
minPlayers: MIN_PLAYERS,
});
expect(result).toEqual({ ok: false, reason: 'insufficient-players' });
});
it('rejects start when magic numbers are missing', () => {
const result = validateStartGameInput({
playerNames: ['Alice', 'Bob'],
magicNumbersByPlayer: ['', '22'],
enableMagicNumber: true,
});
expect(result).toEqual({
ok: false,
reason: 'missing-magic',
players: ['Alice'],
});
});
it('rejects start when at least one magic number is invalid', () => {
const result = validateStartGameInput({
playerNames: ['Alice', 'Bob'],
magicNumbersByPlayer: ['66', '31'],
enableMagicNumber: true,
});
expect(result).toEqual({ ok: false, reason: 'invalid-magic' });
});
it('accepts valid start payload with enabled magic numbers', () => {
const result = validateStartGameInput({
playerNames: ['Alice', ' Bob '],
magicNumbersByPlayer: ['66', '22'],
enableMagicNumber: true,
});
expect(result).toEqual({
ok: true,
cleanNames: ['Alice', 'Bob'],
parsedMagicNumbers: [66, 22],
});
});
it('accepts valid start payload with disabled magic numbers', () => {
const result = validateStartGameInput({
playerNames: ['Alice', 'Bob'],
magicNumbersByPlayer: ['66', '22'],
enableMagicNumber: false,
});
expect(result).toEqual({
ok: true,
cleanNames: ['Alice', 'Bob'],
parsedMagicNumbers: [null, null],
});
});
});
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it } from 'bun:test';
import {
getRareToastMessage,
getRollProbability,
isVeryRareRoll,
} from '../../src/utils/probability';
const originalRandom = Math.random;
afterEach(() => {
Math.random = originalRandom;
});
describe('probability utils', () => {
it('returns the correct roll probability', () => {
expect(getRollProbability([1, 1])).toBe(1 / 36);
expect(getRollProbability([6, 5])).toBe(2 / 36);
});
it('identifies very rare rolls at threshold', () => {
expect(isVeryRareRoll(1 / 36)).toBe(true);
expect(isVeryRareRoll(2 / 36)).toBe(false);
});
it('formats a rare toast message with percentage', () => {
Math.random = () => 0;
const message = getRareToastMessage(1 / 36);
expect(message).toContain('2.8%');
expect(message).toContain('chance');
});
});
+8 -5
View File
@@ -1,6 +1,9 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": [
"bun-types"
]
}
}