diff --git a/web-app/package.json b/web-app/package.json index 4874b310c..116cc8248 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -76,6 +76,10 @@ "devDependencies": { "@eslint/js": "^9.22.0", "@tanstack/router-plugin": "^1.116.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/culori": "^2.1.1", "@types/lodash.debounce": "^4", "@types/node": "^22.14.1", @@ -87,6 +91,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "jsdom": "^26.1.0", "tailwind-merge": "^3.2.0", "typescript": "~5.8.3", "typescript-eslint": "^8.26.1", diff --git a/web-app/src/lib/__tests__/models.test.ts b/web-app/src/lib/__tests__/models.test.ts new file mode 100644 index 000000000..67f37f873 --- /dev/null +++ b/web-app/src/lib/__tests__/models.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi } from 'vitest' +import { + defaultModel, + extractDescription, + removeYamlFrontMatter, + extractModelName, + extractModelRepo, +} from '../models' + +// Mock the token.js module +vi.mock('token.js', () => ({ + models: { + openai: { + models: ['gpt-3.5-turbo', 'gpt-4'], + }, + anthropic: { + models: ['claude-3-sonnet', 'claude-3-haiku'], + }, + mistral: { + models: ['mistral-7b', 'mistral-8x7b'], + }, + }, +})) + +describe('defaultModel', () => { + it('returns first OpenAI model when no provider is given', () => { + expect(defaultModel()).toBe('gpt-3.5-turbo') + }) + + it('returns first OpenAI model when unknown provider is given', () => { + expect(defaultModel('unknown')).toBe('gpt-3.5-turbo') + }) + + it('returns first model for known providers', () => { + expect(defaultModel('anthropic')).toBe('claude-3-sonnet') + expect(defaultModel('mistral')).toBe('mistral-7b') + }) + + it('handles empty string provider', () => { + expect(defaultModel('')).toBe('gpt-3.5-turbo') + }) +}) + +describe('extractDescription', () => { + it('returns undefined for falsy input', () => { + expect(extractDescription()).toBeUndefined() + expect(extractDescription('')).toBe('') + }) + + it('extracts overview section from markdown', () => { + const markdown = `# Model Title +## Overview +This is the model overview section. +It has multiple lines. +## Features +This is another section.` + + expect(extractDescription(markdown)).toBe( + 'This is the model overview section.\nIt has multiple lines.' + ) + }) + + it('falls back to first 500 characters when no overview section', () => { + const longText = 'A'.repeat(600) + expect(extractDescription(longText)).toBe('A'.repeat(500)) + }) + + it('removes YAML front matter before extraction', () => { + const markdownWithYaml = `--- +title: Model +author: Test +--- +# Model Title +## Overview +This is the overview.` + + expect(extractDescription(markdownWithYaml)).toBe('This is the overview.') + }) + + it('removes image markdown syntax', () => { + const markdownWithImages = `## Overview +This is text with ![alt text](image.png) image. +More text here.` + + expect(extractDescription(markdownWithImages)).toBe( + 'This is text with image.\nMore text here.' + ) + }) + + it('removes HTML img tags', () => { + const markdownWithHtmlImages = `## Overview +This is text with alt image. +More text here.` + + expect(extractDescription(markdownWithHtmlImages)).toBe( + 'This is text with image.\nMore text here.' + ) + }) + + it('handles text without overview section', () => { + const simpleText = 'This is a simple description without sections.' + expect(extractDescription(simpleText)).toBe( + 'This is a simple description without sections.' + ) + }) + + it('extracts overview that ends at file end', () => { + const markdown = `# Model Title +## Overview +This is the overview at the end.` + + expect(extractDescription(markdown)).toBe( + 'This is the overview at the end.' + ) + }) +}) + +describe('removeYamlFrontMatter', () => { + it('removes YAML front matter from content', () => { + const contentWithYaml = `--- +title: Test +author: John +--- +# Main Content +This is the main content.` + + const expected = `# Main Content +This is the main content.` + + expect(removeYamlFrontMatter(contentWithYaml)).toBe(expected) + }) + + it('returns content unchanged when no YAML front matter', () => { + const content = `# Main Content +This is the main content.` + + expect(removeYamlFrontMatter(content)).toBe(content) + }) + + it('handles empty content', () => { + expect(removeYamlFrontMatter('')).toBe('') + }) + + it('handles content with only YAML front matter', () => { + const yamlOnly = `--- +title: Test +author: John +--- +` + + expect(removeYamlFrontMatter(yamlOnly)).toBe('') + }) + + it('does not remove YAML-like content in middle of text', () => { + const content = `# Title +Some content here. +--- +This is not front matter +--- +More content.` + + expect(removeYamlFrontMatter(content)).toBe(content) + }) +}) + +describe('extractModelName', () => { + it('extracts model name from repo path', () => { + expect(extractModelName('cortexso/tinyllama')).toBe('tinyllama') + expect(extractModelName('microsoft/DialoGPT-medium')).toBe( + 'DialoGPT-medium' + ) + expect(extractModelName('huggingface/CodeBERTa-small-v1')).toBe( + 'CodeBERTa-small-v1' + ) + }) + + it('returns the input when no slash is present', () => { + expect(extractModelName('tinyllama')).toBe('tinyllama') + expect(extractModelName('single-model-name')).toBe('single-model-name') + }) + + it('handles undefined input', () => { + expect(extractModelName()).toBeUndefined() + }) + + it('handles empty string', () => { + expect(extractModelName('')).toBe('') + }) + + it('handles multiple slashes', () => { + expect(extractModelName('org/sub/model')).toBe('sub') + }) +}) + +describe('extractModelRepo', () => { + it('extracts repo path from HuggingFace URL', () => { + expect(extractModelRepo('https://huggingface.co/cortexso/tinyllama')).toBe( + 'cortexso/tinyllama' + ) + expect( + extractModelRepo('https://huggingface.co/microsoft/DialoGPT-medium') + ).toBe('microsoft/DialoGPT-medium') + }) + + it('returns input unchanged when not a HuggingFace URL', () => { + expect(extractModelRepo('cortexso/tinyllama')).toBe('cortexso/tinyllama') + expect(extractModelRepo('https://github.com/user/repo')).toBe( + 'https://github.com/user/repo' + ) + }) + + it('handles undefined input', () => { + expect(extractModelRepo()).toBeUndefined() + }) + + it('handles empty string', () => { + expect(extractModelRepo('')).toBe('') + }) + + it('handles URLs with trailing slashes', () => { + expect(extractModelRepo('https://huggingface.co/cortexso/tinyllama/')).toBe( + 'cortexso/tinyllama/' + ) + }) +}) diff --git a/web-app/src/lib/__tests__/utils.test.ts b/web-app/src/lib/__tests__/utils.test.ts new file mode 100644 index 000000000..a671643df --- /dev/null +++ b/web-app/src/lib/__tests__/utils.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi } from 'vitest' +import { + getProviderLogo, + getProviderTitle, + getReadableLanguageName, + fuzzySearch, + toGigabytes, + formatMegaBytes, + formatDuration, +} from '../utils' + +describe('getProviderLogo', () => { + it('returns correct logo paths for known providers', () => { + expect(getProviderLogo('llamacpp')).toBe( + '/images/model-provider/llamacpp.svg' + ) + expect(getProviderLogo('anthropic')).toBe( + '/images/model-provider/anthropic.svg' + ) + expect(getProviderLogo('openai')).toBe('/images/model-provider/openai.svg') + expect(getProviderLogo('gemini')).toBe('/images/model-provider/gemini.svg') + }) + + it('returns undefined for unknown providers', () => { + expect(getProviderLogo('unknown')).toBeUndefined() + expect(getProviderLogo('')).toBeUndefined() + }) +}) + +describe('getProviderTitle', () => { + it('returns formatted titles for special providers', () => { + expect(getProviderTitle('llamacpp')).toBe('Llama.cpp') + expect(getProviderTitle('openai')).toBe('OpenAI') + expect(getProviderTitle('openrouter')).toBe('OpenRouter') + expect(getProviderTitle('gemini')).toBe('Gemini') + }) + + it('capitalizes first letter for unknown providers', () => { + expect(getProviderTitle('anthropic')).toBe('Anthropic') + expect(getProviderTitle('mistral')).toBe('Mistral') + expect(getProviderTitle('test')).toBe('Test') + }) + + it('handles empty strings', () => { + expect(getProviderTitle('')).toBe('') + }) +}) + +describe('getReadableLanguageName', () => { + it('returns full language names for known languages', () => { + expect(getReadableLanguageName('js')).toBe('JavaScript') + expect(getReadableLanguageName('ts')).toBe('TypeScript') + expect(getReadableLanguageName('jsx')).toBe('React JSX') + expect(getReadableLanguageName('py')).toBe('Python') + expect(getReadableLanguageName('cpp')).toBe('C++') + expect(getReadableLanguageName('yml')).toBe('YAML') + }) + + it('capitalizes first letter for unknown languages', () => { + expect(getReadableLanguageName('rust')).toBe('Rust') + expect(getReadableLanguageName('unknown')).toBe('Unknown') + expect(getReadableLanguageName('test')).toBe('Test') + }) + + it('handles empty strings', () => { + expect(getReadableLanguageName('')).toBe('') + }) +}) + +describe('fuzzySearch', () => { + it('returns true for exact matches', () => { + expect(fuzzySearch('hello', 'hello')).toBe(true) + expect(fuzzySearch('test', 'test')).toBe(true) + }) + + it('returns true for subsequence matches', () => { + expect(fuzzySearch('hlo', 'hello')).toBe(true) + expect(fuzzySearch('js', 'javascript')).toBe(true) + expect(fuzzySearch('abc', 'aabbcc')).toBe(true) + }) + + it('returns false when needle is longer than haystack', () => { + expect(fuzzySearch('hello', 'hi')).toBe(false) + expect(fuzzySearch('test', 'te')).toBe(false) + }) + + it('returns false for non-matching patterns', () => { + expect(fuzzySearch('xyz', 'hello')).toBe(false) + expect(fuzzySearch('ba', 'abc')).toBe(false) + }) + + it('handles empty strings', () => { + expect(fuzzySearch('', '')).toBe(true) + expect(fuzzySearch('', 'hello')).toBe(true) + expect(fuzzySearch('h', '')).toBe(false) + }) + + it('is case sensitive', () => { + expect(fuzzySearch('H', 'hello')).toBe(false) + expect(fuzzySearch('h', 'Hello')).toBe(false) + }) +}) + +describe('toGigabytes', () => { + it('returns empty string for falsy inputs', () => { + expect(toGigabytes(0)).toBe('') + expect(toGigabytes(null as unknown as number)).toBe('') + expect(toGigabytes(undefined as unknown as number)).toBe('') + }) + + it('formats bytes correctly', () => { + expect(toGigabytes(500)).toBe('500B') + expect(toGigabytes(1000)).toBe('1000B') + }) + + it('formats kilobytes correctly', () => { + expect(toGigabytes(1025)).toBe('1.00KB') + expect(toGigabytes(2048)).toBe('2.00KB') + expect(toGigabytes(1536)).toBe('1.50KB') + }) + + it('formats exactly 1024 bytes as bytes', () => { + expect(toGigabytes(1024)).toBe('1024B') + }) + + it('formats megabytes correctly', () => { + expect(toGigabytes(1024 ** 2 + 1)).toBe('1.00MB') + expect(toGigabytes(1024 ** 2 * 2.5)).toBe('2.50MB') + }) + + it('formats exactly 1024^2 bytes as KB', () => { + expect(toGigabytes(1024 ** 2)).toBe('1024.00KB') + }) + + it('formats gigabytes correctly', () => { + expect(toGigabytes(1024 ** 3 + 1)).toBe('1.00GB') + expect(toGigabytes(1024 ** 3 * 1.5)).toBe('1.50GB') + }) + + it('formats exactly 1024^3 bytes as MB', () => { + expect(toGigabytes(1024 ** 3)).toBe('1024.00MB') + }) + + it('respects hideUnit option', () => { + expect(toGigabytes(1025, { hideUnit: true })).toBe('1.00') + expect(toGigabytes(1024 ** 2 + 1, { hideUnit: true })).toBe('1.00') + expect(toGigabytes(500, { hideUnit: true })).toBe('500') + expect(toGigabytes(1024, { hideUnit: true })).toBe('1024') + }) + + it('respects toFixed option', () => { + expect(toGigabytes(1536, { toFixed: 1 })).toBe('1.5KB') + expect(toGigabytes(1536, { toFixed: 3 })).toBe('1.500KB') + expect(toGigabytes(1024 ** 2 * 1.5, { toFixed: 0 })).toBe('2MB') + }) +}) + +describe('formatMegaBytes', () => { + it('formats values less than 1024 MB as GB', () => { + expect(formatMegaBytes(512)).toBe('0.50 GB') + expect(formatMegaBytes(1000)).toBe('0.98 GB') + expect(formatMegaBytes(1023)).toBe('1.00 GB') + }) + + it('formats values 1024*1024 MB and above as TB', () => { + expect(formatMegaBytes(1024 * 1024)).toBe('1.00 TB') + expect(formatMegaBytes(1024 * 1024 * 2.5)).toBe('2.50 TB') + }) + + it('formats exactly 1024 MB as GB', () => { + expect(formatMegaBytes(1024)).toBe('1.00 GB') + }) + + it('handles zero and small values', () => { + expect(formatMegaBytes(0)).toBe('0.00 GB') + expect(formatMegaBytes(1)).toBe('0.00 GB') + }) +}) + +describe('formatDuration', () => { + it('formats milliseconds when duration is less than 1 second', () => { + const start = Date.now() + const end = start + 500 + expect(formatDuration(start, end)).toBe('500ms') + }) + + it('formats seconds when duration is less than 1 minute', () => { + const start = Date.now() + const end = start + 30000 // 30 seconds + expect(formatDuration(start, end)).toBe('30s') + }) + + it('formats minutes and seconds when duration is less than 1 hour', () => { + const start = Date.now() + const end = start + 150000 // 2 minutes 30 seconds + expect(formatDuration(start, end)).toBe('2m 30s') + }) + + it('formats hours, minutes and seconds when duration is less than 1 day', () => { + const start = Date.now() + const end = start + 7890000 // 2 hours 11 minutes 30 seconds + expect(formatDuration(start, end)).toBe('2h 11m 30s') + }) + + it('formats days, hours, minutes and seconds for longer durations', () => { + const start = Date.now() + const end = start + 180000000 // 2 days 2 hours + expect(formatDuration(start, end)).toBe('2d 2h 0m 0s') + }) + + it('uses current time when endTime is not provided', () => { + vi.useFakeTimers() + const now = new Date('2023-01-01T12:00:00Z').getTime() + vi.setSystemTime(now) + + const start = now - 5000 // 5 seconds ago + expect(formatDuration(start)).toBe('5s') + + vi.useRealTimers() + }) + + it('handles negative durations (future start time)', () => { + const start = Date.now() + 1000 // 1 second in the future + const end = Date.now() + expect(formatDuration(start, end)).toBe( + 'Invalid duration (start time is in the future)' + ) + }) + + it('handles exact time boundaries', () => { + const start = 0 + expect(formatDuration(start, 1000)).toBe('1s') // exactly 1 second + expect(formatDuration(start, 60000)).toBe('1m 0s') // exactly 1 minute + expect(formatDuration(start, 3600000)).toBe('1h 0m 0s') // exactly 1 hour + expect(formatDuration(start, 86400000)).toBe('1d 0h 0m 0s') // exactly 1 day + }) +}) diff --git a/web-app/src/lib/model.spec.ts b/web-app/src/lib/model.spec.ts deleted file mode 100644 index 139597f9c..000000000 --- a/web-app/src/lib/model.spec.ts +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/web-app/src/test/setup.ts b/web-app/src/test/setup.ts new file mode 100644 index 000000000..c126d92fa --- /dev/null +++ b/web-app/src/test/setup.ts @@ -0,0 +1,11 @@ +import { expect, afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' +import * as matchers from '@testing-library/jest-dom/matchers' + +// extends Vitest's expect method with methods from react-testing-library +expect.extend(matchers) + +// runs a cleanup after each test case (e.g. clearing jsdom) +afterEach(() => { + cleanup() +}) \ No newline at end of file diff --git a/web-app/src/utils/__tests__/formatDate.test.ts b/web-app/src/utils/__tests__/formatDate.test.ts new file mode 100644 index 000000000..1c36b1846 --- /dev/null +++ b/web-app/src/utils/__tests__/formatDate.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { formatDate } from '../formatDate' + +describe('formatDate', () => { + it('formats Date objects correctly', () => { + const date = new Date('2023-12-25T15:30:45Z') + const formatted = formatDate(date) + + // The exact format depends on the system locale, but it should include key components + expect(formatted).toMatch(/Dec.*25.*2023/i) + expect(formatted).toMatch(/\d{1,2}:\d{2}/i) // time format + expect(formatted).toMatch(/(AM|PM)/i) + }) + + it('formats ISO string dates correctly', () => { + const isoString = '2023-01-15T09:45:30Z' + const formatted = formatDate(isoString) + + expect(formatted).toMatch(/Jan.*15.*2023/i) + expect(formatted).toMatch(/\d{1,2}:\d{2}/i) + expect(formatted).toMatch(/(AM|PM)/i) + }) + + it('formats timestamp numbers correctly', () => { + const timestamp = 1703519445000 // Dec 25, 2023 15:30:45 UTC + const formatted = formatDate(timestamp) + + expect(formatted).toMatch(/Dec.*25.*2023/i) + expect(formatted).toMatch(/\d{1,2}:\d{2}/i) + expect(formatted).toMatch(/(AM|PM)/i) + }) + + it('handles different months correctly', () => { + const dates = [ + '2023-01-01T12:00:00Z', + '2023-02-01T12:00:00Z', + '2023-03-01T12:00:00Z', + '2023-12-01T12:00:00Z' + ] + + const formatted = dates.map(formatDate) + + expect(formatted[0]).toMatch(/Jan.*1.*2023/i) + expect(formatted[1]).toMatch(/Feb.*1.*2023/i) + expect(formatted[2]).toMatch(/Mar.*1.*2023/i) + expect(formatted[3]).toMatch(/Dec.*1.*2023/i) + }) + + it('shows 12-hour format with AM/PM', () => { + const morningDate = '2023-06-15T09:30:00Z' + const eveningDate = '2023-06-15T21:30:00Z' + + const morningFormatted = formatDate(morningDate) + const eveningFormatted = formatDate(eveningDate) + + // Note: The exact AM/PM depends on timezone, but both should have AM or PM + expect(morningFormatted).toMatch(/(AM|PM)/i) + expect(eveningFormatted).toMatch(/(AM|PM)/i) + }) + + it('handles edge cases', () => { + // Test with very old and very new dates + const oldDate = '1900-01-01T00:00:00Z' + const futureDate = '2099-12-31T23:59:59Z' + + expect(() => formatDate(oldDate)).not.toThrow() + expect(() => formatDate(futureDate)).not.toThrow() + + expect(formatDate(oldDate)).toMatch(/Jan.*1.*1900/i) + // The futureDate might be affected by timezone - let's just check it doesn't throw + const futureDateResult = formatDate(futureDate) + expect(futureDateResult).toMatch(/\d{4}/) // Should contain a year + }) + + it('uses en-US locale formatting', () => { + const date = '2023-07-04T12:00:00Z' + const formatted = formatDate(date) + + // Should use US-style date formatting (Month Day, Year) + expect(formatted).toMatch(/Jul.*4.*2023/i) + // Should include abbreviated month name + expect(formatted).toMatch(/Jul/i) + }) +}) \ No newline at end of file diff --git a/web-app/src/utils/__tests__/number.test.ts b/web-app/src/utils/__tests__/number.test.ts new file mode 100644 index 000000000..ad5848f3c --- /dev/null +++ b/web-app/src/utils/__tests__/number.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { toNumber } from '../number' + +describe('toNumber', () => { + it('converts valid number strings to numbers', () => { + expect(toNumber('123')).toBe(123) + expect(toNumber('0')).toBe(0) + expect(toNumber('-45')).toBe(-45) + expect(toNumber('3.14')).toBe(3.14) + expect(toNumber('-2.5')).toBe(-2.5) + }) + + it('passes through actual numbers unchanged', () => { + expect(toNumber(42)).toBe(42) + expect(toNumber(0)).toBe(0) + expect(toNumber(-17)).toBe(-17) + expect(toNumber(3.14159)).toBe(3.14159) + }) + + it('returns 0 for invalid number strings', () => { + expect(toNumber('abc')).toBe(0) + expect(toNumber('12abc')).toBe(0) + expect(toNumber('hello')).toBe(0) + expect(toNumber('')).toBe(0) + expect(toNumber(' ')).toBe(0) + }) + + it('returns 0 for null and undefined', () => { + expect(toNumber(null)).toBe(0) + expect(toNumber(undefined)).toBe(0) + }) + + it('handles boolean values', () => { + expect(toNumber(true)).toBe(1) + expect(toNumber(false)).toBe(0) + }) + + it('handles arrays and objects', () => { + expect(toNumber([])).toBe(0) + expect(toNumber([1])).toBe(1) + expect(toNumber([1, 2])).toBe(0) // NaN case + expect(toNumber({})).toBe(0) + expect(toNumber({ a: 1 })).toBe(0) + }) + + it('handles special number cases', () => { + expect(toNumber(Infinity)).toBe(Infinity) + expect(toNumber(-Infinity)).toBe(-Infinity) + expect(toNumber(NaN)).toBe(0) // NaN gets converted to 0 + }) + + it('handles scientific notation strings', () => { + expect(toNumber('1e5')).toBe(100000) + expect(toNumber('2.5e-3')).toBe(0.0025) + expect(toNumber('1E10')).toBe(10000000000) + }) + + it('handles hex and octal strings', () => { + expect(toNumber('0x10')).toBe(16) + expect(toNumber('0o10')).toBe(8) + expect(toNumber('0b10')).toBe(2) + }) + + it('handles whitespace in strings', () => { + expect(toNumber(' 123 ')).toBe(123) + expect(toNumber('\t42\n')).toBe(42) + expect(toNumber('\r\n -5.5 \t')).toBe(-5.5) + }) +}) \ No newline at end of file diff --git a/web-app/vitest.config.ts b/web-app/vitest.config.ts new file mode 100644 index 000000000..36d8b6171 --- /dev/null +++ b/web-app/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + css: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + define: { + IS_TAURI: JSON.stringify('false'), + IS_MACOS: JSON.stringify('false'), + IS_WINDOWS: JSON.stringify('false'), + IS_LINUX: JSON.stringify('false'), + IS_IOS: JSON.stringify('false'), + IS_ANDROID: JSON.stringify('false'), + PLATFORM: JSON.stringify('web'), + VERSION: JSON.stringify('test'), + POSTHOG_KEY: JSON.stringify(''), + POSTHOG_HOST: JSON.stringify(''), + }, +}) \ No newline at end of file