diff --git a/src/shared/utils/__tests__/retry.test.js b/src/shared/utils/__tests__/retry.test.js new file mode 100644 index 00000000..5aed2cc1 --- /dev/null +++ b/src/shared/utils/__tests__/retry.test.js @@ -0,0 +1,70 @@ +import { executeWithBackoff } from '../retry'; + +describe('executeWithBackoff', () => { + test('returns result on first success', async () => { + const fn = vi.fn().mockResolvedValue('ok'); + const result = await executeWithBackoff(fn); + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('retries on failure then succeeds', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValue('ok'); + + const result = await executeWithBackoff(fn, { + maxRetries: 3, + baseDelay: 1 + }); + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + test('throws after exhausting retries', async () => { + const fn = vi.fn().mockRejectedValue(new Error('always fails')); + + await expect( + executeWithBackoff(fn, { maxRetries: 2, baseDelay: 1 }) + ).rejects.toThrow('always fails'); + expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + test('stops retrying when shouldRetry returns false', async () => { + const fn = vi.fn().mockRejectedValue(new Error('permanent')); + + await expect( + executeWithBackoff(fn, { + maxRetries: 5, + baseDelay: 1, + shouldRetry: () => false + }) + ).rejects.toThrow('permanent'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('uses exponential backoff delays', async () => { + const delays = []; + const originalSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, delay) => { + delays.push(delay); + return originalSetTimeout(fn, 0); + }); + + const mockFn = vi + .fn() + .mockRejectedValueOnce(new Error('1')) + .mockRejectedValueOnce(new Error('2')) + .mockRejectedValueOnce(new Error('3')) + .mockResolvedValue('done'); + + await executeWithBackoff(mockFn, { maxRetries: 5, baseDelay: 100 }); + + // Delays: 100 * 2^0, 100 * 2^1, 100 * 2^2 + expect(delays).toEqual([100, 200, 400]); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/shared/utils/__tests__/setting.test.js b/src/shared/utils/__tests__/setting.test.js new file mode 100644 index 00000000..1e68da3e --- /dev/null +++ b/src/shared/utils/__tests__/setting.test.js @@ -0,0 +1,23 @@ +import { getVRChatResolution } from '../setting'; + +describe('getVRChatResolution', () => { + test.each([ + ['1280x720', '1280x720 (720p)'], + ['1920x1080', '1920x1080 (1080p)'], + ['2560x1440', '2560x1440 (1440p)'], + ['3840x2160', '3840x2160 (4K)'], + ['7680x4320', '7680x4320 (8K)'] + ])('maps %s to %s', (input, expected) => { + expect(getVRChatResolution(input)).toBe(expected); + }); + + test('returns Custom for unknown resolutions', () => { + expect(getVRChatResolution('1024x768')).toBe('1024x768 (Custom)'); + expect(getVRChatResolution('800x600')).toBe('800x600 (Custom)'); + }); + + test('handles empty/undefined input', () => { + expect(getVRChatResolution('')).toBe(' (Custom)'); + expect(getVRChatResolution(undefined)).toBe('undefined (Custom)'); + }); +}); diff --git a/src/shared/utils/__tests__/throttle.test.js b/src/shared/utils/__tests__/throttle.test.js new file mode 100644 index 00000000..7e7d6384 --- /dev/null +++ b/src/shared/utils/__tests__/throttle.test.js @@ -0,0 +1,56 @@ +import { createRateLimiter } from '../throttle'; + +describe('createRateLimiter', () => { + test('schedule executes function and returns result', async () => { + const limiter = createRateLimiter({ + limitPerInterval: 10, + intervalMs: 1000 + }); + const result = await limiter.schedule(() => 42); + expect(result).toBe(42); + }); + + test('schedule executes async functions', async () => { + const limiter = createRateLimiter({ + limitPerInterval: 10, + intervalMs: 1000 + }); + const result = await limiter.schedule( + () => new Promise((r) => setTimeout(() => r('async'), 10)) + ); + expect(result).toBe('async'); + }); + + test('allows bursts up to limit', async () => { + const limiter = createRateLimiter({ + limitPerInterval: 3, + intervalMs: 1000 + }); + const results = []; + for (let i = 0; i < 3; i++) { + results.push(await limiter.schedule(() => i)); + } + expect(results).toEqual([0, 1, 2]); + }); + + test('clear resets the rate limiter', async () => { + const limiter = createRateLimiter({ + limitPerInterval: 1, + intervalMs: 50 + }); + + await limiter.schedule(() => 'first'); + limiter.clear(); + // After clear, should be able to schedule immediately + const result = await limiter.schedule(() => 'second'); + expect(result).toBe('second'); + }); + + test('wait resolves without executing a function', async () => { + const limiter = createRateLimiter({ + limitPerInterval: 10, + intervalMs: 1000 + }); + await expect(limiter.wait()).resolves.toBeUndefined(); + }); +});