Files
readest/apps/readest-app/src/__tests__/utils/lru.test.ts
T
Huang Xin 0e516f6e56 chore(test): add unit tests and enforce dash-case naming for test files (#3715)
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk,
usage, txt-worker), services (EdgeTTSClient, transformers), and
suppress noisy console output across all test files. Rename 27
camelCase test files to dash-case for consistency.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:14:00 +02:00

472 lines
14 KiB
TypeScript

import { describe, test, expect, vi } from 'vitest';
import { LRUCache } from '@/utils/lru';
describe('LRUCache', () => {
describe('constructor', () => {
test('creates cache with valid capacity', () => {
const cache = new LRUCache<string, number>(3);
expect(cache.size()).toBe(0);
});
test('throws when capacity is 0', () => {
expect(() => new LRUCache<string, number>(0)).toThrow(
'LRUCache capacity must be greater than 0',
);
});
test('throws when capacity is negative', () => {
expect(() => new LRUCache<string, number>(-1)).toThrow(
'LRUCache capacity must be greater than 0',
);
});
test('throws when capacity is -Infinity', () => {
expect(() => new LRUCache<string, number>(-Infinity)).toThrow(
'LRUCache capacity must be greater than 0',
);
});
});
describe('set and get', () => {
test('stores and retrieves a value', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
expect(cache.get('a')).toBe(1);
});
test('returns undefined for missing key', () => {
const cache = new LRUCache<string, number>(3);
expect(cache.get('nonexistent')).toBeUndefined();
});
test('updates value for existing key', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('a', 2);
expect(cache.get('a')).toBe(2);
expect(cache.size()).toBe(1);
});
test('stores multiple values up to capacity', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.size()).toBe(3);
expect(cache.get('a')).toBe(1);
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
});
test('works with non-string keys', () => {
const cache = new LRUCache<number, string>(2);
cache.set(42, 'answer');
expect(cache.get(42)).toBe('answer');
});
test('works with object values', () => {
const cache = new LRUCache<string, { name: string }>(2);
const obj = { name: 'test' };
cache.set('key', obj);
expect(cache.get('key')).toBe(obj);
});
});
describe('eviction', () => {
test('evicts oldest entry when capacity exceeded', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.has('a')).toBe(false);
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
expect(cache.size()).toBe(2);
});
test('evicts correct entry after get reorders', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
// Access 'a' to make it most recently used
cache.get('a');
// Now 'b' is oldest, so 'b' should be evicted
cache.set('c', 3);
expect(cache.has('b')).toBe(false);
expect(cache.get('a')).toBe(1);
expect(cache.get('c')).toBe(3);
});
test('evicts multiple entries as new ones are added', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // evicts 'a'
cache.set('d', 4); // evicts 'b'
expect(cache.has('a')).toBe(false);
expect(cache.has('b')).toBe(false);
expect(cache.get('c')).toBe(3);
expect(cache.get('d')).toBe(4);
});
test('updating existing key does not trigger capacity eviction', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('a', 10); // update, not a new entry
expect(cache.size()).toBe(2);
expect(cache.get('a')).toBe(10);
expect(cache.get('b')).toBe(2);
});
});
describe('capacity 1', () => {
test('holds exactly one entry', () => {
const cache = new LRUCache<string, number>(1);
cache.set('a', 1);
expect(cache.size()).toBe(1);
expect(cache.get('a')).toBe(1);
});
test('evicts the only entry when a new one is added', () => {
const cache = new LRUCache<string, number>(1);
cache.set('a', 1);
cache.set('b', 2);
expect(cache.has('a')).toBe(false);
expect(cache.get('b')).toBe(2);
expect(cache.size()).toBe(1);
});
test('get on missing key does not disrupt state', () => {
const cache = new LRUCache<string, number>(1);
cache.set('a', 1);
expect(cache.get('missing')).toBeUndefined();
expect(cache.get('a')).toBe(1);
});
});
describe('has', () => {
test('returns true for existing key', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
expect(cache.has('a')).toBe(true);
});
test('returns false for missing key', () => {
const cache = new LRUCache<string, number>(3);
expect(cache.has('a')).toBe(false);
});
test('returns false for evicted key', () => {
const cache = new LRUCache<string, number>(1);
cache.set('a', 1);
cache.set('b', 2);
expect(cache.has('a')).toBe(false);
});
test('does not promote key (no LRU reorder)', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.has('a'); // should NOT move 'a' to most recent
cache.set('c', 3); // should evict 'a' (still oldest)
expect(cache.has('a')).toBe(false);
expect(cache.has('b')).toBe(true);
expect(cache.has('c')).toBe(true);
});
});
describe('delete', () => {
test('removes an existing key and returns true', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
expect(cache.delete('a')).toBe(true);
expect(cache.has('a')).toBe(false);
expect(cache.size()).toBe(0);
});
test('returns false for missing key', () => {
const cache = new LRUCache<string, number>(3);
expect(cache.delete('nonexistent')).toBe(false);
});
test('frees capacity for new entries after deletion', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.delete('a');
cache.set('c', 3);
// 'b' should still be present since we freed a slot
expect(cache.has('b')).toBe(true);
expect(cache.has('c')).toBe(true);
expect(cache.size()).toBe(2);
});
});
describe('clear', () => {
test('removes all entries', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.clear();
expect(cache.size()).toBe(0);
expect(cache.has('a')).toBe(false);
expect(cache.has('b')).toBe(false);
expect(cache.has('c')).toBe(false);
});
test('clearing empty cache is a no-op', () => {
const cache = new LRUCache<string, number>(3);
cache.clear();
expect(cache.size()).toBe(0);
});
test('cache is usable after clear', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.clear();
cache.set('b', 2);
expect(cache.size()).toBe(1);
expect(cache.get('b')).toBe(2);
});
});
describe('size', () => {
test('returns 0 for empty cache', () => {
const cache = new LRUCache<string, number>(5);
expect(cache.size()).toBe(0);
});
test('tracks insertions', () => {
const cache = new LRUCache<string, number>(5);
cache.set('a', 1);
expect(cache.size()).toBe(1);
cache.set('b', 2);
expect(cache.size()).toBe(2);
});
test('does not exceed capacity', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.size()).toBe(2);
});
test('decreases on delete', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.delete('a');
expect(cache.size()).toBe(1);
});
});
describe('entries', () => {
test('returns empty array for empty cache', () => {
const cache = new LRUCache<string, number>(3);
expect(cache.entries()).toEqual([]);
});
test('returns entries in most-recent-first order', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.entries()).toEqual([
['c', 3],
['b', 2],
['a', 1],
]);
});
test('reflects LRU reorder after get', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a'); // move 'a' to most recent
expect(cache.entries()).toEqual([
['a', 1],
['c', 3],
['b', 2],
]);
});
test('reflects order after update via set', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('a', 10); // update 'a', moves to most recent
expect(cache.entries()).toEqual([
['a', 10],
['c', 3],
['b', 2],
]);
});
test('reflects eviction', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // evicts 'a'
expect(cache.entries()).toEqual([
['c', 3],
['b', 2],
]);
});
});
describe('onEvict callback', () => {
test('called when entry is evicted due to capacity', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(2, onEvict);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // evicts 'a'
expect(onEvict).toHaveBeenCalledTimes(1);
expect(onEvict).toHaveBeenCalledWith('a', 1);
});
test('called when entry is updated via set', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(2, onEvict);
cache.set('a', 1);
cache.set('a', 2); // update triggers onEvict with old value
expect(onEvict).toHaveBeenCalledTimes(1);
expect(onEvict).toHaveBeenCalledWith('a', 1);
});
test('called when entry is deleted', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(2, onEvict);
cache.set('a', 1);
cache.delete('a');
expect(onEvict).toHaveBeenCalledTimes(1);
expect(onEvict).toHaveBeenCalledWith('a', 1);
});
test('not called when delete returns false', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(2, onEvict);
cache.delete('nonexistent');
expect(onEvict).not.toHaveBeenCalled();
});
test('called for each entry on clear', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(3, onEvict);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.clear();
expect(onEvict).toHaveBeenCalledTimes(3);
expect(onEvict).toHaveBeenCalledWith('a', 1);
expect(onEvict).toHaveBeenCalledWith('b', 2);
expect(onEvict).toHaveBeenCalledWith('c', 3);
});
test('not called on clear when cache is empty', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(3, onEvict);
cache.clear();
expect(onEvict).not.toHaveBeenCalled();
});
test('tracks all eviction events across operations', () => {
const evicted: Array<[string, number]> = [];
const onEvict = (key: string, value: number) => {
evicted.push([key, value]);
};
const cache = new LRUCache<string, number>(2, onEvict);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // evicts 'a'
cache.set('b', 20); // updates 'b', evicts old value
cache.delete('c'); // deletes 'c'
cache.clear(); // clears 'b'
expect(evicted).toEqual([
['a', 1],
['b', 2],
['c', 3],
['b', 20],
]);
});
test('not called on get', () => {
const onEvict = vi.fn();
const cache = new LRUCache<string, number>(2, onEvict);
cache.set('a', 1);
cache.get('a');
cache.get('nonexistent');
expect(onEvict).not.toHaveBeenCalled();
});
});
describe('LRU ordering stress', () => {
test('repeated gets maintain correct eviction order', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
// Access in order: b, a, c
cache.get('b');
cache.get('a');
cache.get('c');
// Now order from oldest to newest: b, a, c
// Adding 'd' should evict 'b'
cache.set('d', 4);
expect(cache.has('b')).toBe(false);
expect(cache.has('a')).toBe(true);
expect(cache.has('c')).toBe(true);
expect(cache.has('d')).toBe(true);
});
test('set on existing key moves it to most recent', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('a', 10); // 'a' becomes most recent
// oldest is 'b', then 'c', then 'a'
cache.set('d', 4); // evicts 'b'
expect(cache.has('b')).toBe(false);
cache.set('e', 5); // evicts 'c'
expect(cache.has('c')).toBe(false);
expect(cache.has('a')).toBe(true);
expect(cache.has('d')).toBe(true);
expect(cache.has('e')).toBe(true);
});
test('interleaved get and set maintains correct order', () => {
const evictedKeys: string[] = [];
const cache = new LRUCache<string, number>(3, (key) => {
evictedKeys.push(key);
});
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a'); // order: b, c, a
cache.set('d', 4); // evicts b; order: c, a, d
cache.get('c'); // order: a, d, c
cache.set('e', 5); // evicts a; order: d, c, e
expect(evictedKeys).toEqual(['b', 'a']);
expect(cache.entries()).toEqual([
['e', 5],
['c', 3],
['d', 4],
]);
});
});
});