diff options
| author | 2025-12-26 22:39:23 +0800 | |
|---|---|---|
| committer | 2025-12-26 22:39:23 +0800 | |
| commit | 32ca410f4edbff578d71781d943c41573912f476 (patch) | |
| tree | 49f7e1e5602657d23945082fe273fc4802959a40 /tests | |
Initial commitmain
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/cache-files.test.ts | 100 | ||||
| -rw-r--r-- | tests/cache-format.test.ts | 27 | ||||
| -rw-r--r-- | tests/cache-lock.test.ts | 141 | ||||
| -rw-r--r-- | tests/cache-manifest.test.ts | 76 | ||||
| -rw-r--r-- | tests/cache-metadata.test.ts | 21 | ||||
| -rw-r--r-- | tests/cache-utils.test.ts | 23 | ||||
| -rw-r--r-- | tests/debounce.test.ts | 60 | ||||
| -rw-r--r-- | tests/logger.test.ts | 26 | ||||
| -rw-r--r-- | tests/search-utils.test.ts | 24 |
9 files changed, 498 insertions, 0 deletions
diff --git a/tests/cache-files.test.ts b/tests/cache-files.test.ts new file mode 100644 index 0000000..0dc740a --- /dev/null +++ b/tests/cache-files.test.ts @@ -0,0 +1,100 @@ +import { test, expect, beforeEach, afterEach } from 'bun:test'; +import { calculateFileChecksum, ensureDir, cleanupDir } from '../scripts/cache/files'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const testDir = join(tmpdir(), 'bun-test-cache'); + +beforeEach(async () => { + await mkdir(testDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); +}); + +test('calculateFileChecksum returns consistent hash for same content', async () => { + const filePath = join(testDir, 'test.txt'); + await writeFile(filePath, 'Hello, World!'); + + const hash1 = await calculateFileChecksum(filePath); + const hash2 = await calculateFileChecksum(filePath); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // SHA256 produces 64 hex characters +}); + +test('calculateFileChecksum returns different hashes for different content', async () => { + const filePath1 = join(testDir, 'test1.txt'); + const filePath2 = join(testDir, 'test2.txt'); + + await writeFile(filePath1, 'Hello, World!'); + await writeFile(filePath2, 'Goodbye, World!'); + + const hash1 = await calculateFileChecksum(filePath1); + const hash2 = await calculateFileChecksum(filePath2); + + expect(hash1).not.toBe(hash2); +}); + +test('calculateFileChecksum handles empty file', async () => { + const filePath = join(testDir, 'empty.txt'); + await writeFile(filePath, ''); + + const hash = await calculateFileChecksum(filePath); + expect(hash).toHaveLength(64); +}); + +test('calculateFileChecksum handles larger file', async () => { + const filePath = join(testDir, 'large.txt'); + const content = 'x'.repeat(11 * 1024 * 1024); // ~11MB (over 10MB threshold) + await writeFile(filePath, content); + + const hash = await calculateFileChecksum(filePath); + expect(hash).toHaveLength(64); +}); + +test('ensureDir creates directory if it does not exist', async () => { + const newDir = join(testDir, 'new-directory'); + + await ensureDir(newDir); + + // Check if directory exists by trying to create a file in it + await writeFile(join(newDir, 'test.txt'), 'test'); + expect(true).toBe(true); // If we get here, directory exists +}); + +test('ensureDir does not error if directory already exists', async () => { + await ensureDir(testDir); + await ensureDir(testDir); // Should not throw + + expect(true).toBe(true); +}); + +test('cleanupDir removes directory and all contents', async () => { + const subDir = join(testDir, 'subdir'); + await mkdir(subDir); + await writeFile(join(subDir, 'file.txt'), 'content'); + await writeFile(join(testDir, 'file2.txt'), 'content2'); + + await cleanupDir(testDir); + + // Directory should be gone + let exists = true; + try { + await writeFile(join(testDir, 'test.txt'), 'test'); + } catch { + exists = false; + } + expect(exists).toBe(false); +}); + +test('cleanupDir handles non-existent directory', async () => { + const nonExistent = join(testDir, 'does-not-exist'); + + // Should not throw + await cleanupDir(nonExistent); + + expect(true).toBe(true); +}); diff --git a/tests/cache-format.test.ts b/tests/cache-format.test.ts new file mode 100644 index 0000000..33eb89b --- /dev/null +++ b/tests/cache-format.test.ts @@ -0,0 +1,27 @@ +import { test, expect } from 'bun:test'; + +// formatBytes is an internal function in cache.ts +// This test verifies the expected behavior +function formatBytes(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(2); +} + +test('formatBytes converts bytes to MB', () => { + expect(formatBytes(1024 * 1024)).toBe('1.00'); + expect(formatBytes(2 * 1024 * 1024)).toBe('2.00'); + expect(formatBytes(10.5 * 1024 * 1024)).toBe('10.50'); +}); + +test('formatBytes rounds to 2 decimal places', () => { + expect(formatBytes(1024 * 1024 + 1)).toBe('1.00'); + expect(formatBytes(1.234 * 1024 * 1024)).toBe('1.23'); +}); + +test('formatBytes handles zero', () => { + expect(formatBytes(0)).toBe('0.00'); +}); + +test('formatBytes handles small values', () => { + expect(formatBytes(512 * 1024)).toBe('0.50'); + expect(formatBytes(1024)).toBe('0.00'); +}); diff --git a/tests/cache-lock.test.ts b/tests/cache-lock.test.ts new file mode 100644 index 0000000..c67ba01 --- /dev/null +++ b/tests/cache-lock.test.ts @@ -0,0 +1,141 @@ +import { test, expect } from 'bun:test'; +import { generateInstanceId } from '../scripts/cache/lock'; +import type { CacheLock } from '../scripts/cache/utils'; + +test('generateInstanceId returns non-empty string', () => { + const id = generateInstanceId(); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); +}); + +test('generateInstanceId includes timestamp', () => { + const before = Date.now(); + const id = generateInstanceId(); + const after = Date.now(); + + const timestampPart = id.split('-')[0]; + const timestamp = parseInt(timestampPart, 10); + + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); +}); + +test('generateInstanceId includes random component', () => { + const id1 = generateInstanceId(); + const id2 = generateInstanceId(); + + expect(id1).not.toBe(id2); +}); + +test('generateInstanceId format is timestamp-randomstring', () => { + const id = generateInstanceId(); + const parts = id.split('-'); + expect(parts.length).toBe(2); + + const [timestamp, random] = parts; + expect(timestamp).toMatch(/^\d+$/); + expect(random).toMatch(/^[a-z0-9]+$/); + expect(random.length).toBeGreaterThan(0); + expect(random.length).toBeLessThan(10); +}); + +// Helper function to test isLockStale logic (since it's private) +function isLockStale(lock: CacheLock, currentHostname: string): boolean { + const lockAge = Date.now() - lock.timestamp; + const timeSinceRenewal = lock.renewedAt ? Date.now() - lock.renewedAt : lockAge; + + // Check 1: Timestamp-based staleness (30 minutes) + if (timeSinceRenewal > 30 * 60 * 1000) { + return true; + } + + // Check 2: Process-based staleness (only on same machine) + if (lock.hostname === currentHostname) { + // For testing, assume process doesn't exist if pid is -1 + if (lock.pid === -1) { + return true; + } + } + + return false; +} + +test('isLockStale returns true for old lock (over 30 minutes)', () => { + const lock: CacheLock = { + locked: true, + timestamp: Date.now() - 31 * 60 * 1000, // 31 minutes ago + instance: 'test-instance', + ttl: 30 * 60 * 1000, + pid: 12345, + hostname: 'test-host' + }; + + expect(isLockStale(lock, 'test-host')).toBe(true); +}); + +test('isLockStale returns false for recent lock (under 30 minutes)', () => { + const lock: CacheLock = { + locked: true, + timestamp: Date.now() - 29 * 60 * 1000, // 29 minutes ago + instance: 'test-instance', + ttl: 30 * 60 * 1000, + pid: 12345, + hostname: 'test-host' + }; + + expect(isLockStale(lock, 'test-host')).toBe(false); +}); + +test('isLockStale respects renewedAt timestamp', () => { + const now = Date.now(); + const lock: CacheLock = { + locked: true, + timestamp: now - 40 * 60 * 1000, // 40 minutes ago + instance: 'test-instance', + ttl: 30 * 60 * 1000, + renewedAt: now - 10 * 60 * 1000, // Renewed 10 minutes ago + pid: 12345, + hostname: 'test-host' + }; + + expect(isLockStale(lock, 'test-host')).toBe(false); +}); + +test('isLockStale returns true when lock on same host but process dead', () => { + const lock: CacheLock = { + locked: true, + timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago + instance: 'test-instance', + ttl: 30 * 60 * 1000, + pid: -1, // Simulate dead process + hostname: 'test-host' + }; + + expect(isLockStale(lock, 'test-host')).toBe(true); +}); + +test('isLockStale returns false when lock on different host (even if old process)', () => { + const lock: CacheLock = { + locked: true, + timestamp: Date.now() - 10 * 60 * 1000, + instance: 'test-instance', + ttl: 30 * 60 * 1000, + pid: -1, + hostname: 'different-host' + }; + + expect(isLockStale(lock, 'test-host')).toBe(false); +}); + +test('isLockStale handles missing renewedAt', () => { + const lock: CacheLock = { + locked: true, + timestamp: Date.now() - 35 * 60 * 1000, // 35 minutes ago + instance: 'test-instance', + ttl: 30 * 60 * 1000, + pid: 12345, + hostname: 'test-host' + }; + + expect(isLockStale(lock, 'test-host')).toBe(true); +}); diff --git a/tests/cache-manifest.test.ts b/tests/cache-manifest.test.ts new file mode 100644 index 0000000..d54cd8b --- /dev/null +++ b/tests/cache-manifest.test.ts @@ -0,0 +1,76 @@ +import { test, expect } from 'bun:test'; +import { findCacheByKey, findCacheByPrefix } from '../scripts/cache/manifest'; +import type { CacheManifest } from '../scripts/cache/utils'; + +test('findCacheByKey returns null when cache not found', () => { + const manifest: CacheManifest = { + version: 1, + caches: [ + { key: 'cache1.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 }, + { key: 'cache2.tzst', hash: 'def456', timestamp: 2000, lastAccessed: 2000 } + ] + }; + + expect(findCacheByKey(manifest, 'cache3.tzst')).toBeNull(); +}); + +test('findCacheByKey returns matching cache entry', () => { + const manifest: CacheManifest = { + version: 1, + caches: [ + { key: 'cache1.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 }, + { key: 'cache2.tzst', hash: 'def456', timestamp: 2000, lastAccessed: 2000 } + ] + }; + + const result = findCacheByKey(manifest, 'cache1.tzst'); + expect(result).not.toBeNull(); + expect(result?.key).toBe('cache1.tzst'); + expect(result?.hash).toBe('abc123'); +}); + +test('findCacheByPrefix returns null when no caches match prefix', () => { + const manifest: CacheManifest = { + version: 1, + caches: [ + { key: 'cache1.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 }, + { key: 'cache2.tzst', hash: 'def456', timestamp: 2000, lastAccessed: 2000 } + ] + }; + + expect(findCacheByPrefix(manifest, 'other-')).toBeNull(); +}); + +test('findCacheByPrefix returns most recently created cache', () => { + const manifest: CacheManifest = { + version: 1, + caches: [ + { key: 'extensions-abc123.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 }, + { key: 'extensions-def456.tzst', hash: 'def456', timestamp: 3000, lastAccessed: 3000 }, + { key: 'extensions-ghi789.tzst', hash: 'ghi789', timestamp: 2000, lastAccessed: 2000 } + ] + }; + + const result = findCacheByPrefix(manifest, 'extensions-'); + expect(result).not.toBeNull(); + expect(result?.key).toBe('extensions-def456.tzst'); + expect(result?.timestamp).toBe(3000); +}); + +test('findCacheByPrefix handles empty caches array', () => { + const manifest: CacheManifest = { + version: 1, + caches: [] + }; + + expect(findCacheByPrefix(manifest, 'extensions-')).toBeNull(); +}); + +test('findCacheByKey handles empty caches array', () => { + const manifest: CacheManifest = { + version: 1, + caches: [] + }; + + expect(findCacheByKey(manifest, 'cache1.tzst')).toBeNull(); +}); diff --git a/tests/cache-metadata.test.ts b/tests/cache-metadata.test.ts new file mode 100644 index 0000000..cf725c6 --- /dev/null +++ b/tests/cache-metadata.test.ts @@ -0,0 +1,21 @@ +import { test, expect } from 'bun:test'; + +// The getMetadataKey function is internal to metadata.ts +// This test verifies the expected behavior/format +function getMetadataKey(cacheKey: string): string { + return `${cacheKey}.meta.json`; +} + +test('getMetadataKey appends .meta.json to cache key', () => { + expect(getMetadataKey('cache.tzst')).toBe('cache.tzst.meta.json'); + expect(getMetadataKey('extensions-abc123.tzst')).toBe('extensions-abc123.tzst.meta.json'); + expect(getMetadataKey('my-cache.tar.zst')).toBe('my-cache.tar.zst.meta.json'); +}); + +test('getMetadataKey handles keys with path', () => { + expect(getMetadataKey('path/to/cache.tzst')).toBe('path/to/cache.tzst.meta.json'); +}); + +test('getMetadataKey handles empty string', () => { + expect(getMetadataKey('')).toBe('.meta.json'); +}); diff --git a/tests/cache-utils.test.ts b/tests/cache-utils.test.ts new file mode 100644 index 0000000..cd3beff --- /dev/null +++ b/tests/cache-utils.test.ts @@ -0,0 +1,23 @@ +import { test, expect } from 'bun:test'; +import { generateCacheKey, CACHE_KEY_PREFIX } from '../scripts/cache/utils'; + +test('generateCacheKey returns key with correct prefix', async () => { + const key = await generateCacheKey(); + + expect(key).toStartWith(CACHE_KEY_PREFIX); + expect(key).toEndWith('.tzst'); +}); + +test('generateCacheKey produces consistent hash for same content', async () => { + const key1 = await generateCacheKey(); + const key2 = await generateCacheKey(); + + expect(key1).toBe(key2); +}); + +test('generateCacheKey produces 64-character hash', async () => { + const key = await generateCacheKey(); + const hashPart = key.replace(CACHE_KEY_PREFIX, '').replace('.tzst', ''); + + expect(hashPart).toHaveLength(64); +}); diff --git a/tests/debounce.test.ts b/tests/debounce.test.ts new file mode 100644 index 0000000..da029a1 --- /dev/null +++ b/tests/debounce.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'bun:test'; +import { debounce } from '../src/lib/search/debounce'; + +test('debounce delays function execution', async () => { + const results: number[] = []; + const debouncedFn = debounce((value: number) => results.push(value), 100); + + debouncedFn(1); + debouncedFn(2); + debouncedFn(3); + + expect(results.length).toBe(0); + + await Bun.sleep(150); + expect(results.length).toBe(1); + expect(results[0]).toBe(3); +}); + +test('debounce resets timer on repeated calls', async () => { + const results: number[] = []; + const debouncedFn = debounce((value: number) => results.push(value), 100); + + debouncedFn(1); + await Bun.sleep(50); + debouncedFn(2); + await Bun.sleep(50); + debouncedFn(3); + await Bun.sleep(50); + + expect(results.length).toBe(0); + + await Bun.sleep(150); + expect(results.length).toBe(1); + expect(results[0]).toBe(3); +}); + +test('debounce allows multiple executions over time', async () => { + const results: number[] = []; + const debouncedFn = debounce((value: number) => results.push(value), 50); + + debouncedFn(1); + await Bun.sleep(100); + + debouncedFn(2); + await Bun.sleep(100); + + expect(results.length).toBe(2); + expect(results).toEqual([1, 2]); +}); + +test('debounce passes arguments correctly', async () => { + const results: [string, number][] = []; + const debouncedFn = debounce((text: string, num: number) => results.push([text, num]), 50); + + debouncedFn('hello', 42); + await Bun.sleep(100); + + expect(results.length).toBe(1); + expect(results[0]).toEqual(['hello', 42]); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..e19f466 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test'; + +// Re-implement the helper functions for testing (since they're private in the module) +function formatTransferStats(bytes: number, elapsedSeconds: number): string { + const sizeMB = (bytes / (1024 * 1024)).toFixed(2); + const speedMBps = (bytes / (1024 * 1024) / elapsedSeconds).toFixed(2); + return `${sizeMB} MB (${speedMBps} MB/s)`; +} + +test('formatTransferStats formats bytes correctly', () => { + // 10 MB over 1 second + expect(formatTransferStats(10 * 1024 * 1024, 1)).toBe('10.00 MB (10.00 MB/s)'); + + // 5.5 MB over 2 seconds (2.75 MB/s) + expect(formatTransferStats(5.5 * 1024 * 1024, 2)).toBe('5.50 MB (2.75 MB/s)'); + + // 1 KB (0.00 MB) + expect(formatTransferStats(1024, 1)).toBe('0.00 MB (0.00 MB/s)'); +}); + +test('formatTransferStats handles zero elapsed time', () => { + // Should handle gracefully (Infinity would be wrong) + const bytes = 1024 * 1024; // 1 MB + const result = formatTransferStats(bytes, 0); + expect(result).toContain('1.00 MB'); +}); diff --git a/tests/search-utils.test.ts b/tests/search-utils.test.ts new file mode 100644 index 0000000..18d3b45 --- /dev/null +++ b/tests/search-utils.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from 'bun:test'; +import { findSourceByFormattedName, formatSourceName } from '../src/lib/search/utils'; + +test('formatSourceName converts to lowercase and replaces spaces with dots', () => { + expect(formatSourceName('Example Source')).toBe('example.source'); + expect(formatSourceName('Multiple Spaces')).toBe('multiple.spaces'); + expect(formatSourceName('ALREADY LOWERCASE')).toBe('already.lowercase'); + expect(formatSourceName('Mixed Case')).toBe('mixed.case'); +}); + +test('findSourceByFormattedName returns "all" for "all"', () => { + expect(findSourceByFormattedName('all', ['Source A', 'Source B'])).toBe('all'); +}); + +test('findSourceByFormattedName finds matching source', () => { + const sources = ['Example Source', 'Another Source', 'Test']; + expect(findSourceByFormattedName('example.source', sources)).toBe('Example Source'); + expect(findSourceByFormattedName('another.source', sources)).toBe('Another Source'); +}); + +test('findSourceByFormattedName returns "all" when no match found', () => { + const sources = ['Example Source', 'Another Source']; + expect(findSourceByFormattedName('non.existent', sources)).toBe('all'); +}); |
