Skip to content

Commit 02f249d

Browse files
committed
Move Avatar component to use /server directory, use fixtures for avatars in unit test, update unit test
1 parent 95355f1 commit 02f249d

10 files changed

Lines changed: 87 additions & 49 deletions

File tree

‎src/components/Avatar/index.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const fallbackInitial = name.charAt(0).toUpperCase()
4949
height={avatarImage.height}
5050
/>
5151
) : (
52-
<div class="avatar-placeholder bg-[color:var(--color-bg-offset)] w-16 h-16 rounded-full flex items-center justify-center text-[color:var(--color-text-offset)]">
52+
<div class="avatar-placeholder bg-offset w-16 h-16 rounded-full flex items-center justify-center text-offset">
5353
{fallbackInitial}
5454
</div>
5555
)
5.83 KB
Loading
5.63 KB
Loading
5.07 KB
Loading
6.1 KB
Loading
4.08 KB
Loading
5.15 KB
Loading

src/components/Avatar/__tests__/server.spec.ts renamed to src/components/Avatar/server/__tests__/index.spec.ts

Lines changed: 74 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
1+
import type { ImageMetadata } from 'astro'
12
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2-
import { isDev } from '@lib/config/environmentServer'
3+
4+
/* eslint-disable-next-line no-restricted-syntax -- import.meta.glob required to load fixture assets */
5+
const fixtureModules = import.meta.glob('../__fixtures__/avatars/*.{webp,jpg,png}', {
6+
eager: true,
7+
import: 'default',
8+
}) as Record<string, ImageMetadata>
9+
10+
const fixtureAvatarNames = Array.from(
11+
new Set(
12+
Object.keys(fixtureModules)
13+
.map((path) => path.split('/').pop()?.replace(/\.(webp|jpg|png)$/i, ''))
14+
.filter((name): name is string => Boolean(name))
15+
)
16+
)
17+
18+
const mockLoadAvatarModules = vi.fn(() => fixtureModules)
19+
20+
vi.mock('../avatarImports', () => ({
21+
loadAvatarModules: mockLoadAvatarModules,
22+
}))
23+
24+
const primaryAvatar = 'kevin-brown'
25+
const fixtureAvatarCount = fixtureAvatarNames.length
326
/**
427
* Comprehensive unit tests for AvatarManager
528
*
@@ -10,9 +33,9 @@ import { isDev } from '@lib/config/environmentServer'
1033
* - Backward-compatible deprecated functions
1134
* - Edge cases and error handling
1235
*
13-
* Note: These tests use the actual avatar files in src/assets/images/avatars/
14-
* Expected files: brian-bristol.webp, chris-southam.webp, dru-sellers.webp,
15-
* kevin-brown.jpg, kevin-brown.webp, sara-king.webp, test-red-dot.png
36+
* Note: These tests load dedicated fixture avatars from
37+
* src/components/Avatar/server/__fixtures__/avatars/ to keep metadata stable.
38+
* Fixtures mirror the production files except for test-red-dot.png.
1639
*/
1740

