Skip to content

Commit a1a17dc

Browse files
Merge pull request #121 from CSSLab/copilot/fix-120
feat: add analyze entire game functionality with configurable depth and progress tracking
2 parents b6debdd + 00ee8b5 commit a1a17dc

14 files changed

Lines changed: 644 additions & 24 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import React from 'react'
2+
import { render, screen } from '@testing-library/react'
3+
import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal'
4+
import { AnalysisProgressOverlay } from 'src/components/Analysis/AnalysisProgressOverlay'
5+
import '@testing-library/jest-dom'
6+
7+
// Mock framer-motion to avoid animation issues in tests
8+
jest.mock('framer-motion', () => ({
9+
motion: {
10+
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
11+
},
12+
AnimatePresence: ({ children }: any) => <>{children}</>,
13+
}))
14+
15+
describe('Analyze Entire Game Components', () => {
16+
describe('AnalysisConfigModal', () => {
17+
const defaultProps = {
18+
isOpen: true,
19+
onClose: jest.fn(),
20+
onConfirm: jest.fn(),
21+
initialDepth: 15,
22+
}
23+
24+
it('renders the modal when open', () => {
25+
render(<AnalysisConfigModal {...defaultProps} />)
26+
27+
expect(screen.getByText('Analyze Entire Game')).toBeInTheDocument()
28+
expect(
29+
screen.getByText(
30+
'Choose the Stockfish analysis depth for all positions in the game:',
31+
),
32+
).toBeInTheDocument()
33+
})
34+
35+
it('renders depth options', () => {
36+
render(<AnalysisConfigModal {...defaultProps} />)
37+
38+
expect(screen.getByText('Fast (d12)')).toBeInTheDocument()
39+
expect(screen.getByText('Balanced (d15)')).toBeInTheDocument()
40+
expect(screen.getByText('Deep (d18)')).toBeInTheDocument()
41+
})
42+
43+
it('renders start analysis button', () => {
44+
render(<AnalysisConfigModal {...defaultProps} />)
45+
46+
expect(screen.getByText('Start Analysis')).toBeInTheDocument()
47+
expect(screen.getByText('Cancel')).toBeInTheDocument()
48+
})
49+
50+
it('does not render when closed', () => {
51+
render(<AnalysisConfigModal {...defaultProps} isOpen={false} />)
52+
53+
expect(screen.queryByText('Analyze Entire Game')).not.toBeInTheDocument()
54+
})
55+
})
56+
57+
describe('AnalysisProgressOverlay', () => {
58+
const mockProgress = {
59+
currentMoveIndex: 5,
60+
totalMoves: 20,
61+
currentMove: 'e4',
62+
isAnalyzing: true,
63+
isComplete: false,
64+
isCancelled: false,
65+
}
66+
67+
const defaultProps = {
68+
progress: mockProgress,
69+
onCancel: jest.fn(),
70+
}
71+
72+
it('renders progress overlay when analyzing', () => {
73+
render(<AnalysisProgressOverlay {...defaultProps} />)
74+
75+
expect(screen.getByText('Analyzing Game')).toBeInTheDocument()
76+
expect(
77+
screen.getByText('Deep analysis in progress...'),
78+
).toBeInTheDocument()
79+
expect(screen.getByText('Position 5 of 20')).toBeInTheDocument()
80+
expect(screen.getByText('25%')).toBeInTheDocument()
81+
})
82+
83+
it('renders current move being analyzed', () => {
84+
render(<AnalysisProgressOverlay {...defaultProps} />)
85+
86+
expect(screen.getByText('Currently analyzing:')).toBeInTheDocument()
87+
expect(screen.getByText('e4')).toBeInTheDocument()
88+
})
89+
90+
it('renders cancel button', () => {
91+
render(<AnalysisProgressOverlay {...defaultProps} />)
92+
93+
expect(screen.getByText('Cancel Analysis')).toBeInTheDocument()
94+
})
95+
96+
it('does not render when not analyzing', () => {
97+
const notAnalyzingProgress = {
98+
...mockProgress,
99+
isAnalyzing: false,
100+
}
101+
102+
render(
103+
<AnalysisProgressOverlay
104+
{...defaultProps}
105+
progress={notAnalyzingProgress}
106+
/>,
107+
)
108+
109+
expect(screen.queryByText('Analyzing Game')).not.toBeInTheDocument()
110+
})
111+
})
112+
})
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import React, { useState } from 'react'
2+
import { motion } from 'framer-motion'
3+
4+
interface Props {
5+
isOpen: boolean
6+
onClose: () => void
7+
onConfirm: (depth: number) => void
8+
initialDepth?: number
9+
}
10+
11+
export const AnalysisConfigModal: React.FC<Props> = ({
12+
isOpen,
13+
onClose,
14+
onConfirm,
15+
initialDepth = 15,
16+
}) => {
17+
const [selectedDepth, setSelectedDepth] = useState(initialDepth)
18+
19+
const depthOptions = [
20+
{
21+
value: 12,
22+
label: 'Fast (d12)',
23+
description: 'Quick surface-level analysis',
24+
},
25+
{
26+
value: 15,
27+
label: 'Balanced (d15)',
28+
description: 'Deeper analysis with good speed',
29+
},
30+
{
31+
value: 18,
32+
label: 'Deep (d18)',
33+
description: 'Thorough analysis with slower speed',
34+
},
35+
]
36+
37+
const handleConfirm = () => {
38+
onConfirm(selectedDepth)
39+
onClose()
40+
}
41+
42+
if (!isOpen) return null
43+
44+
return (
45+
<motion.div
46+
className="absolute left-0 top-0 z-20 flex h-screen w-screen flex-col items-center justify-center bg-black/70 px-4 backdrop-blur-sm md:px-0"
47+
initial={{ opacity: 0 }}
48+
animate={{ opacity: 1 }}
49+
exit={{ opacity: 0 }}
50+
transition={{ duration: 0.2 }}
51+
data-testid="analysis-config-modal"
52+
onClick={(e) => {
53+
if (e.target === e.currentTarget) onClose()
54+
}}
55+
>
56+
<motion.div
57+
className="flex w-full flex-col gap-4 rounded-md border border-white/10 bg-background-1 p-5 md:w-[min(500px,40vw)] md:p-6"
58+
initial={{ y: 20, opacity: 0 }}
59+
animate={{ y: 0, opacity: 1 }}
60+
exit={{ y: 20, opacity: 0 }}
61+
transition={{ duration: 0.3 }}
62+
onClick={(e) => e.stopPropagation()}
63+
>
64+
<div className="flex items-center gap-2">
65+
<span className="material-symbols-outlined text-2xl text-human-3">
66+
network_intelligence
67+
</span>
68+
<h2 className="text-xl font-semibold">Analyze Entire Game</h2>
69+
</div>
70+
71+
<div className="flex flex-col gap-3">
72+
<p className="text-sm text-primary/80">
73+
Choose the Stockfish analysis depth for all positions in the game:
74+
</p>
75+
76+
<div className="flex flex-col gap-2">
77+
{depthOptions.map((option) => (
78+
<label
79+
key={option.value}
80+
className={`flex cursor-pointer items-center gap-3 rounded border p-3 transition duration-200 ${
81+
selectedDepth === option.value
82+
? 'border-human-4 bg-human-4/10'
83+
: 'border-white/10 hover:border-white/20 hover:bg-white/5'
84+
}`}
85+
htmlFor={`depth-${option.value}`}
86+
aria-label={`Select ${option.label}`}
87+
>
88+
<input
89+
type="radio"
90+
name="depth"
91+
id={`depth-${option.value}`}
92+
value={option.value}
93+
checked={selectedDepth === option.value}
94+
onChange={(e) => setSelectedDepth(Number(e.target.value))}
95+
className="h-4 w-4 text-human-4"
96+
/>
97+
<div className="flex flex-col gap-0.5">
98+
<span className="text-sm font-medium">{option.label}</span>
99+
<span className="text-xs text-secondary">
100+
{option.description}
101+
</span>
102+
</div>
103+
</label>
104+
))}
105+
</div>
106+
107+
<div className="mt-2 flex items-start gap-2 rounded bg-background-2/60 p-3">
108+
<span className="material-symbols-outlined !text-base text-secondary">
109+
info
110+
</span>
111+
<p className="text-xs text-secondary">
112+
Higher depths provide more accurate analysis but take longer to
113+
complete. You can cancel the analysis at any time. Currently,
114+
analysis only persists until you close the tab, but we are working
115+
on a persistent analysis feature!
116+
</p>
117+
</div>
118+
</div>
119+
120+
<div className="flex justify-end gap-2 pt-2">
121+
<button
122+
onClick={onClose}
123+
className="flex h-9 items-center gap-1 rounded bg-background-2 px-4 text-sm transition duration-200 hover:bg-background-3"
124+
>
125+
Cancel
126+
</button>
127+
<button
128+
onClick={handleConfirm}
129+
className="flex h-9 items-center gap-1 rounded bg-human-4 px-4 text-sm font-medium text-white transition duration-200 hover:bg-human-4/90"
130+
>
131+
<span className="material-symbols-outlined text-sm">
132+
play_arrow
133+
</span>
134+
Start Analysis
135+
</button>
136+
</div>
137+
</motion.div>
138+
</motion.div>
139+
)
140+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React from 'react'
2+
import { motion } from 'framer-motion'
3+
import { GameAnalysisProgress } from 'src/hooks/useAnalysisController/useAnalysisController'
4+
5+
interface Props {
6+
progress: GameAnalysisProgress
7+
onCancel: () => void
8+
}
9+
10+
export const AnalysisProgressOverlay: React.FC<Props> = ({
11+
progress,
12+
onCancel,
13+
}) => {
14+
const progressPercentage =
15+
progress.totalMoves > 0
16+
? Math.round((progress.currentMoveIndex / progress.totalMoves) * 100)
17+
: 0
18+
19+
if (!progress.isAnalyzing) return null
20+
21+
return (
22+
<motion.div
23+
className="absolute left-0 top-0 z-20 flex h-screen w-screen flex-col items-center justify-center bg-black/60 px-4 md:px-0"
24+
initial={{ opacity: 0 }}
25+
animate={{ opacity: 1 }}
26+
exit={{ opacity: 0 }}
27+
transition={{ duration: 0.2 }}
28+
data-testid="analysis-progress-overlay"
29+
>
30+
<motion.div
31+
className="flex w-full flex-col gap-5 rounded-md border border-white/10 bg-background-1 p-6 md:w-[min(600px,50vw)] md:p-8"
32+
initial={{ y: 20, opacity: 0 }}
33+
animate={{ y: 0, opacity: 1 }}
34+
exit={{ y: 20, opacity: 0 }}
35+
transition={{ duration: 0.3 }}
36+
>
37+
<div className="flex items-center gap-3">
38+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-human-4/20">
39+
<span className="material-symbols-outlined animate-spin text-human-4">
40+
network_intelligence
41+
</span>
42+
</div>
43+
<div className="flex flex-col">
44+
<h2 className="text-xl font-semibold">Analyzing Game</h2>
45+
<p className="text-sm text-secondary">
46+
Deep analysis in progress...
47+
</p>
48+
</div>
49+
</div>
50+
51+
<div className="flex flex-col gap-4">
52+
{/* Progress bar */}
53+
<div className="flex flex-col gap-2">
54+
<div className="flex items-center justify-between text-sm">
55+
<span className="text-primary/80">
56+
Position {progress.currentMoveIndex} of {progress.totalMoves}
57+
</span>
58+
<span className="font-medium text-human-4">
59+
{progressPercentage}%
60+
</span>
61+
</div>
62+
<div className="relative h-2 w-full overflow-hidden rounded-full bg-background-2">
63+
<motion.div
64+
className="h-full rounded-full bg-human-4"
65+
initial={{ width: 0 }}
66+
animate={{ width: `${progressPercentage}%` }}
67+
transition={{ duration: 0.3, ease: 'easeOut' }}
68+
/>
69+
</div>
70+
</div>
71+
72+
{/* Current move being analyzed */}
73+
{progress.currentMove && (
74+
<div className="flex items-center gap-2 rounded bg-background-2/60 p-3">
75+
<span className="material-symbols-outlined text-lg text-human-3">
76+
memory
77+
</span>
78+
<div className="flex flex-col">
79+
<span className="text-sm font-medium">
80+
Currently analyzing:
81+
</span>
82+
<span className="font-mono text-xs text-secondary">
83+
{progress.currentMove}
84+
</span>
85+
</div>
86+
</div>
87+
)}
88+
89+
{/* Analysis info */}
90+
<div className="rounded bg-background-2/60 p-3">
91+
<div className="flex items-start gap-2">
92+
<span className="material-symbols-outlined !text-base text-secondary">
93+
info
94+
</span>
95+
<p className="text-xs text-secondary">
96+
Both Maia and Stockfish are analyzing each position. You can
97+
cancel anytime and keep completed analysis.
98+
</p>
99+
</div>
100+
</div>
101+
</div>
102+
103+
<div className="flex justify-center pt-2">
104+
<button
105+
onClick={onCancel}
106+
className="flex h-10 items-center gap-2 rounded bg-background-2 px-6 text-sm transition duration-200 hover:bg-background-3"
107+
>
108+
<span className="material-symbols-outlined text-lg">stop</span>
109+
Cancel Analysis
110+
</button>
111+
</div>
112+
</motion.div>
113+
</motion.div>
114+
)
115+
}

0 commit comments

Comments
 (0)