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
100 changes: 100 additions & 0 deletions app/game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import { useReduceMotion } from '../utils/useReduceMotion';
import type { GamePhase, GameVariant } from '../types';
import type { ConfettiIntensity } from '@components/ConfettiBurst';

// Nach jeweils so vielen abgeschlossenen Leveln wird eine Pause vorgeschlagen (Issue #337).
const LEVELS_PER_PAUSE = 5;

export default function GameScreen() {
const { t } = useTranslation();
const router = useRouter();
Expand All @@ -50,6 +53,7 @@ export default function GameScreen() {
const [showHintModal, setShowHintModal] = useState(false);
const [hasUsedHint, setHasUsedHint] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const [showPauseModal, setShowPauseModal] = useState(false);
const [celebrationEnabled, setCelebrationEnabled] = useState(true);
const [confettiIntensity, setConfettiIntensity] = useState<ConfettiIntensity>('full');
const [unlockedBadge, setUnlockedBadge] = useState<AchievementDef | null>(null);
Expand Down Expand Up @@ -128,10 +132,25 @@ export default function GameScreen() {
};

const handleStartNextLevel = () => {
if (levelNumber % LEVELS_PER_PAUSE === 0 && levelNumber < totalLevels) {
setShowPauseModal(true);
return;
}
setHasUsedHint(false);
startNextLevel();
};

const handlePauseContinue = () => {
setShowPauseModal(false);
setHasUsedHint(false);
startNextLevel();
};

const handlePauseGoToMainMenu = () => {
setShowPauseModal(false);
router.replace('/');
};

const handleRestartFromLevel1 = () => {
setHasUsedHint(false);
restartFromLevel1();
Expand Down Expand Up @@ -351,6 +370,43 @@ export default function GameScreen() {
</TouchableOpacity>
</Modal>

{/* Pause-Vorschlag alle LEVELS_PER_PAUSE Level (Issue #337) */}
<Modal
visible={showPauseModal}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={handlePauseContinue}
>
<View style={[styles.modalOverlay, { backgroundColor: colors.modalOverlay }]}>
<View style={[styles.pauseModal, { backgroundColor: colors.background }]}>
<Text style={styles.pauseEmoji}>🌤️</Text>
<Text style={[styles.pauseTitle, { color: colors.text.primary }]}>
{t('game.pause.title')}
</Text>
<Text style={[styles.pauseMessage, { color: colors.text.secondary }]}>
{t('game.pause.message', { count: levelNumber })}
</Text>
<TouchableOpacity
style={[styles.pausePrimaryButton, { backgroundColor: colors.primary }]}
onPress={handlePauseGoToMainMenu}
accessibilityRole="button"
>
<Text style={styles.pausePrimaryButtonText}>{t('game.pause.mainMenu')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.pauseSecondaryButton}
onPress={handlePauseContinue}
accessibilityRole="button"
>
<Text style={[styles.pauseSecondaryButtonText, { color: colors.text.secondary }]}>
{t('game.pause.continue')}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>

{/* Phase Content — crossfade on transition */}
<Animated.View style={phaseAnimStyle}>
{visiblePhase === 'memorize' && (
Expand Down Expand Up @@ -543,4 +599,48 @@ const styles = StyleSheet.create({
closeText: {
fontSize: 24,
},
pauseModal: {
borderRadius: BorderRadius.xxl,
padding: Spacing.lg,
width: '90%',
maxWidth: 360,
alignItems: 'center',
...Colors.shadow.large,
},
pauseEmoji: {
fontSize: 48,
marginBottom: Spacing.sm,
},
pauseTitle: {
fontSize: FontSize.lg,
fontWeight: FontWeight.bold,
textAlign: 'center',
marginBottom: Spacing.xs,
},
pauseMessage: {
fontSize: FontSize.sm,
textAlign: 'center',
marginBottom: Spacing.lg,
},
pausePrimaryButton: {
width: '100%',
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
marginBottom: Spacing.sm,
},
pausePrimaryButtonText: {
color: '#ffffff',
fontSize: FontSize.md,
fontWeight: FontWeight.bold,
},
pauseSecondaryButton: {
width: '100%',
paddingVertical: Spacing.sm,
alignItems: 'center',
},
pauseSecondaryButtonText: {
fontSize: FontSize.sm,
fontWeight: FontWeight.medium,
},
});
141 changes: 134 additions & 7 deletions components/__tests__/GameScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,52 @@
import React from 'react';
import { render, act } from '@testing-library/react-native';
import { render, act, fireEvent } from '@testing-library/react-native';
import { TouchableOpacity } from 'react-native';

// Der echte react-native Modal lässt sich unter react-test-renderer/jsdom nicht
// sichtbar mounten (parentInstance.children.indexOf) — gleiches Muster wie in
// OnboardingModal.test.tsx. Nur Modal wird zu einem Passthrough.
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
const ReactLocal = require('react');
return new Proxy(RN, {
get(target, prop) {
if (prop === 'Modal') {
return ({ children, visible = true }: any) =>
visible ? ReactLocal.createElement(target.View, null, children) : null;
}
return target[prop as keyof typeof target];
},
});
});

// testID maps to data-testid on the react-native-web host element, not the
// React Test Instance's testID prop — getByTestId can't find it (see
// CLAUDE.md testing notes). Filter TouchableOpacity instances by their
// testID prop instead.
const pressByTestId = (UNSAFE_getAllByType: (type: any) => any[], testID: string) => {
const match = UNSAFE_getAllByType(TouchableOpacity).find((n: any) => n.props.testID === testID);
if (!match) throw new Error(`No TouchableOpacity with testID "${testID}" found`);
fireEvent.press(match);
};

// getByText also can't reliably match text under the mocked Modal/react-native-web
// stack here — find the Text instance by its exact children, then walk up to the
// nearest pressable ancestor (mirrors the testID workaround above).
const pressByLabel = (UNSAFE_getAllByType: (type: any) => any[], label: string) => {
const { Text } = require('react-native');
const textNode = UNSAFE_getAllByType(Text).find((n: any) => n.props.children === label);
if (!textNode) throw new Error(`No Text with children "${label}" found`);
let node = textNode.parent;
while (node && typeof node.props?.onPress !== 'function') {
node = node.parent;
}
if (!node) throw new Error(`No pressable ancestor found for text "${label}"`);
fireEvent.press(node);
};

const mockRouterReplace = jest.fn();
jest.mock('expo-router', () => ({
useRouter: () => ({ back: jest.fn() }),
useRouter: () => ({ back: jest.fn(), replace: mockRouterReplace }),
useLocalSearchParams: () => ({}),
}));

Expand Down Expand Up @@ -84,6 +128,17 @@ jest.mock('../../components/SettingsModal', () => {
};
});

jest.mock('../../components/game/ResultPhase', () => {
const { TouchableOpacity, Text } = require('react-native');
return function ResultPhase(props: any) {
return (
<TouchableOpacity testID="mock-next-level" onPress={props.onNextLevel}>
<Text>next-level</Text>
</TouchableOpacity>
);
};
});

jest.mock('../../components/ErrorBoundary', () => ({
ErrorBoundary: ({ children }: any) => {
const { View } = require('react-native');
Expand Down Expand Up @@ -164,9 +219,12 @@ jest.mock('../../services/StorageManager', () => ({
},
}));

jest.mock('../../services/useGamePhase', () => ({
useGamePhase: () => ({
phase: 'memorize',
const mockStartNextLevel = jest.fn();
function createGamePhaseMock(
overrides: { phase?: 'memorize' | 'draw' | 'result'; levelNumber?: number } = {},
) {
return {
phase: 'memorize' as 'memorize' | 'draw' | 'result',
setPhase: jest.fn(),
levelNumber: 1,
currentImage: null,
Expand All @@ -182,9 +240,14 @@ jest.mock('../../services/useGamePhase', () => ({
saveToGallery: jest.fn(),
startReplay: jest.fn(),
restartCurrentLevel: jest.fn(),
startNextLevel: jest.fn(),
startNextLevel: mockStartNextLevel,
restartFromLevel1: jest.fn(),
}),
...overrides,
};
}
const mockUseGamePhase = jest.fn(() => createGamePhaseMock());
jest.mock('../../services/useGamePhase', () => ({
useGamePhase: () => mockUseGamePhase(),
}));

import GameScreen from '../../app/game';
Expand All @@ -197,6 +260,12 @@ const getAllTexts = (getAllByType: (type: any) => any[]) => {
};

describe('GameScreen', () => {
beforeEach(() => {
mockStartNextLevel.mockClear();
mockRouterReplace.mockClear();
mockUseGamePhase.mockClear();
});

it('renders the level header without the app name', async () => {
const { UNSAFE_getAllByType } = render(<GameScreen />);
await act(async () => {});
Expand All @@ -206,4 +275,62 @@ describe('GameScreen', () => {
// Header shows level info
expect(texts.some((t: any) => typeof t === 'string' && t.startsWith('Level '))).toBe(true);
});

// getTotalLevels() is mocked to 10 above.
describe('pause prompt every 5 levels (Issue #337)', () => {
it('shows the pause modal instead of advancing after level 5', async () => {
mockUseGamePhase.mockReturnValue(createGamePhaseMock({ phase: 'result', levelNumber: 5 }));

const { UNSAFE_getAllByType } = render(<GameScreen />);
await act(async () => {});

pressByTestId(UNSAFE_getAllByType, 'mock-next-level');
await act(async () => {});

expect(mockStartNextLevel).not.toHaveBeenCalled();
expect(getAllTexts(UNSAFE_getAllByType)).toContain('game.pause.title');
});

it('navigates to the main menu when "Hauptmenü" is chosen', async () => {
mockUseGamePhase.mockReturnValue(createGamePhaseMock({ phase: 'result', levelNumber: 5 }));

const { UNSAFE_getAllByType } = render(<GameScreen />);
await act(async () => {});
pressByTestId(UNSAFE_getAllByType, 'mock-next-level');
await act(async () => {});

pressByLabel(UNSAFE_getAllByType, 'game.pause.mainMenu');
await act(async () => {});

expect(mockRouterReplace).toHaveBeenCalledWith('/');
expect(mockStartNextLevel).not.toHaveBeenCalled();
});

it('advances to the next level when "Weiter" is chosen', async () => {
mockUseGamePhase.mockReturnValue(createGamePhaseMock({ phase: 'result', levelNumber: 5 }));

const { UNSAFE_getAllByType } = render(<GameScreen />);
await act(async () => {});
pressByTestId(UNSAFE_getAllByType, 'mock-next-level');
await act(async () => {});

pressByLabel(UNSAFE_getAllByType, 'game.pause.continue');
await act(async () => {});

expect(mockStartNextLevel).toHaveBeenCalledTimes(1);
expect(mockRouterReplace).not.toHaveBeenCalled();
});

it('does not show the pause modal on the last level (no pause after the final level)', async () => {
mockUseGamePhase.mockReturnValue(createGamePhaseMock({ phase: 'result', levelNumber: 10 }));

const { UNSAFE_getAllByType } = render(<GameScreen />);
await act(async () => {});
pressByTestId(UNSAFE_getAllByType, 'mock-next-level');
await act(async () => {});

expect(mockStartNextLevel).toHaveBeenCalledTimes(1);
expect(getAllTexts(UNSAFE_getAllByType)).not.toContain('game.pause.title');
});
});
});
6 changes: 6 additions & 0 deletions locales/de/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"playAgain": "Von vorne",
"replay": "▶ Abspielen",
"replayStop": "⏹ Stopp"
},
"pause": {
"title": "Kurze Pause?",
"message": "Du hast schon {{count}} Bilder gemalt! Zeit für eine kleine Pause?",
"mainMenu": "Hauptmenü",
"continue": "Weiter"
}
},
"settings": {
Expand Down
6 changes: 6 additions & 0 deletions locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"playAgain": "Restart",
"replay": "▶ Replay",
"replayStop": "⏹ Stop"
},
"pause": {
"title": "Time for a break?",
"message": "You've drawn {{count}} pictures already! Time for a short break?",
"mainMenu": "Main Menu",
"continue": "Continue"
}
},
"settings": {
Expand Down
6 changes: 6 additions & 0 deletions locales/es/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"playAgain": "Reiniciar",
"replay": "▶ Repetir",
"replayStop": "⏹ Parar"
},
"pause": {
"title": "¿Una pequeña pausa?",
"message": "¡Ya has dibujado {{count}} imágenes! ¿Qué tal un pequeño descanso?",
"mainMenu": "Menú principal",
"continue": "Seguir"
}
},
"settings": {
Expand Down
6 changes: 6 additions & 0 deletions locales/fr/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"playAgain": "Recommencer",
"replay": "▶ Rejouer",
"replayStop": "⏹ Arrêter"
},
"pause": {
"title": "Une petite pause ?",
"message": "Tu as déjà dessiné {{count}} images ! Envie d’une petite pause ?",
"mainMenu": "Menu principal",
"continue": "Continuer"
}
},
"settings": {
Expand Down
6 changes: 6 additions & 0 deletions locales/it/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"playAgain": "Ricomincia",
"replay": "▶ Riproduci",
"replayStop": "⏹ Ferma"
},
"pause": {
"title": "Una piccola pausa?",
"message": "Hai già disegnato {{count}} immagini! Che ne dici di una piccola pausa?",
"mainMenu": "Menu principale",
"continue": "Continua"
}
},
"settings": {
Expand Down
6 changes: 6 additions & 0 deletions locales/nl/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"playAgain": "Opnieuw beginnen",
"replay": "▶ Herhalen",
"replayStop": "⏹ Stoppen"
},
"pause": {
"title": "Even pauzeren?",
"message": "Je hebt al {{count}} tekeningen gemaakt! Tijd voor een korte pauze?",
"mainMenu": "Hoofdmenu",
"continue": "Doorgaan"
}
},
"settings": {
Expand Down
Loading
Loading