|
|
|
|
@ -63,18 +63,17 @@ issues:
|
|
|
|
|
// .eslintrc.json
|
|
|
|
|
{
|
|
|
|
|
"extends": [
|
|
|
|
|
"@react-native",
|
|
|
|
|
"plugin:solid/typescript",
|
|
|
|
|
"@typescript-eslint/recommended",
|
|
|
|
|
"prettier"
|
|
|
|
|
],
|
|
|
|
|
"parser": "@typescript-eslint/parser",
|
|
|
|
|
"plugins": ["@typescript-eslint", "react-hooks"],
|
|
|
|
|
"plugins": ["@typescript-eslint", "solid"],
|
|
|
|
|
"rules": {
|
|
|
|
|
"@typescript-eslint/explicit-function-return-type": "error",
|
|
|
|
|
"@typescript-eslint/no-unused-vars": "error",
|
|
|
|
|
"@typescript-eslint/prefer-const": "error",
|
|
|
|
|
"react-hooks/rules-of-hooks": "error",
|
|
|
|
|
"react-hooks/exhaustive-deps": "warn",
|
|
|
|
|
"solid/reactivity": "warn",
|
|
|
|
|
"prefer-const": "error",
|
|
|
|
|
"no-var": "error",
|
|
|
|
|
"object-shorthand": "error",
|
|
|
|
|
@ -129,14 +128,14 @@ type ScoreCalculator interface {}
|
|
|
|
|
// Files: kebab-case
|
|
|
|
|
game-card.tsx
|
|
|
|
|
user-service.ts
|
|
|
|
|
auth-context.tsx
|
|
|
|
|
auth-store.ts
|
|
|
|
|
|
|
|
|
|
// Components: PascalCase
|
|
|
|
|
export const GameCard: React.FC<GameCardProps> = () => {}
|
|
|
|
|
export const LeaderboardList: React.FC = () => {}
|
|
|
|
|
export const GameCard: Component<GameCardProps> = () => {}
|
|
|
|
|
export const LeaderboardList: Component = () => {}
|
|
|
|
|
|
|
|
|
|
// Hooks: camelCase with "use" prefix
|
|
|
|
|
export const useGameSession = () => {}
|
|
|
|
|
// Composables/stores: camelCase with "create"/"use" prefix
|
|
|
|
|
export const createGameSessionStore = () => {}
|
|
|
|
|
export const useAuthentication = () => {}
|
|
|
|
|
|
|
|
|
|
// Functions: camelCase
|
|
|
|
|
@ -608,24 +607,21 @@ func (s *GameService) calculateTimeRemaining(startTime time.Time) int {
|
|
|
|
|
|
|
|
|
|
## TypeScript Development Standards
|
|
|
|
|
|
|
|
|
|
### React Component Patterns
|
|
|
|
|
### SolidJS Component Patterns
|
|
|
|
|
|
|
|
|
|
#### Component Structure
|
|
|
|
|
```typescript
|
|
|
|
|
// Component with proper TypeScript types and patterns
|
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
|
|
|
// Component with proper TypeScript types and SolidJS patterns
|
|
|
|
|
import type { Component } from 'solid-js'
|
|
|
|
|
import { createMemo, createSignal, Show } from 'solid-js'
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
VStack,
|
|
|
|
|
HStack,
|
|
|
|
|
Text,
|
|
|
|
|
Input,
|
|
|
|
|
Box,
|
|
|
|
|
Button,
|
|
|
|
|
Badge,
|
|
|
|
|
Progress
|
|
|
|
|
} from '@gluestack-ui/themed'
|
|
|
|
|
import { useGameSession } from '../hooks/useGameSession'
|
|
|
|
|
import { useTimer } from '../hooks/useTimer'
|
|
|
|
|
Input,
|
|
|
|
|
LinearProgress,
|
|
|
|
|
Typography,
|
|
|
|
|
Chip,
|
|
|
|
|
} from '@suid/material'
|
|
|
|
|
|
|
|
|
|
// Props interface with proper documentation
|
|
|
|
|
export interface GameCardProps {
|
|
|
|
|
@ -637,346 +633,99 @@ export interface GameCardProps {
|
|
|
|
|
timeRemaining: number
|
|
|
|
|
/** Number of attempts left */
|
|
|
|
|
attemptsLeft: number
|
|
|
|
|
/** Current player score */
|
|
|
|
|
currentScore: number
|
|
|
|
|
/** Loading state */
|
|
|
|
|
isLoading?: boolean
|
|
|
|
|
/** Callback when answer is submitted */
|
|
|
|
|
onSubmitAnswer: (answer: string) => Promise<void>
|
|
|
|
|
/** Callback when hint is requested */
|
|
|
|
|
onRequestHint: () => Promise<void>
|
|
|
|
|
/** Callback when time expires */
|
|
|
|
|
onTimeExpire?: () => void
|
|
|
|
|
/** Loading state from async operations */
|
|
|
|
|
loading?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GameCard component displays a quiz question with input and controls
|
|
|
|
|
*
|
|
|
|
|
* @param props - GameCard props
|
|
|
|
|
* @returns React component
|
|
|
|
|
* @returns SolidJS component
|
|
|
|
|
*/
|
|
|
|
|
export const GameCard: React.FC<GameCardProps> = ({
|
|
|
|
|
question,
|
|
|
|
|
theme,
|
|
|
|
|
timeRemaining,
|
|
|
|
|
attemptsLeft,
|
|
|
|
|
currentScore,
|
|
|
|
|
isLoading = false,
|
|
|
|
|
onSubmitAnswer,
|
|
|
|
|
onRequestHint,
|
|
|
|
|
onTimeExpire
|
|
|
|
|
}) => {
|
|
|
|
|
// Local state with proper typing
|
|
|
|
|
const [answer, setAnswer] = useState<string>('')
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
|
|
|
|
|
|
|
|
|
|
// Custom hooks
|
|
|
|
|
const { formatTime } = useTimer()
|
|
|
|
|
|
|
|
|
|
// Memoized handlers
|
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
|
|
|
if (!answer.trim() || isSubmitting) return
|
|
|
|
|
export const GameCard: Component<GameCardProps> = (props) => {
|
|
|
|
|
const [answer, setAnswer] = createSignal('')
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
|
|
|
|
|
|
|
|
|
const disabled = createMemo(() => props.loading || isSubmitting())
|
|
|
|
|
const progressValue = createMemo(() => ((30 * 60 - props.timeRemaining) / (30 * 60)) * 100)
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
const value = answer().trim()
|
|
|
|
|
if (!value || disabled()) return
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true)
|
|
|
|
|
try {
|
|
|
|
|
await onSubmitAnswer(answer.trim())
|
|
|
|
|
setAnswer('') // Clear input on successful submission
|
|
|
|
|
await props.onSubmitAnswer(value)
|
|
|
|
|
setAnswer('')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to submit answer:', error)
|
|
|
|
|
// Error handling would be managed by parent component
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false)
|
|
|
|
|
}
|
|
|
|
|
}, [answer, isSubmitting, onSubmitAnswer])
|
|
|
|
|
|
|
|
|
|
const handleHintRequest = useCallback(async () => {
|
|
|
|
|
if (isLoading) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await onRequestHint()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to request hint:', error)
|
|
|
|
|
}
|
|
|
|
|
}, [isLoading, onRequestHint])
|
|
|
|
|
|
|
|
|
|
// Effects
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (timeRemaining === 0 && onTimeExpire) {
|
|
|
|
|
onTimeExpire()
|
|
|
|
|
}
|
|
|
|
|
}, [timeRemaining, onTimeExpire])
|
|
|
|
|
|
|
|
|
|
// Computed values
|
|
|
|
|
const progressValue = ((30 * 60 - timeRemaining) / (30 * 60)) * 100
|
|
|
|
|
const isTimeWarning = timeRemaining <= 300 // 5 minutes
|
|
|
|
|
const isTimeCritical = timeRemaining <= 60 // 1 minute
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card size="lg" variant="elevated" m="$4">
|
|
|
|
|
<VStack space="md" p="$4">
|
|
|
|
|
{/* Header with theme and timer */}
|
|
|
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
|
|
|
<Badge
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="solid"
|
|
|
|
|
action="info"
|
|
|
|
|
testID="theme-badge"
|
|
|
|
|
>
|
|
|
|
|
<Text color="$white" fontSize="$sm" fontWeight="$semibold">
|
|
|
|
|
{theme}
|
|
|
|
|
</Text>
|
|
|
|
|
</Badge>
|
|
|
|
|
<HStack space="sm" alignItems="center">
|
|
|
|
|
<Text
|
|
|
|
|
fontSize="$sm"
|
|
|
|
|
color={isTimeCritical ? "$error500" : isTimeWarning ? "$warning500" : "$textLight600"}
|
|
|
|
|
testID="timer-display"
|
|
|
|
|
>
|
|
|
|
|
⏱️ {formatTime(timeRemaining)}
|
|
|
|
|
</Text>
|
|
|
|
|
</HStack>
|
|
|
|
|
</HStack>
|
|
|
|
|
|
|
|
|
|
{/* Progress indicator */}
|
|
|
|
|
<Progress
|
|
|
|
|
value={progressValue}
|
|
|
|
|
size="sm"
|
|
|
|
|
testID="session-progress"
|
|
|
|
|
>
|
|
|
|
|
<ProgressFilledTrack />
|
|
|
|
|
</Progress>
|
|
|
|
|
|
|
|
|
|
{/* Question */}
|
|
|
|
|
<Text
|
|
|
|
|
fontSize="$lg"
|
|
|
|
|
fontWeight="$semibold"
|
|
|
|
|
lineHeight="$xl"
|
|
|
|
|
testID="question-text"
|
|
|
|
|
>
|
|
|
|
|
{question}
|
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
|
|
{/* Answer input */}
|
|
|
|
|
<Box sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
|
|
|
<Chip label={props.theme} />
|
|
|
|
|
<Typography variant="body2">{props.timeRemaining}s</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
<LinearProgress variant="determinate" value={progressValue()} />
|
|
|
|
|
<Typography variant="h6" sx={{ mt: 2 }}>{props.question}</Typography>
|
|
|
|
|
<Typography variant="body2" sx={{ mt: 1 }}>Attempts left: {props.attemptsLeft}/3</Typography>
|
|
|
|
|
<Input
|
|
|
|
|
size="lg"
|
|
|
|
|
isDisabled={isLoading || isSubmitting}
|
|
|
|
|
testID="answer-input"
|
|
|
|
|
>
|
|
|
|
|
<InputField
|
|
|
|
|
placeholder="Enter your answer..."
|
|
|
|
|
value={answer}
|
|
|
|
|
onChangeText={setAnswer}
|
|
|
|
|
autoCapitalize="none"
|
|
|
|
|
autoCorrect={false}
|
|
|
|
|
onSubmitEditing={handleSubmit}
|
|
|
|
|
returnKeyType="send"
|
|
|
|
|
value={answer()}
|
|
|
|
|
onInput={(e) => setAnswer(e.currentTarget.value)}
|
|
|
|
|
disabled={disabled()}
|
|
|
|
|
placeholder="Enter your answer"
|
|
|
|
|
/>
|
|
|
|
|
</Input>
|
|
|
|
|
|
|
|
|
|
{/* Game stats */}
|
|
|
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
|
|
|
<Text fontSize="$sm" color="$textLight600" testID="attempts-counter">
|
|
|
|
|
Attempts left: {attemptsLeft}/3
|
|
|
|
|
</Text>
|
|
|
|
|
<Text fontSize="$sm" color="$textLight600" testID="score-display">
|
|
|
|
|
Score: {currentScore} points
|
|
|
|
|
</Text>
|
|
|
|
|
</HStack>
|
|
|
|
|
|
|
|
|
|
{/* Action buttons */}
|
|
|
|
|
<HStack space="sm">
|
|
|
|
|
<Button
|
|
|
|
|
size="lg"
|
|
|
|
|
variant="solid"
|
|
|
|
|
action="primary"
|
|
|
|
|
flex={1}
|
|
|
|
|
onPress={handleSubmit}
|
|
|
|
|
isDisabled={!answer.trim() || isLoading || isSubmitting}
|
|
|
|
|
testID="submit-button"
|
|
|
|
|
>
|
|
|
|
|
<ButtonText>
|
|
|
|
|
{isSubmitting ? 'Submitting...' : 'Submit Answer'}
|
|
|
|
|
</ButtonText>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="lg"
|
|
|
|
|
variant="outline"
|
|
|
|
|
action="secondary"
|
|
|
|
|
onPress={handleHintRequest}
|
|
|
|
|
isDisabled={isLoading || isSubmitting}
|
|
|
|
|
testID="hint-button"
|
|
|
|
|
>
|
|
|
|
|
<ButtonText>💡 Hint</ButtonText>
|
|
|
|
|
<Button onClick={handleSubmit} disabled={disabled() || answer().trim().length === 0}>
|
|
|
|
|
Submit Answer
|
|
|
|
|
</Button>
|
|
|
|
|
</HStack>
|
|
|
|
|
</VStack>
|
|
|
|
|
</Card>
|
|
|
|
|
<Show when={disabled()}>
|
|
|
|
|
<Typography variant="caption">Submitting...</Typography>
|
|
|
|
|
</Show>
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default export for dynamic imports
|
|
|
|
|
export default GameCard
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Custom Hooks Pattern
|
|
|
|
|
#### Solid Composable Pattern
|
|
|
|
|
```typescript
|
|
|
|
|
// Custom hook with proper TypeScript types
|
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
|
|
import { createSignal } from 'solid-js'
|
|
|
|
|
import { GameSessionService } from '../services/game-session-service'
|
|
|
|
|
|
|
|
|
|
interface UseGameSessionOptions {
|
|
|
|
|
playerName: string
|
|
|
|
|
onSessionEnd?: (finalScore: number) => void
|
|
|
|
|
onError?: (error: Error) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface GameSessionState {
|
|
|
|
|
sessionId: string | null
|
|
|
|
|
currentQuestion: Question | null
|
|
|
|
|
score: number
|
|
|
|
|
attemptsLeft: number
|
|
|
|
|
timeRemaining: number
|
|
|
|
|
isLoading: boolean
|
|
|
|
|
error: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface GameSessionActions {
|
|
|
|
|
startGame: () => Promise<void>
|
|
|
|
|
submitAnswer: (answer: string) => Promise<boolean>
|
|
|
|
|
requestHint: () => Promise<string>
|
|
|
|
|
endGame: () => Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useGameSession = (
|
|
|
|
|
options: UseGameSessionOptions
|
|
|
|
|
): [GameSessionState, GameSessionActions] => {
|
|
|
|
|
const [state, setState] = useState<GameSessionState>({
|
|
|
|
|
sessionId: null,
|
|
|
|
|
currentQuestion: null,
|
|
|
|
|
score: 0,
|
|
|
|
|
attemptsLeft: 3,
|
|
|
|
|
timeRemaining: 30 * 60, // 30 minutes
|
|
|
|
|
isLoading: false,
|
|
|
|
|
error: null,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
|
const gameService = useRef(new GameSessionService())
|
|
|
|
|
|
|
|
|
|
// Timer effect
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (state.sessionId && state.timeRemaining > 0) {
|
|
|
|
|
timerRef.current = setInterval(() => {
|
|
|
|
|
setState(prev => {
|
|
|
|
|
const newTimeRemaining = prev.timeRemaining - 1
|
|
|
|
|
if (newTimeRemaining <= 0 && options.onSessionEnd) {
|
|
|
|
|
options.onSessionEnd(prev.score)
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, timeRemaining: Math.max(0, newTimeRemaining) }
|
|
|
|
|
})
|
|
|
|
|
}, 1000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (timerRef.current) {
|
|
|
|
|
clearInterval(timerRef.current)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [state.sessionId, state.timeRemaining, options.onSessionEnd])
|
|
|
|
|
|
|
|
|
|
// Actions
|
|
|
|
|
const startGame = useCallback(async () => {
|
|
|
|
|
setState(prev => ({ ...prev, isLoading: true, error: null }))
|
|
|
|
|
export const createGameSessionStore = (playerName: () => string) => {
|
|
|
|
|
const service = new GameSessionService()
|
|
|
|
|
const [isLoading, setIsLoading] = createSignal(false)
|
|
|
|
|
const [error, setError] = createSignal<string | null>(null)
|
|
|
|
|
const [sessionId, setSessionId] = createSignal<string | null>(null)
|
|
|
|
|
|
|
|
|
|
const startGame = async () => {
|
|
|
|
|
setIsLoading(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const session = await gameService.current.startGame(options.playerName)
|
|
|
|
|
setState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sessionId: session.sessionId,
|
|
|
|
|
currentQuestion: session.firstQuestion,
|
|
|
|
|
score: 0,
|
|
|
|
|
attemptsLeft: 3,
|
|
|
|
|
timeRemaining: session.timeRemaining,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
}))
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to start game'
|
|
|
|
|
setState(prev => ({ ...prev, error: errorMessage, isLoading: false }))
|
|
|
|
|
options.onError?.(error instanceof Error ? error : new Error(errorMessage))
|
|
|
|
|
}
|
|
|
|
|
}, [options.playerName, options.onError])
|
|
|
|
|
|
|
|
|
|
const submitAnswer = useCallback(async (answer: string): Promise<boolean> => {
|
|
|
|
|
if (!state.sessionId || !state.currentQuestion) {
|
|
|
|
|
throw new Error('No active game session')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setState(prev => ({ ...prev, isLoading: true }))
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await gameService.current.submitAnswer(
|
|
|
|
|
state.sessionId,
|
|
|
|
|
state.currentQuestion.id,
|
|
|
|
|
answer
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
setState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
score: result.newScore,
|
|
|
|
|
attemptsLeft: result.attemptsLeft,
|
|
|
|
|
currentQuestion: result.nextQuestion,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
return result.isCorrect
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setState(prev => ({ ...prev, isLoading: false }))
|
|
|
|
|
throw error
|
|
|
|
|
const session = await service.startGame(playerName())
|
|
|
|
|
setSessionId(session.sessionId)
|
|
|
|
|
return session
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to start game'
|
|
|
|
|
setError(message)
|
|
|
|
|
throw err
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}, [state.sessionId, state.currentQuestion])
|
|
|
|
|
|
|
|
|
|
const requestHint = useCallback(async (): Promise<string> => {
|
|
|
|
|
if (!state.sessionId || !state.currentQuestion) {
|
|
|
|
|
throw new Error('No active game session')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hint = await gameService.current.requestHint(
|
|
|
|
|
state.sessionId,
|
|
|
|
|
state.currentQuestion.id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return hint
|
|
|
|
|
}, [state.sessionId, state.currentQuestion])
|
|
|
|
|
|
|
|
|
|
const endGame = useCallback(async () => {
|
|
|
|
|
if (!state.sessionId) return
|
|
|
|
|
|
|
|
|
|
await gameService.current.endGame(state.sessionId)
|
|
|
|
|
|
|
|
|
|
setState({
|
|
|
|
|
sessionId: null,
|
|
|
|
|
currentQuestion: null,
|
|
|
|
|
score: 0,
|
|
|
|
|
attemptsLeft: 3,
|
|
|
|
|
timeRemaining: 30 * 60,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
error: null,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (timerRef.current) {
|
|
|
|
|
clearInterval(timerRef.current)
|
|
|
|
|
}
|
|
|
|
|
}, [state.sessionId])
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
state,
|
|
|
|
|
{
|
|
|
|
|
return {
|
|
|
|
|
isLoading,
|
|
|
|
|
error,
|
|
|
|
|
sessionId,
|
|
|
|
|
startGame,
|
|
|
|
|
submitAnswer,
|
|
|
|
|
requestHint,
|
|
|
|
|
endGame,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
@ -1148,197 +897,51 @@ func BenchmarkGameService_StartGame(b *testing.B) {
|
|
|
|
|
|
|
|
|
|
### TypeScript Testing Patterns
|
|
|
|
|
```typescript
|
|
|
|
|
// React component testing with Jest and Testing Library
|
|
|
|
|
import React from 'react'
|
|
|
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
|
|
|
import { GluestackUIProvider } from '@gluestack-ui/themed'
|
|
|
|
|
// SolidJS component testing with Vitest and Testing Library
|
|
|
|
|
import { render, screen, fireEvent } from '@solidjs/testing-library'
|
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { GameCard, GameCardProps } from '../GameCard'
|
|
|
|
|
|
|
|
|
|
// Test wrapper for Gluestack UI
|
|
|
|
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
|
|
|
<GluestackUIProvider>{children}</GluestackUIProvider>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Mock props factory
|
|
|
|
|
const createMockProps = (overrides: Partial<GameCardProps> = {}): GameCardProps => ({
|
|
|
|
|
question: "What is the capital of France?",
|
|
|
|
|
theme: "Geography",
|
|
|
|
|
timeRemaining: 1800, // 30 minutes
|
|
|
|
|
attemptsLeft: 3,
|
|
|
|
|
currentScore: 0,
|
|
|
|
|
onSubmitAnswer: jest.fn(),
|
|
|
|
|
onRequestHint: jest.fn(),
|
|
|
|
|
onSubmitAnswer: vi.fn(),
|
|
|
|
|
loading: false,
|
|
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('GameCard', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Rendering', () => {
|
|
|
|
|
it('should render question and theme correctly', () => {
|
|
|
|
|
const props = createMockProps()
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
render(() => <GameCard {...props} />)
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('question-text')).toHaveTextContent(props.question)
|
|
|
|
|
expect(screen.getByTestId('theme-badge')).toHaveTextContent(props.theme)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should display timer in correct format', () => {
|
|
|
|
|
const props = createMockProps({ timeRemaining: 125 }) // 2:05
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should show attempts and score correctly', () => {
|
|
|
|
|
const props = createMockProps({ attemptsLeft: 2, currentScore: 5 })
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('attempts-counter')).toHaveTextContent('Attempts left: 2/3')
|
|
|
|
|
expect(screen.getByTestId('score-display')).toHaveTextContent('Score: 5 points')
|
|
|
|
|
expect(screen.getByText(props.question)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(props.theme)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('User Interactions', () => {
|
|
|
|
|
it('should call onSubmitAnswer when submit button is clicked', async () => {
|
|
|
|
|
const props = createMockProps()
|
|
|
|
|
const mockOnSubmitAnswer = jest.fn().mockResolvedValue(undefined)
|
|
|
|
|
const mockOnSubmitAnswer = vi.fn().mockResolvedValue(undefined)
|
|
|
|
|
props.onSubmitAnswer = mockOnSubmitAnswer
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
render(() => <GameCard {...props} />)
|
|
|
|
|
|
|
|
|
|
// Type an answer
|
|
|
|
|
const input = screen.getByTestId('answer-input')
|
|
|
|
|
fireEvent.changeText(input, 'Paris')
|
|
|
|
|
const input = screen.getByPlaceholderText('Enter your answer')
|
|
|
|
|
fireEvent.input(input, { target: { value: 'Paris' } })
|
|
|
|
|
|
|
|
|
|
// Click submit
|
|
|
|
|
const submitButton = screen.getByTestId('submit-button')
|
|
|
|
|
fireEvent.press(submitButton)
|
|
|
|
|
const submitButton = screen.getByRole('button', { name: 'Submit Answer' })
|
|
|
|
|
fireEvent.click(submitButton)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should not submit empty answer', () => {
|
|
|
|
|
const props = createMockProps()
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
const submitButton = screen.getByTestId('submit-button')
|
|
|
|
|
expect(submitButton).toBeDisabled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should call onRequestHint when hint button is clicked', async () => {
|
|
|
|
|
const props = createMockProps()
|
|
|
|
|
const mockOnRequestHint = jest.fn().mockResolvedValue('City of Light')
|
|
|
|
|
props.onRequestHint = mockOnRequestHint
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
const hintButton = screen.getByTestId('hint-button')
|
|
|
|
|
fireEvent.press(hintButton)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockOnRequestHint).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Loading States', () => {
|
|
|
|
|
it('should disable inputs when loading', () => {
|
|
|
|
|
const props = createMockProps({ isLoading: true })
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('answer-input')).toBeDisabled()
|
|
|
|
|
expect(screen.getByTestId('submit-button')).toBeDisabled()
|
|
|
|
|
expect(screen.getByTestId('hint-button')).toBeDisabled()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Timer States', () => {
|
|
|
|
|
it('should show warning color when time is low', () => {
|
|
|
|
|
const props = createMockProps({ timeRemaining: 300 }) // 5 minutes
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
const timer = screen.getByTestId('timer-display')
|
|
|
|
|
expect(timer).toHaveStyle({ color: expect.stringContaining('warning') })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should show critical color when time is very low', () => {
|
|
|
|
|
const props = createMockProps({ timeRemaining: 30 }) // 30 seconds
|
|
|
|
|
|
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
|
|
|
|
|
|
const timer = screen.getByTestId('timer-display')
|
|
|
|
|
expect(timer).toHaveStyle({ color: expect.stringContaining('error') })
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Hook testing
|
|
|
|
|
import { renderHook, act } from '@testing-library/react'
|
|
|
|
|
import { useGameSession } from '../hooks/useGameSession'
|
|
|
|
|
|
|
|
|
|
// Mock the service
|
|
|
|
|
jest.mock('../services/game-session-service')
|
|
|
|
|
|
|
|
|
|
describe('useGameSession', () => {
|
|
|
|
|
const mockGameService = {
|
|
|
|
|
startGame: jest.fn(),
|
|
|
|
|
submitAnswer: jest.fn(),
|
|
|
|
|
requestHint: jest.fn(),
|
|
|
|
|
endGame: jest.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks()
|
|
|
|
|
// Set up service mock
|
|
|
|
|
;(GameSessionService as jest.Mock).mockImplementation(() => mockGameService)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should start game successfully', async () => {
|
|
|
|
|
mockGameService.startGame.mockResolvedValue({
|
|
|
|
|
sessionId: 'session123',
|
|
|
|
|
firstQuestion: { id: 'q1', text: 'Test question' },
|
|
|
|
|
timeRemaining: 1800,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useGameSession({ playerName: 'TestPlayer' })
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current[1].startGame()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current[0].sessionId).toBe('session123')
|
|
|
|
|
expect(result.current[0].currentQuestion).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle errors during game start', async () => {
|
|
|
|
|
const mockError = new Error('Failed to start game')
|
|
|
|
|
mockGameService.startGame.mockRejectedValue(mockError)
|
|
|
|
|
|
|
|
|
|
const onError = jest.fn()
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useGameSession({ playerName: 'TestPlayer', onError })
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current[1].startGame()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current[0].error).toBe('Failed to start game')
|
|
|
|
|
expect(onError).toHaveBeenCalledWith(mockError)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
```
|
|
|
|
|
## Git Workflow
|
|
|
|
|
@ -1404,26 +1007,19 @@ docker-compose up -d
|
|
|
|
|
|
|
|
|
|
### Frontend Development
|
|
|
|
|
Note: This project uses Yarn with `nodeLinker: node-modules`. PnP files like `.pnp.cjs` and `.pnp.loader.mjs` are not used.
|
|
|
|
|
Status: `Web` commands are implemented. `Tauri cross-platform` commands are planned and require a future `frontend/apps/cross-platform` workspace.
|
|
|
|
|
```bash
|
|
|
|
|
# Web application
|
|
|
|
|
cd frontend/apps/web
|
|
|
|
|
npm install
|
|
|
|
|
npm run dev # Start development server
|
|
|
|
|
npm run build # Production build
|
|
|
|
|
npm run test # Run Jest tests
|
|
|
|
|
npm run lint # ESLint
|
|
|
|
|
npm run type-check # TypeScript checking
|
|
|
|
|
|
|
|
|
|
# Mobile application
|
|
|
|
|
cd frontend/apps/mobile
|
|
|
|
|
npm install
|
|
|
|
|
npx expo start # Start Expo development
|
|
|
|
|
npx expo run:ios # Run on iOS simulator
|
|
|
|
|
npx expo run:android # Run on Android emulator
|
|
|
|
|
|
|
|
|
|
# Desktop application
|
|
|
|
|
cd frontend/apps/desktop
|
|
|
|
|
npm install
|
|
|
|
|
npm run dev # Start Wails development
|
|
|
|
|
npm run build # Build desktop app
|
|
|
|
|
cd frontend
|
|
|
|
|
yarn install
|
|
|
|
|
yarn dev # Start web development server (SolidJS)
|
|
|
|
|
yarn build # Build all frontend workspaces
|
|
|
|
|
yarn test # Run Vitest suites
|
|
|
|
|
yarn lint # ESLint
|
|
|
|
|
yarn format:check # Prettier check
|
|
|
|
|
|
|
|
|
|
# Cross-platform packaging with Tauri (desktop/mobile)
|
|
|
|
|
cd frontend/apps/cross-platform
|
|
|
|
|
yarn tauri dev # Run Tauri app with embedded web UI
|
|
|
|
|
yarn tauri build # Build release bundles
|
|
|
|
|
```
|
|
|
|
|
|