1841
describe('AvatarManager', () => {
@@ -22,12 +45,15 @@ describe('AvatarManager', () => {
2245
let getAvailableAvatars: any
2346

2447
beforeEach(async () => {
48+
mockLoadAvatarModules.mockClear()
49+
vi.resetModules()
50+
2551
// Mock console methods to avoid cluttering test output
2652
vi.spyOn(console, 'log').mockImplementation(() => {})
2753
vi.spyOn(console, 'warn').mockImplementation(() => {})
2854

2955
// Import the module
30-
const module = await import('../server')
56+
const module = await import('..')
3157
AvatarManager = module.AvatarManager
3258

3359
// Get the singleton instance and extract methods for backward compatibility
@@ -50,18 +76,13 @@ describe('AvatarManager', () => {
5076
})
5177

5278
it('should initialize only once', () => {
53-
const consoleLogSpy = vi.spyOn(console, 'log')
79+
const initialCalls = mockLoadAvatarModules.mock.calls.length
80+
expect(initialCalls).toBe(1)
5481

55-
// Get instance multiple times
56-
AvatarManager.getInstance()
5782
AvatarManager.getInstance()
5883
AvatarManager.getInstance()
5984

60-
// Constructor should only run once (if in development mode)
61-
// In production, console.log won't be called
62-
if (isDev()) {
63-
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
64-
}
85+
expect(mockLoadAvatarModules).toHaveBeenCalledTimes(initialCalls)
6586
})
6687

6788
it('should have a private constructor (enforced by TypeScript)', () => {
@@ -76,7 +97,7 @@ describe('AvatarManager', () => {
7697
describe('getAvatar()', () => {
7798
it('should return avatar metadata for existing avatar', () => {
7899
const instance = AvatarManager.getInstance()
79-
const avatar = instance.getAvatar('kevin-brown')
100+
const avatar = instance.getAvatar(primaryAvatar)
80101

81102
expect(avatar).toBeDefined()
82103
// Avatar should be an object or a string path
@@ -95,7 +116,7 @@ describe('AvatarManager', () => {
95116
it('should return correct metadata for kevin-brown avatar', () => {
96117
const instance = AvatarManager.getInstance()
97118

98-
const kevinAvatar = instance.getAvatar('kevin-brown')
119+
const kevinAvatar = instance.getAvatar(primaryAvatar)
99120
expect(kevinAvatar).toBeDefined()
100121

101122
// Check the structure of the returned value
@@ -123,7 +144,7 @@ describe('AvatarManager', () => {
123144
it('should be case-sensitive for filenames', () => {
124145
const instance = AvatarManager.getInstance()
125146

126-
const lowerCase = instance.getAvatar('kevin-brown')
147+
const lowerCase = instance.getAvatar(primaryAvatar)
127148
const upperCase = instance.getAvatar('Kevin-Brown')
128149

129150
expect(lowerCase).toBeDefined()
@@ -135,7 +156,7 @@ describe('AvatarManager', () => {
135156
it('should return true for existing avatar', () => {
136157
const instance = AvatarManager.getInstance()
137158

138-
expect(instance.hasAvatar('kevin-brown')).toBe(true)
159+
expect(instance.hasAvatar(primaryAvatar)).toBe(true)
139160
})
140161

141162
it('should return false for non-existent avatar', () => {
@@ -154,7 +175,7 @@ describe('AvatarManager', () => {
154175
it('should be case-sensitive', () => {
155176
const instance = AvatarManager.getInstance()
156177

157-
expect(instance.hasAvatar('kevin-brown')).toBe(true)
178+
expect(instance.hasAvatar(primaryAvatar)).toBe(true)
158179
expect(instance.hasAvatar('Kevin-Brown')).toBe(false)
159180
expect(instance.hasAvatar('KEVIN-BROWN')).toBe(false)
160181
})
@@ -166,14 +187,14 @@ describe('AvatarManager', () => {
166187
const avatars = instance.getAvailableAvatars()
167188

168189
expect(Array.isArray(avatars)).toBe(true)
169-
expect(avatars.length).toBeGreaterThan(0)
190+
expect(avatars.length).toBe(fixtureAvatarCount)
170191
})
171192

172193
it('should include kevin-brown avatar', () => {
173194
const instance = AvatarManager.getInstance()
174195
const avatars = instance.getAvailableAvatars()
175196

176-
expect(avatars).toContain('kevin-brown')
197+
expect(avatars).toContain(primaryAvatar)
177198
})
178199

179200
it('should return filenames without extensions', () => {
@@ -208,7 +229,7 @@ describe('AvatarManager', () => {
208229
const instance = AvatarManager.getInstance()
209230
const allAvatars = instance.getAll()
210231

211-
expect(allAvatars['kevin-brown']).toBeDefined()
232+
expect(allAvatars[primaryAvatar]).toBeDefined()
212233
})
213234

214235
it('should return a read-only object', () => {
@@ -224,8 +245,7 @@ describe('AvatarManager', () => {
224245
it('should return the correct number of avatars', () => {
225246
const instance = AvatarManager.getInstance()
226247

227-
// Should match the actual number of avatar files in the directory
228-
expect(instance.count).toBeGreaterThan(0)
248+
expect(instance.count).toBe(fixtureAvatarCount)
229249
})
230250

231251
it('should be consistent with getAvailableAvatars length', () => {
@@ -261,29 +281,41 @@ describe('AvatarManager', () => {
261281
const instance = AvatarManager.getInstance()
262282
const allAvatars = instance.getAll()
263283

264-
expect(() => {
265-
// Testing runtime immutability
266-
delete (allAvatars as any)['kevin-brown']
267-
}).toThrow()
284+
let deleteResult: boolean | undefined
285+
try {
286+
deleteResult = delete (allAvatars as any)[primaryAvatar]
287+
} catch (error) {
288+
expect(error).toBeInstanceOf(TypeError)
289+
}
290+
291+
if (typeof deleteResult === 'boolean') {
292+
expect(deleteResult).toBe(false)
293+
}
294+
295+
expect(allAvatars[primaryAvatar]).toBeDefined()
268296
})
269297

270298
it('should not allow modification of existing avatar properties', () => {
271299
const instance = AvatarManager.getInstance()
272300
const allAvatars = instance.getAll()
273301

274-
expect(() => {
275-
// Testing runtime immutability
276-
;(allAvatars as any)['kevin-brown'].src = '/modified.jpg'
277-
}).toThrow()
302+
const originalSrc = allAvatars[primaryAvatar]?.src
303+
try {
304+
;(allAvatars as any)[primaryAvatar].src = '/modified.jpg'
305+
} catch (error) {
306+
expect(error).toBeInstanceOf(TypeError)
307+
}
308+
309+
expect(allAvatars[primaryAvatar]?.src).toBe(originalSrc)
278310
})
279311
})
280312

281313
describe('Backward Compatibility', () => {
282314
describe('getAvatarImage() (deprecated)', () => {
283315
it('should work the same as getInstance().getAvatar()', () => {
284316
const instance = AvatarManager.getInstance()
285-
const newWay = instance.getAvatar('kevin-brown')
286-
const oldWay = getAvatarImage('kevin-brown')
317+
const newWay = instance.getAvatar(primaryAvatar)
318+
const oldWay = getAvatarImage(primaryAvatar)
287319

288320
expect(oldWay).toEqual(newWay)
289321
})
@@ -319,24 +351,23 @@ describe('AvatarManager', () => {
319351
const avatars = getAvailableAvatars()
320352

321353
expect(Array.isArray(avatars)).toBe(true)
322-
expect(avatars.length).toBeGreaterThan(0)
354+
expect(avatars.length).toBe(fixtureAvatarCount)
323355
})
324356
})
325357
})
326358

327359
describe('Edge Cases', () => {
328360
it('should handle avatar with special characters in filename', () => {
329-
// This would need mock data with special chars, skipping for now
330-
// but good to consider for real-world usage
331361
const instance = AvatarManager.getInstance()
332-
expect(instance.hasAvatar('avatar-with-dash')).toBeDefined()
362+
const hyphenatedName = fixtureAvatarNames.find((name) => name.includes('-')) ?? primaryAvatar
363+
expect(instance.hasAvatar(hyphenatedName)).toBe(true)
333364
})
334365

335366
it('should handle multiple file extensions correctly', () => {
336367
const instance = AvatarManager.getInstance()
337368

338-
// kevin-brown should be found regardless of extension
339-
expect(instance.hasAvatar('kevin-brown')).toBe(true)
369+
// The canonical avatar should be found regardless of extension
370+
expect(instance.hasAvatar(primaryAvatar)).toBe(true)
340371
})
341372

342373
it('should strip file extension case-insensitively', () => {
@@ -356,7 +387,7 @@ describe('AvatarManager', () => {
356387
describe('Type Safety', () => {
357388
it('should return proper ImageMetadata type from getAvatar', () => {
358389
const instance = AvatarManager.getInstance()
359-
const avatar = instance.getAvatar('kevin-brown')
390+
const avatar = instance.getAvatar(primaryAvatar)
360391

361392
if (avatar && typeof avatar === 'object') {
362393
expect(typeof avatar.src).toBe('string')
@@ -397,13 +428,13 @@ describe('AvatarManager', () => {
397428

398429
// Make many rapid calls
399430
for (let i = 0; i < 100; i++) {
400-
instance.getAvatar('kevin-brown')
401-
instance.hasAvatar('kevin-brown')
431+
instance.getAvatar(primaryAvatar)
432+
instance.hasAvatar(primaryAvatar)
402433
instance.getAvailableAvatars()
403434
}
404435

405436
// Should still work correctly
406-
expect(instance.getAvatar('kevin-brown')).toBeDefined()
437+
expect(instance.getAvatar(primaryAvatar)).toBeDefined()
407438
expect(instance.count).toBeGreaterThan(0)
408439
})
409440
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ImageMetadata } from 'astro'
2+
3+
export type AvatarModuleMap = Record<string, ImageMetadata>
4+
5+
export const loadAvatarModules = (): AvatarModuleMap =>
6+
/* eslint-disable-next-line no-restricted-syntax -- rule to forbid import.meta.env is not granular enough to allow .glob */
7+
import.meta.glob('../../assets/images/avatars/*.{webp,jpg,png}', {
8+
eager: true,
9+
import: 'default',
10+
}) as AvatarModuleMap
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import type { ImageMetadata } from 'astro'
1818
import { isDev } from '@components/scripts/utils/environmentClient'
19+
import { loadAvatarModules } from './avatarImports'
1920

2021
type AvatarMap = Record<string, ImageMetadata>
2122

@@ -43,11 +44,7 @@ class AvatarManagerClass {
4344

4445
private constructor() {
4546
// Eagerly import all avatar images during initialization
46-
/* eslint-disable-next-line no-restricted-syntax -- The eslint syntax isn't fine-grained enough to allow "glob" method on import.meta */
47-
const avatarModules = import.meta.glob('../../assets/images/avatars/*.{webp,jpg,png}', {
48-
eager: true,
49-
import: 'default',
50-
}) as Record<string, ImageMetadata>
47+
const avatarModules = loadAvatarModules()
5148

5249
// Build the avatar mapping
5350
const map: AvatarMap = {}

0 commit comments

Comments
 (0)