diff --git a/core/src/browser/extensions/engines/AIEngine.test.ts b/core/src/browser/extensions/engines/AIEngine.test.ts index ab3280e1c..2ead360a8 100644 --- a/core/src/browser/extensions/engines/AIEngine.test.ts +++ b/core/src/browser/extensions/engines/AIEngine.test.ts @@ -13,6 +13,38 @@ class TestAIEngine extends AIEngine { inference(data: any) {} stopInference() {} + + async list(): Promise { + return [] + } + + async load(modelId: string): Promise { + return { pid: 1, port: 8080, model_id: modelId, model_path: '', api_key: '' } + } + + async unload(sessionId: string): Promise { + return { success: true } + } + + async chat(opts: any): Promise { + return { id: 'test', object: 'chat.completion', created: Date.now(), model: 'test', choices: [] } + } + + async delete(modelId: string): Promise { + return + } + + async import(modelId: string, opts: any): Promise { + return + } + + async abortImport(modelId: string): Promise { + return + } + + async getLoadedModels(): Promise { + return [] + } } describe('AIEngine', () => { @@ -23,35 +55,31 @@ describe('AIEngine', () => { jest.clearAllMocks() }) - it('should load model if provider matches', async () => { - const model: any = { id: 'model1', engine: 'test-provider' } as any + it('should load model successfully', async () => { + const modelId = 'model1' - await engine.loadModel(model) + const result = await engine.load(modelId) - expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelReady, model) + expect(result).toEqual({ pid: 1, port: 8080, model_id: modelId, model_path: '', api_key: '' }) }) - it('should not load model if provider does not match', async () => { - const model: any = { id: 'model1', engine: 'other-provider' } as any + it('should unload model successfully', async () => { + const sessionId = 'session1' - await engine.loadModel(model) + const result = await engine.unload(sessionId) - expect(events.emit).not.toHaveBeenCalledWith(ModelEvent.OnModelReady, model) + expect(result).toEqual({ success: true }) }) - it('should unload model if provider matches', async () => { - const model: Model = { id: 'model1', version: '1.0', engine: 'test-provider' } as any + it('should list models', async () => { + const result = await engine.list() - await engine.unloadModel(model) - - expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelStopped, model) + expect(result).toEqual([]) }) - it('should not unload model if provider does not match', async () => { - const model: Model = { id: 'model1', version: '1.0', engine: 'other-provider' } as any + it('should get loaded models', async () => { + const result = await engine.getLoadedModels() - await engine.unloadModel(model) - - expect(events.emit).not.toHaveBeenCalledWith(ModelEvent.OnModelStopped, model) + expect(result).toEqual([]) }) }) diff --git a/core/src/browser/extensions/engines/LocalOAIEngine.ts b/core/src/browser/extensions/engines/LocalOAIEngine.ts index 7c465384c..d9f9220bf 100644 --- a/core/src/browser/extensions/engines/LocalOAIEngine.ts +++ b/core/src/browser/extensions/engines/LocalOAIEngine.ts @@ -28,9 +28,14 @@ export abstract class LocalOAIEngine extends OAIEngine { /** * Load the model. */ - async loadModel(model: Model & { file_path?: string }): Promise {} + async loadModel(model: Model & { file_path?: string }): Promise { + // Implementation of loading the model + } + /** * Stops the model. */ - async unloadModel(model?: Model) {} + async unloadModel(model?: Model) { + // Implementation of unloading the model + } } diff --git a/core/src/browser/extensions/index.test.ts b/core/src/browser/extensions/index.test.ts index bc5a7c358..feb72db5e 100644 --- a/core/src/browser/extensions/index.test.ts +++ b/core/src/browser/extensions/index.test.ts @@ -1,7 +1,6 @@ import { ConversationalExtension } from './index'; import { InferenceExtension } from './index'; import { AssistantExtension } from './index'; -import { ModelExtension } from './index'; import * as Engines from './index'; describe('index.ts exports', () => { @@ -17,9 +16,6 @@ describe('index.ts exports', () => { expect(AssistantExtension).toBeDefined(); }); - test('should export ModelExtension', () => { - expect(ModelExtension).toBeDefined(); - }); test('should export Engines', () => { expect(Engines).toBeDefined(); diff --git a/core/src/types/index.test.ts b/core/src/types/index.test.ts index d938feee9..c58c6b2e1 100644 --- a/core/src/types/index.test.ts +++ b/core/src/types/index.test.ts @@ -6,7 +6,6 @@ import * as message from './message'; import * as inference from './inference'; import * as file from './file'; import * as config from './config'; -import * as huggingface from './huggingface'; import * as miscellaneous from './miscellaneous'; import * as api from './api'; import * as setting from './setting'; @@ -19,7 +18,6 @@ import * as setting from './setting'; expect(inference).toBeDefined(); expect(file).toBeDefined(); expect(config).toBeDefined(); - expect(huggingface).toBeDefined(); expect(miscellaneous).toBeDefined(); expect(api).toBeDefined(); expect(setting).toBeDefined(); diff --git a/web-app/eslint.config.js b/web-app/eslint.config.js index 092408a9f..6b0b92336 100644 --- a/web-app/eslint.config.js +++ b/web-app/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ['dist', 'coverage', '**/__tests__/**', '**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/web-app/src/hooks/__tests__/useMediaQuery.test.ts b/web-app/src/hooks/__tests__/useMediaQuery.test.ts new file mode 100644 index 000000000..21dcaeec5 --- /dev/null +++ b/web-app/src/hooks/__tests__/useMediaQuery.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useMediaQuery } from '../useMediaQuery' + +// Mock window.matchMedia +const mockMatchMedia = vi.fn() + +beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: mockMatchMedia, + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useMediaQuery hook', () => { + it('should return initial match value', () => { + const mockMediaQueryList = { + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + mockMatchMedia.mockReturnValue(mockMediaQueryList) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(true) + expect(mockMatchMedia).toHaveBeenCalledWith('(min-width: 768px)') + }) + + it('should return false when media query does not match', () => { + const mockMediaQueryList = { + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + mockMatchMedia.mockReturnValue(mockMediaQueryList) + + const { result } = renderHook(() => useMediaQuery('(max-width: 767px)')) + + expect(result.current).toBe(false) + }) + + it('should update when media query changes', () => { + const mockMediaQueryList = { + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + mockMatchMedia.mockReturnValue(mockMediaQueryList) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + // Simulate media query change + const changeHandler = mockMediaQueryList.addEventListener.mock.calls[0][1] + + act(() => { + changeHandler({ matches: true }) + }) + + expect(result.current).toBe(true) + }) + + it('should add event listener on mount', () => { + const mockMediaQueryList = { + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + mockMatchMedia.mockReturnValue(mockMediaQueryList) + + renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(mockMediaQueryList.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)) + }) + + it('should remove event listener on unmount', () => { + const mockMediaQueryList = { + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + mockMatchMedia.mockReturnValue(mockMediaQueryList) + + const { unmount } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + unmount() + + expect(mockMediaQueryList.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)) + }) + + it('should handle different media queries', () => { + const mockMediaQueryList = { + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + mockMatchMedia.mockReturnValue(mockMediaQueryList) + + const { result: result1 } = renderHook(() => useMediaQuery('(min-width: 768px)')) + const { result: result2 } = renderHook(() => useMediaQuery('(max-width: 1024px)')) + + expect(result1.current).toBe(true) + expect(result2.current).toBe(true) + expect(mockMatchMedia).toHaveBeenCalledWith('(min-width: 768px)') + expect(mockMatchMedia).toHaveBeenCalledWith('(max-width: 1024px)') + }) + + it('should handle matchMedia not being available', () => { + // @ts-ignore + delete window.matchMedia + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + }) +}) \ No newline at end of file diff --git a/web-app/src/services/__tests__/models.test.ts b/web-app/src/services/__tests__/models.test.ts new file mode 100644 index 000000000..a0d572753 --- /dev/null +++ b/web-app/src/services/__tests__/models.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + fetchModels, + fetchModelCatalog, + updateModel, + pullModel, + abortDownload, + deleteModel, + getActiveModels, + stopModel, + stopAllModels, + startModel, + configurePullOptions, +} from '../models' +import { EngineManager } from '@janhq/core' + +// Mock EngineManager +vi.mock('@janhq/core', () => ({ + EngineManager: { + instance: vi.fn(), + }, +})) + +// Mock fetch +global.fetch = vi.fn() + +// Mock MODEL_CATALOG_URL +Object.defineProperty(global, 'MODEL_CATALOG_URL', { + value: 'https://example.com/models', + writable: true, + configurable: true, +}) + +describe('models service', () => { + const mockEngine = { + list: vi.fn(), + updateSettings: vi.fn(), + import: vi.fn(), + abortImport: vi.fn(), + delete: vi.fn(), + getLoadedModels: vi.fn(), + unload: vi.fn(), + load: vi.fn(), + } + + const mockEngineManager = { + get: vi.fn().mockReturnValue(mockEngine), + } + + beforeEach(() => { + vi.clearAllMocks() + ;(EngineManager.instance as any).mockReturnValue(mockEngineManager) + }) + + describe('fetchModels', () => { + it('should fetch models successfully', async () => { + const mockModels = [ + { id: 'model1', name: 'Model 1' }, + { id: 'model2', name: 'Model 2' }, + ] + mockEngine.list.mockResolvedValue(mockModels) + + const result = await fetchModels() + + expect(result).toEqual(mockModels) + expect(mockEngine.list).toHaveBeenCalled() + }) + }) + + describe('fetchModelCatalog', () => { + it('should fetch model catalog successfully', async () => { + const mockCatalog = [ + { + model_name: 'GPT-4', + description: 'Large language model', + developer: 'OpenAI', + downloads: 1000, + num_quants: 5, + quants: [], + }, + ] + + ;(fetch as any).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockCatalog), + }) + + const result = await fetchModelCatalog() + + expect(result).toEqual(mockCatalog) + }) + + it('should handle fetch error', async () => { + ;(fetch as any).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + + await expect(fetchModelCatalog()).rejects.toThrow('Failed to fetch model catalog: 404 Not Found') + }) + + it('should handle network error', async () => { + ;(fetch as any).mockRejectedValue(new Error('Network error')) + + await expect(fetchModelCatalog()).rejects.toThrow('Failed to fetch model catalog: Network error') + }) + }) + + describe('updateModel', () => { + it('should update model settings', async () => { + const model = { + id: 'model1', + settings: [{ key: 'temperature', value: 0.7 }], + } + + await updateModel(model) + + expect(mockEngine.updateSettings).toHaveBeenCalledWith(model.settings) + }) + + it('should handle model without settings', async () => { + const model = { id: 'model1' } + + await updateModel(model) + + expect(mockEngine.updateSettings).not.toHaveBeenCalled() + }) + }) + + describe('pullModel', () => { + it('should pull model successfully', async () => { + const id = 'model1' + const modelPath = '/path/to/model' + + await pullModel(id, modelPath) + + expect(mockEngine.import).toHaveBeenCalledWith(id, { modelPath }) + }) + }) + + describe('abortDownload', () => { + it('should abort download successfully', async () => { + const id = 'model1' + + await abortDownload(id) + + expect(mockEngine.abortImport).toHaveBeenCalledWith(id) + }) + }) + + describe('deleteModel', () => { + it('should delete model successfully', async () => { + const id = 'model1' + + await deleteModel(id) + + expect(mockEngine.delete).toHaveBeenCalledWith(id) + }) + }) + + describe('getActiveModels', () => { + it('should get active models successfully', async () => { + const mockActiveModels = ['model1', 'model2'] + mockEngine.getLoadedModels.mockResolvedValue(mockActiveModels) + + const result = await getActiveModels() + + expect(result).toEqual(mockActiveModels) + expect(mockEngine.getLoadedModels).toHaveBeenCalled() + }) + }) + + describe('stopModel', () => { + it('should stop model successfully', async () => { + const model = 'model1' + const provider = 'openai' + + await stopModel(model, provider) + + expect(mockEngine.unload).toHaveBeenCalledWith(model) + }) + }) + + describe('stopAllModels', () => { + it('should stop all active models', async () => { + const mockActiveModels = ['model1', 'model2'] + mockEngine.getLoadedModels.mockResolvedValue(mockActiveModels) + + await stopAllModels() + + expect(mockEngine.unload).toHaveBeenCalledTimes(2) + expect(mockEngine.unload).toHaveBeenCalledWith('model1') + expect(mockEngine.unload).toHaveBeenCalledWith('model2') + }) + + it('should handle empty active models', async () => { + mockEngine.getLoadedModels.mockResolvedValue(null) + + await stopAllModels() + + expect(mockEngine.unload).not.toHaveBeenCalled() + }) + }) + + describe('startModel', () => { + it('should start model successfully', async () => { + const provider = { provider: 'openai', models: [] } as ProviderObject + const model = 'model1' + const mockSession = { id: 'session1' } + + mockEngine.load.mockResolvedValue(mockSession) + + const result = await startModel(provider, model) + + expect(result).toEqual(mockSession) + expect(mockEngine.load).toHaveBeenCalledWith(model) + }) + + it('should handle start model error', async () => { + const provider = { provider: 'openai', models: [] } as ProviderObject + const model = 'model1' + const error = new Error('Failed to start model') + + mockEngine.load.mockRejectedValue(error) + + await expect(startModel(provider, model)).rejects.toThrow(error) + }) + }) + + describe('configurePullOptions', () => { + it('should configure proxy options', async () => { + const proxyOptions = { + proxyEnabled: true, + proxyUrl: 'http://proxy.com', + proxyUsername: 'user', + proxyPassword: 'pass', + proxyIgnoreSSL: false, + verifyProxySSL: true, + verifyProxyHostSSL: true, + verifyPeerSSL: true, + verifyHostSSL: true, + noProxy: '', + } + + // Mock console.log to avoid output during tests + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await configurePullOptions(proxyOptions) + + expect(consoleSpy).toHaveBeenCalledWith('Configuring proxy options:', proxyOptions) + consoleSpy.mockRestore() + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/services/__tests__/threads.test.ts b/web-app/src/services/__tests__/threads.test.ts new file mode 100644 index 000000000..e9589aca9 --- /dev/null +++ b/web-app/src/services/__tests__/threads.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fetchThreads, createThread, updateThread, deleteThread } from '../threads' +import { ExtensionManager } from '@/lib/extension' +import { ConversationalExtension, ExtensionTypeEnum } from '@janhq/core' +import { defaultAssistant } from '@/hooks/useAssistant' + +// Mock ExtensionManager +vi.mock('@/lib/extension', () => ({ + ExtensionManager: { + getInstance: vi.fn(), + }, +})) + +vi.mock('@/hooks/useAssistant', () => ({ + defaultAssistant: { + id: 'jan', + name: 'Jan', + instructions: 'You are a helpful assistant.', + }, +})) + +describe('threads service', () => { + const mockConversationalExtension = { + listThreads: vi.fn(), + createThread: vi.fn(), + modifyThread: vi.fn(), + deleteThread: vi.fn(), + } + + const mockExtensionManager = { + get: vi.fn().mockReturnValue(mockConversationalExtension), + } + + beforeEach(() => { + vi.clearAllMocks() + ;(ExtensionManager.getInstance as any).mockReturnValue(mockExtensionManager) + }) + + describe('fetchThreads', () => { + it('should fetch and transform threads successfully', async () => { + const mockThreads = [ + { + id: '1', + title: 'Test Thread', + updated: 1234567890, + metadata: { order: 1, is_favorite: true }, + assistants: [{ model: { id: 'gpt-4', engine: 'openai' } }], + }, + ] + + mockConversationalExtension.listThreads.mockResolvedValue(mockThreads) + + const result = await fetchThreads() + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + id: '1', + title: 'Test Thread', + updated: 1234567890, + order: 1, + isFavorite: true, + model: { id: 'gpt-4', provider: 'openai' }, + assistants: [{ model: { id: 'gpt-4', engine: 'openai' } }], + }) + }) + + it('should handle empty threads array', async () => { + mockConversationalExtension.listThreads.mockResolvedValue([]) + + const result = await fetchThreads() + + expect(result).toEqual([]) + }) + + it('should handle error and return empty array', async () => { + mockConversationalExtension.listThreads.mockRejectedValue(new Error('API Error')) + + const result = await fetchThreads() + + expect(result).toEqual([]) + }) + + it('should handle null/undefined response', async () => { + mockConversationalExtension.listThreads.mockResolvedValue(null) + + const result = await fetchThreads() + + expect(result).toEqual([]) + }) + }) + + describe('createThread', () => { + it('should create thread successfully', async () => { + const inputThread = { + id: '1', + title: 'New Thread', + model: { id: 'gpt-4', provider: 'openai' }, + assistants: [defaultAssistant], + order: 1, + } + + const mockCreatedThread = { + id: '1', + title: 'New Thread', + updated: 1234567890, + assistants: [{ model: { id: 'gpt-4', engine: 'openai' } }], + metadata: { order: 1 }, + } + + mockConversationalExtension.createThread.mockResolvedValue(mockCreatedThread) + + const result = await createThread(inputThread as Thread) + + expect(result).toMatchObject({ + id: '1', + title: 'New Thread', + updated: 1234567890, + model: { id: 'gpt-4', provider: 'openai' }, + order: 1, + assistants: [{ model: { id: 'gpt-4', engine: 'openai' } }], + }) + }) + + it('should handle creation error and return original thread', async () => { + const inputThread = { + id: '1', + title: 'New Thread', + model: { id: 'gpt-4', provider: 'openai' }, + } + + mockConversationalExtension.createThread.mockRejectedValue(new Error('Creation failed')) + + const result = await createThread(inputThread as Thread) + + expect(result).toEqual(inputThread) + }) + }) + + describe('updateThread', () => { + it('should update thread successfully', async () => { + const thread = { + id: '1', + title: 'Updated Thread', + model: { id: 'gpt-4', provider: 'openai' }, + assistants: [defaultAssistant], + isFavorite: true, + order: 2, + } + + const result = updateThread(thread as Thread) + + expect(mockConversationalExtension.modifyThread).toHaveBeenCalledWith( + expect.objectContaining({ + id: '1', + title: 'Updated Thread', + assistants: expect.arrayContaining([ + expect.objectContaining({ + model: { id: 'gpt-4', engine: 'openai' }, + }), + ]), + metadata: { is_favorite: true, order: 2 }, + }) + ) + }) + }) + + describe('deleteThread', () => { + it('should delete thread successfully', () => { + const threadId = '1' + + deleteThread(threadId) + + expect(mockConversationalExtension.deleteThread).toHaveBeenCalledWith(threadId) + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/utils/__tests__/error.test.ts b/web-app/src/utils/__tests__/error.test.ts new file mode 100644 index 000000000..e6286060c --- /dev/null +++ b/web-app/src/utils/__tests__/error.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest' +import { OUT_OF_CONTEXT_SIZE } from '../error' + +describe('error utilities', () => { + describe('OUT_OF_CONTEXT_SIZE', () => { + it('should have correct error message', () => { + expect(OUT_OF_CONTEXT_SIZE).toBe('the request exceeds the available context size.') + }) + + it('should be a string', () => { + expect(typeof OUT_OF_CONTEXT_SIZE).toBe('string') + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/utils/__tests__/highlight.test.ts b/web-app/src/utils/__tests__/highlight.test.ts new file mode 100644 index 000000000..0277ba41a --- /dev/null +++ b/web-app/src/utils/__tests__/highlight.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { highlightFzfMatch } from '../highlight' + +describe('highlight utility', () => { + describe('highlightFzfMatch', () => { + it('should highlight characters at specified positions', () => { + const text = 'Hello World' + const positions = [0, 6] + const result = highlightFzfMatch(text, positions) + + expect(result).toBe('Hello World') + }) + + it('should handle empty positions array', () => { + const text = 'Hello World' + const positions: number[] = [] + const result = highlightFzfMatch(text, positions) + + expect(result).toBe('Hello World') + }) + + it('should handle empty text', () => { + const text = '' + const positions = [0, 1] + const result = highlightFzfMatch(text, positions) + + expect(result).toBe('') + }) + + it('should handle positions out of bounds', () => { + const text = 'Hello' + const positions = [0, 10] + const result = highlightFzfMatch(text, positions) + + expect(result).toBe('Hello') + }) + + it('should handle custom highlight class', () => { + const text = 'Hello World' + const positions = [0] + const result = highlightFzfMatch(text, positions, 'custom-highlight') + + expect(result).toBe('Hello World') + }) + + it('should sort positions automatically', () => { + const text = 'Hello World' + const positions = [6, 0] + const result = highlightFzfMatch(text, positions) + + expect(result).toBe('Hello World') + }) + + it('should handle multiple consecutive positions', () => { + const text = 'Hello' + const positions = [0, 1, 2] + const result = highlightFzfMatch(text, positions) + + expect(result).toBe('Hello') + }) + + it('should handle null or undefined positions', () => { + const text = 'Hello World' + const result1 = highlightFzfMatch(text, null as any) + const result2 = highlightFzfMatch(text, undefined as any) + + expect(result1).toBe('Hello World') + expect(result2).toBe('Hello World') + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/utils/__tests__/teamEmoji.test.ts b/web-app/src/utils/__tests__/teamEmoji.test.ts new file mode 100644 index 000000000..eda023c01 --- /dev/null +++ b/web-app/src/utils/__tests__/teamEmoji.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { teamEmoji } from '../teamEmoji' + +describe('teamEmoji utility', () => { + describe('teamEmoji', () => { + it('should contain team member data', () => { + expect(teamEmoji).toBeInstanceOf(Array) + expect(teamEmoji.length).toBeGreaterThan(0) + }) + + it('should have correct structure for team members', () => { + const member = teamEmoji[0] + expect(member).toHaveProperty('names') + expect(member).toHaveProperty('imgUrl') + expect(member).toHaveProperty('id') + expect(Array.isArray(member.names)).toBe(true) + expect(typeof member.imgUrl).toBe('string') + expect(typeof member.id).toBe('string') + }) + + it('should contain expected team members', () => { + const memberIds = teamEmoji.map(m => m.id) + expect(memberIds).toContain('louis') + expect(memberIds).toContain('emre') + expect(memberIds).toContain('alex') + expect(memberIds).toContain('daniel') + expect(memberIds).toContain('bach') + }) + + it('should have unique IDs', () => { + const ids = teamEmoji.map(m => m.id) + const uniqueIds = [...new Set(ids)] + expect(ids.length).toBe(uniqueIds.length) + }) + + it('should have valid image URLs', () => { + teamEmoji.forEach(member => { + expect(member.imgUrl).toMatch(/^\/images\/emoji\/.*\.png$/) + }) + }) + }) +}) \ No newline at end of file