From 1b0caabe591eeeab86b7f45bf3d29b2bab916575 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 01:57:47 +0000 Subject: [PATCH] feat(game): Pause-Vorschlag alle 5 Level (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach jedem 5. abgeschlossenen Level (5, 10, 15 — nicht beim letzten Level 20, dort greift bereits das bestehende "Alle Level geschafft"- Modal) erscheint statt des direkten Übergangs zum nächsten Level ein Pause-Dialog mit zwei Optionen: - "Hauptmenü" (primär, hervorgehoben) — navigiert zur Startseite - "Weiter" (sekundär, unauffällig) — setzt die Runde fort Neuer i18n-Key `game.pause.{title,message,mainMenu,continue}` in allen 7 Sprachen. Tests decken beide Pfade sowie das Ausbleiben des Dialogs beim letzten Level ab; die Modal-Interaktion zusätzlich per Web-Build/Headless-Browser end-to-end verifiziert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MKKsCtexSeDGBEmdXrCttP --- app/game.tsx | 100 ++++++++++++++++ components/__tests__/GameScreen.test.tsx | 141 +++++++++++++++++++++-- locales/de/translations.json | 6 + locales/en/translations.json | 6 + locales/es/translations.json | 6 + locales/fr/translations.json | 6 + locales/it/translations.json | 6 + locales/nl/translations.json | 6 + locales/pl/translations.json | 6 + 9 files changed, 276 insertions(+), 7 deletions(-) diff --git a/app/game.tsx b/app/game.tsx index d6aefbc..ba9d3c5 100644 --- a/app/game.tsx +++ b/app/game.tsx @@ -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(); @@ -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('full'); const [unlockedBadge, setUnlockedBadge] = useState(null); @@ -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(); @@ -351,6 +370,43 @@ export default function GameScreen() { + {/* Pause-Vorschlag alle LEVELS_PER_PAUSE Level (Issue #337) */} + + + + 🌤️ + + {t('game.pause.title')} + + + {t('game.pause.message', { count: levelNumber })} + + + {t('game.pause.mainMenu')} + + + + {t('game.pause.continue')} + + + + + + {/* Phase Content — crossfade on transition */} {visiblePhase === 'memorize' && ( @@ -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, + }, }); diff --git a/components/__tests__/GameScreen.test.tsx b/components/__tests__/GameScreen.test.tsx index 6a9fc2c..dd6dea9 100644 --- a/components/__tests__/GameScreen.test.tsx +++ b/components/__tests__/GameScreen.test.tsx @@ -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: () => ({}), })); @@ -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 ( + + next-level + + ); + }; +}); + jest.mock('../../components/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: any) => { const { View } = require('react-native'); @@ -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, @@ -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'; @@ -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(); await act(async () => {}); @@ -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(); + 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(); + 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(); + 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(); + 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'); + }); + }); }); diff --git a/locales/de/translations.json b/locales/de/translations.json index e270d4d..fd3cda6 100644 --- a/locales/de/translations.json +++ b/locales/de/translations.json @@ -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": { diff --git a/locales/en/translations.json b/locales/en/translations.json index bca46fd..dada32b 100644 --- a/locales/en/translations.json +++ b/locales/en/translations.json @@ -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": { diff --git a/locales/es/translations.json b/locales/es/translations.json index 9a54fcd..c6a4e03 100644 --- a/locales/es/translations.json +++ b/locales/es/translations.json @@ -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": { diff --git a/locales/fr/translations.json b/locales/fr/translations.json index d438906..d8097d9 100644 --- a/locales/fr/translations.json +++ b/locales/fr/translations.json @@ -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": { diff --git a/locales/it/translations.json b/locales/it/translations.json index dd2b7cb..78aa204 100644 --- a/locales/it/translations.json +++ b/locales/it/translations.json @@ -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": { diff --git a/locales/nl/translations.json b/locales/nl/translations.json index c2417b3..2d0e352 100644 --- a/locales/nl/translations.json +++ b/locales/nl/translations.json @@ -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": { diff --git a/locales/pl/translations.json b/locales/pl/translations.json index d3de15d..8f6b45f 100644 --- a/locales/pl/translations.json +++ b/locales/pl/translations.json @@ -107,6 +107,12 @@ "playAgain": "Zacznij od nowa", "replay": "▶ Powtórz", "replayStop": "⏹ Zatrzymaj" + }, + "pause": { + "title": "Chwila przerwy?", + "message": "Narysowałeś już {{count}} obrazków! Czas na krótką przerwę?", + "mainMenu": "Menu główne", + "continue": "Dalej" } }, "settings": {