🔧test: util and lib unit tests

This commit is contained in:
Louis 2025-06-23 21:10:07 +07:00
parent 5edc773535
commit f70bb2705d
No known key found for this signature in database
GPG Key ID: 44FA9F4D33C37DE2
8 changed files with 661 additions and 2 deletions

View File

@ -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",

View File

@ -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 <img src="image.png" alt="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/'
)
})
})

View File

@ -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
})
})

View File

@ -1,2 +0,0 @@

11
web-app/src/test/setup.ts Normal file
View File

@ -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()
})

View File

@ -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)
})
})

View File

@ -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)
})
})

30
web-app/vitest.config.ts Normal file
View File

@ -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(''),
},
})