Claude commited on
Commit
cf9842a
·
unverified ·
1 Parent(s): 0341500

test(frontend): Sprint 5 — Vitest setup, 14 component/API tests, Viewer memo

Browse files

- Setup Vitest + React Testing Library + jsdom
- 7 API client tests (endpoints, ApiError, extractDetail, delete)
- 3 RetroButton tests (render, onClick, disabled)
- 2 RetroTextarea tests (accessible label linkage)
- 2 RetroSelect tests (accessible label, options rendering)
- Memoize Viewer.tsx source with useMemo

https://claude.ai/code/session_012NCh8yLxMXkRmBYQgHCTik

frontend/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/package.json CHANGED
@@ -5,7 +5,8 @@
5
  "scripts": {
6
  "dev": "vite",
7
  "build": "tsc && vite build",
8
- "preview": "vite preview"
 
9
  },
10
  "dependencies": {
11
  "openseadragon": "^4.1.0",
@@ -14,14 +15,19 @@
14
  "react-router-dom": "^7.14.0"
15
  },
16
  "devDependencies": {
 
 
 
17
  "@types/openseadragon": "^3.0.10",
18
  "@types/react": "^18.3.3",
19
  "@types/react-dom": "^18.3.0",
20
  "@vitejs/plugin-react": "^4.3.1",
21
  "autoprefixer": "^10.4.19",
 
22
  "postcss": "^8.4.38",
23
  "tailwindcss": "^3.4.4",
24
  "typescript": "^5.5.3",
25
- "vite": "^5.3.4"
 
26
  }
27
  }
 
5
  "scripts": {
6
  "dev": "vite",
7
  "build": "tsc && vite build",
8
+ "preview": "vite preview",
9
+ "test": "vitest run"
10
  },
11
  "dependencies": {
12
  "openseadragon": "^4.1.0",
 
15
  "react-router-dom": "^7.14.0"
16
  },
17
  "devDependencies": {
18
+ "@testing-library/jest-dom": "^6.9.1",
19
+ "@testing-library/react": "^16.3.2",
20
+ "@testing-library/user-event": "^14.6.1",
21
  "@types/openseadragon": "^3.0.10",
22
  "@types/react": "^18.3.3",
23
  "@types/react-dom": "^18.3.0",
24
  "@vitejs/plugin-react": "^4.3.1",
25
  "autoprefixer": "^10.4.19",
26
+ "jsdom": "^29.0.2",
27
  "postcss": "^8.4.38",
28
  "tailwindcss": "^3.4.4",
29
  "typescript": "^5.5.3",
30
+ "vite": "^5.3.4",
31
+ "vitest": "^4.1.4"
32
  }
33
  }
frontend/src/components/Viewer.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, type FC } from 'react'
2
  import OpenSeadragon from 'openseadragon'
3
  import { RetroButton } from './retro'
4
 
@@ -39,7 +39,10 @@ const Viewer: FC<Props> = ({ iiifServiceUrl, fallbackImageUrl, onViewerReady })
39
  }, [])
40
 
41
  // Source à ouvrir : préférer le service IIIF (zoom tuilé), sinon image statique
42
- const source = iiifServiceUrl || fallbackImageUrl || ''
 
 
 
43
 
44
  useEffect(() => {
45
  const viewer = viewerRef.current
 
1
+ import { useEffect, useMemo, useRef, type FC } from 'react'
2
  import OpenSeadragon from 'openseadragon'
3
  import { RetroButton } from './retro'
4
 
 
39
  }, [])
40
 
41
  // Source à ouvrir : préférer le service IIIF (zoom tuilé), sinon image statique
42
+ const source = useMemo(
43
+ () => iiifServiceUrl || fallbackImageUrl || '',
44
+ [iiifServiceUrl, fallbackImageUrl]
45
+ )
46
 
