Skip to content

Commit a4b199e

Browse files
feat(frontend): add character management to dashboard (#62)
- Add CharacterCard component with MiniPreview (character silhouette) - Add always-visible action buttons (edit, duplicate, delete, share) - Add MiniPreview component with memoization and typed configs - Add reusable Icon base component and CharacterIcons - Add DeleteConfirmModal and DuplicateModal components - Add character duplicate, delete, toggle share functionality - Extend CharacterSummary interface with appearance fields - Add comprehensive tests for all new components
1 parent 52a0ced commit a4b199e

File tree

16 files changed

+1769
-47
lines changed

16 files changed

+1769
-47
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { axe, toHaveNoViolations } from 'jest-axe';
5+
import { CharacterCard } from './CharacterCard';
6+
import type { CharacterSummary } from '../../services/characterService';
7+
8+
expect.extend(toHaveNoViolations);
9+
10+
const mockCharacter: CharacterSummary = {
11+
id: 1,
12+
name: 'Arthas',
13+
className: 'Guerrier',
14+
status: 'Draft',
15+
gender: 'Male',
16+
isShared: false,
17+
skinColor: '#C19A6B',
18+
hairColor: '#4A3C31',
19+
eyeColor: '#4A3C31',
20+
faceShape: 'ovale',
21+
hairStyle: 'court',
22+
eyeShape: 'amande',
23+
noseShape: 'droit',
24+
mouthShape: 'moyenne',
25+
};
26+
27+
const approvedCharacter: CharacterSummary = {
28+
...mockCharacter,
29+
status: 'Approved',
30+
};
31+
32+
const renderCard = (
33+
character: CharacterSummary = mockCharacter,
34+
props: Partial<React.ComponentProps<typeof CharacterCard>> = {}
35+
) => {
36+
return render(
37+
<MemoryRouter>
38+
<CharacterCard character={character} {...props} />
39+
</MemoryRouter>
40+
);
41+
};
42+
43+
describe('CharacterCard', () => {
44+
describe('rendering', () => {
45+
it('should render character name', () => {
46+
renderCard();
47+
expect(screen.getByText('Arthas')).toBeInTheDocument();
48+
});
49+
50+
it('should render class and gender', () => {
51+
renderCard();
52+
expect(screen.getByText(/Guerrier/)).toBeInTheDocument();
53+
expect(screen.getByText(/Masculin/)).toBeInTheDocument();
54+
});
55+
56+
it('should render female gender correctly', () => {
57+
renderCard({ ...mockCharacter, gender: 'Female' });
58+
expect(screen.getByText(/Féminin/)).toBeInTheDocument();
59+
});
60+
61+
it('should render status badge for Draft', () => {
62+
renderCard();
63+
expect(screen.getByText('Brouillon')).toBeInTheDocument();
64+
});
65+
66+
it('should render status badge for Approved', () => {
67+
renderCard(approvedCharacter);
68+
expect(screen.getByText('Approuvé')).toBeInTheDocument();
69+
});
70+
71+
it('should render status badge for Pending', () => {
72+
renderCard({ ...mockCharacter, status: 'Pending' });
73+
expect(screen.getByText('En attente')).toBeInTheDocument();
74+
});
75+
76+
it('should render status badge for Rejected', () => {
77+
renderCard({ ...mockCharacter, status: 'Rejected' });
78+
expect(screen.getByText('Rejeté')).toBeInTheDocument();
79+
});
80+
81+
it('should render link to character details', () => {
82+
renderCard();
83+
const link = screen.getByRole('link');
84+
expect(link).toHaveAttribute('href', '/characters/1');
85+
});
86+
87+
it('should show shared indicator for approved shared character', () => {
88+
renderCard({ ...approvedCharacter, isShared: true });
89+
expect(screen.getByText('Partagé')).toBeInTheDocument();
90+
});
91+
92+
it('should show private indicator for approved non-shared character', () => {
93+
renderCard({ ...approvedCharacter, isShared: false });
94+
expect(screen.getByText('Privé')).toBeInTheDocument();
95+
});
96+
97+
it('should not show shared indicator for non-approved character', () => {
98+
renderCard({ ...mockCharacter, isShared: true });
99+
expect(screen.queryByText('Partagé')).not.toBeInTheDocument();
100+
expect(screen.queryByText('Privé')).not.toBeInTheDocument();
101+
});
102+
});
103+
104+
describe('actions visibility by status', () => {
105+
it('should show Edit button for Draft status', () => {
106+
const onEdit = vi.fn();
107+
renderCard(mockCharacter, { onEdit });
108+
expect(screen.getByText('Modifier')).toBeInTheDocument();
109+
});
110+
111+
it('should show Edit button for Rejected status', () => {
112+
const onEdit = vi.fn();
113+
renderCard({ ...mockCharacter, status: 'Rejected' }, { onEdit });
114+
expect(screen.getByText('Modifier')).toBeInTheDocument();
115+
});
116+
117+
it('should not show Edit button for Approved status', () => {
118+
const onEdit = vi.fn();
119+
renderCard(approvedCharacter, { onEdit });
120+
expect(screen.queryByText('Modifier')).not.toBeInTheDocument();
121+
});
122+
123+
it('should not show Edit button for Pending status', () => {
124+
const onEdit = vi.fn();
125+
renderCard({ ...mockCharacter, status: 'Pending' }, { onEdit });
126+
expect(screen.queryByText('Modifier')).not.toBeInTheDocument();
127+
});
128+
129+
it('should show Duplicate button only for Approved status', () => {
130+
const onDuplicate = vi.fn();
131+
renderCard(approvedCharacter, { onDuplicate });
132+
expect(screen.getByText('Dupliquer')).toBeInTheDocument();
133+
});
134+
135+
it('should not show Duplicate button for Draft status', () => {
136+
const onDuplicate = vi.fn();
137+
renderCard(mockCharacter, { onDuplicate });
138+
expect(screen.queryByText('Dupliquer')).not.toBeInTheDocument();
139+
});
140+
141+
it('should show Share button only for Approved status', () => {
142+
const onToggleShare = vi.fn();
143+
renderCard({ ...approvedCharacter, isShared: false }, { onToggleShare });
144+
expect(screen.getByText('Partager')).toBeInTheDocument();
145+
});
146+
147+
it('should show Unshare button for shared approved character', () => {
148+
const onToggleShare = vi.fn();
149+
renderCard({ ...approvedCharacter, isShared: true }, { onToggleShare });
150+
expect(screen.getByText('Rendre privé')).toBeInTheDocument();
151+
});
152+
153+
it('should always show Delete button when onDelete provided', () => {
154+
const onDelete = vi.fn();
155+
renderCard(mockCharacter, { onDelete });
156+
expect(screen.getByText('Supprimer')).toBeInTheDocument();
157+
});
158+
});
159+
160+
describe('action callbacks', () => {
161+
it('should call onEdit with character id', () => {
162+
const onEdit = vi.fn();
163+
renderCard(mockCharacter, { onEdit });
164+
fireEvent.click(screen.getByText('Modifier'));
165+
expect(onEdit).toHaveBeenCalledWith(1);
166+
});
167+
168+
it('should call onDuplicate when clicked', () => {
169+
const onDuplicate = vi.fn();
170+
renderCard(approvedCharacter, { onDuplicate });
171+
fireEvent.click(screen.getByText('Dupliquer'));
172+
expect(onDuplicate).toHaveBeenCalled();
173+
});
174+
175+
it('should call onToggleShare with character id', () => {
176+
const onToggleShare = vi.fn();
177+
renderCard({ ...approvedCharacter, isShared: false }, { onToggleShare });
178+
fireEvent.click(screen.getByText('Partager'));
179+
expect(onToggleShare).toHaveBeenCalledWith(1);
180+
});
181+
182+
it('should call onDelete when clicked', () => {
183+
const onDelete = vi.fn();
184+
renderCard(mockCharacter, { onDelete });
185+
fireEvent.click(screen.getByText('Supprimer'));
186+
expect(onDelete).toHaveBeenCalled();
187+
});
188+
});
189+
190+
describe('loading states', () => {
191+
it('should show loading state for duplicate', () => {
192+
const onDuplicate = vi.fn();
193+
renderCard(approvedCharacter, { onDuplicate, isDuplicating: true });
194+
expect(screen.getByText('Duplication...')).toBeInTheDocument();
195+
});
196+
197+
it('should show loading state for toggle share', () => {
198+
const onToggleShare = vi.fn();
199+
renderCard(approvedCharacter, { onToggleShare, isTogglingShare: true });
200+
expect(screen.getByText('En cours...')).toBeInTheDocument();
201+
});
202+
203+
it('should show loading state for delete', () => {
204+
const onDelete = vi.fn();
205+
renderCard(mockCharacter, { onDelete, isDeleting: true });
206+
expect(screen.getByText('Suppression...')).toBeInTheDocument();
207+
});
208+
});
209+
210+
describe('accessibility (RGAA/WCAG)', () => {
211+
it('should have no accessibility violations', async () => {
212+
const { container } = renderCard(approvedCharacter, {
213+
onEdit: vi.fn(),
214+
onDuplicate: vi.fn(),
215+
onDelete: vi.fn(),
216+
onToggleShare: vi.fn(),
217+
});
218+
219+
const results = await axe(container);
220+
expect(results).toHaveNoViolations();
221+
});
222+
223+
it('should have accessible button labels', () => {
224+
const onEdit = vi.fn();
225+
const onDelete = vi.fn();
226+
renderCard(mockCharacter, { onEdit, onDelete });
227+
228+
const editButton = screen.getByLabelText(/modifier arthas/i);
229+
const deleteButton = screen.getByLabelText(/supprimer arthas/i);
230+
expect(editButton).toBeInTheDocument();
231+
expect(deleteButton).toBeInTheDocument();
232+
});
233+
234+
it('should have character preview with aria-label', () => {
235+
renderCard(mockCharacter);
236+
expect(screen.getByRole('img', { name: /aperçu du personnage/i })).toBeInTheDocument();
237+
});
238+
});
239+
});

0 commit comments

Comments
 (0)