47
  useEffect(() => {
48
  const viewer = viewerRef.current
frontend/src/components/retro/__tests__/RetroButton.test.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { RetroButton } from '../index'
4
+
5
+ test('renders button with text', () => {
6
+ render(<RetroButton>Click me</RetroButton>)
7
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
8
+ })
9
+
10
+ test('calls onClick when clicked', async () => {
11
+ const handleClick = vi.fn()
12
+ render(<RetroButton onClick={handleClick}>Click</RetroButton>)
13
+ await userEvent.click(screen.getByRole('button'))
14
+ expect(handleClick).toHaveBeenCalledOnce()
15
+ })
16
+
17
+ test('disabled button does not call onClick', async () => {
18
+ const handleClick = vi.fn()
19
+ render(<RetroButton disabled onClick={handleClick}>Click</RetroButton>)
20
+ await userEvent.click(screen.getByRole('button'))
21
+ expect(handleClick).not.toHaveBeenCalled()
22
+ })
frontend/src/components/retro/__tests__/RetroSelect.test.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from '@testing-library/react'
2
+ import { RetroSelect } from '../index'
3
+
4
+ test('select has accessible label', () => {
5
+ render(
6
+ <RetroSelect
7
+ label="Profil"
8
+ options={[{ value: 'a', label: 'Option A' }]}
9
+ value="a"
10
+ onChange={() => {}}
11
+ />
12
+ )
13
+ expect(screen.getByLabelText('Profil')).toBeInTheDocument()
14
+ })
15
+
16
+ test('renders all options', () => {
17
+ render(
18
+ <RetroSelect
19
+ label="Test"
20
+ options={[
21
+ { value: 'a', label: 'A' },
22
+ { value: 'b', label: 'B' },
23
+ ]}
24
+ value="a"
25
+ onChange={() => {}}
26
+ />
27
+ )
28
+ expect(screen.getAllByRole('option')).toHaveLength(2)
29
+ })
frontend/src/components/retro/__tests__/RetroTextarea.test.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from '@testing-library/react'
2
+ import { RetroTextarea } from '../index'
3
+
4
+ test('textarea has accessible label', () => {
5
+ render(<RetroTextarea label="Notes" />)
6
+ expect(screen.getByLabelText('Notes')).toBeInTheDocument()
7
+ expect(screen.getByLabelText('Notes').tagName).toBe('TEXTAREA')
8
+ })
9
+
10
+ test('renders without label', () => {
11
+ render(<RetroTextarea placeholder="Type here" />)
12
+ expect(screen.getByPlaceholderText('Type here')).toBeInTheDocument()
13
+ })
frontend/src/lib/__tests__/api.test.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ // We need to mock fetch before importing the module,
4
+ // and also need to handle import.meta.env used by the module.
5
+
6
+ // Mock global fetch
7
+ const mockFetch = vi.fn()
8
+ vi.stubGlobal('fetch', mockFetch)
9
+
10
+ // Dynamic import so our fetch stub is in place
11
+ const api = await import('../api')
12
+
13
+ beforeEach(() => {
14
+ mockFetch.mockReset()
15
+ })
16
+
17
+ describe('fetchCorpora', () => {
18
+ it('calls the correct endpoint', async () => {
19
+ const mockCorpora = [
20
+ { id: '1', slug: 'test', title: 'Test', profile_id: 'medieval-illuminated', created_at: '', updated_at: '' },
21
+ ]
22
+ mockFetch.mockResolvedValueOnce({
23
+ ok: true,
24
+ json: () => Promise.resolve(mockCorpora),
25
+ })
26
+
27
+ const result = await api.fetchCorpora()
28
+
29
+ expect(mockFetch).toHaveBeenCalledOnce()
30
+ expect(mockFetch).toHaveBeenCalledWith('/api/v1/corpora')
31
+ expect(result).toEqual(mockCorpora)
32
+ })
33
+
34
+ it('throws ApiError on non-200 response', async () => {
35
+ mockFetch.mockResolvedValueOnce({
36
+ ok: false,
37
+ status: 404,
38
+ })
39
+
40
+ await expect(api.fetchCorpora()).rejects.toThrow(api.ApiError)
41
+ await mockFetch.mockResolvedValueOnce({
42
+ ok: false,
43
+ status: 500,
44
+ })
45
+ await expect(api.fetchCorpora()).rejects.toThrow('HTTP 500')
46
+ })
47
+ })
48
+
49
+ describe('ApiError', () => {
50
+ it('has correct name and status', () => {
51
+ const err = new api.ApiError(422, 'Validation failed')
52
+ expect(err.name).toBe('ApiError')
53
+ expect(err.status).toBe(422)
54
+ expect(err.message).toBe('Validation failed')
55
+ expect(err).toBeInstanceOf(Error)
56
+ })
57
+ })
58
+
59
+ describe('post helper — error extraction', () => {
60
+ it('extracts string detail from FastAPI error', async () => {
61
+ mockFetch.mockResolvedValueOnce({
62
+ ok: false,
63
+ status: 400,
64
+ json: () => Promise.resolve({ detail: 'Corpus not found' }),
65
+ })
66
+
67
+ await expect(
68
+ api.createCorpus({ slug: 'x', title: 'X', profile_id: 'p' })
69
+ ).rejects.toThrow('Corpus not found')
70
+ })
71
+
72
+ it('extracts array detail from FastAPI validation error', async () => {
73
+ mockFetch.mockResolvedValueOnce({
74
+ ok: false,
75
+ status: 422,
76
+ json: () =>
77
+ Promise.resolve({
78
+ detail: [
79
+ { loc: ['body', 'slug'], msg: 'field required', type: 'missing' },
80
+ ],
81
+ }),
82
+ })
83
+
84
+ await expect(
85
+ api.createCorpus({ slug: '', title: '', profile_id: '' })
86
+ ).rejects.toThrow('body \u2192 slug : field required')
87
+ })
88
+
89
+ it('falls back to HTTP status when json parsing fails', async () => {
90
+ mockFetch.mockResolvedValueOnce({
91
+ ok: false,
92
+ status: 500,
93
+ json: () => Promise.reject(new Error('not json')),
94
+ })
95
+
96
+ await expect(
97
+ api.createCorpus({ slug: 'x', title: 'X', profile_id: 'p' })
98
+ ).rejects.toThrow('HTTP 500')
99
+ })
100
+ })
101
+
102
+ describe('deleteCorpus', () => {
103
+ it('calls DELETE on the correct endpoint', async () => {
104
+ mockFetch.mockResolvedValueOnce({ ok: true })
105
+
106
+ await api.deleteCorpus('abc-123')
107
+
108
+ expect(mockFetch).toHaveBeenCalledWith('/api/v1/corpora/abc-123', {
109
+ method: 'DELETE',
110
+ })
111
+ })
112
+ })
frontend/src/test-setup.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ import '@testing-library/jest-dom'
frontend/vitest.config.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ setupFiles: './src/test-setup.ts',
7
+ globals: true,
8
+ },
9
+ })