mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
|
|
import { StaleExpirationMap } from '../src/utils/stale-expiration-map.js';
|
||
|
|
|
||
|
|
describe('StaleExpirationMap', () => {
|
||
|
|
let map: StaleExpirationMap<string, number>;
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
// Always dispose to clear cleanup timers
|
||
|
|
map?.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('construction', () => {
|
||
|
|
it('creates an empty map', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
expect(map.isDisposed).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('defaults refreshOnGet to true', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
// Verify by setting, advancing time partially, getting (refreshes), advancing again
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(800); // 800ms of 1000ms TTL
|
||
|
|
map.get('a'); // should refresh
|
||
|
|
vi.advanceTimersByTime(800); // 1600ms total, but only 800ms since last access
|
||
|
|
expect(map.has('a')).toBe(true);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('set/get', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stores and retrieves values', () => {
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
expect(map.get('a')).toBe(1);
|
||
|
|
expect(map.get('b')).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns undefined for missing keys', () => {
|
||
|
|
expect(map.get('nonexistent')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('overwrites existing values', () => {
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('a', 10);
|
||
|
|
expect(map.get('a')).toBe(10);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('supports chaining on set', () => {
|
||
|
|
const result = map.set('a', 1).set('b', 2);
|
||
|
|
expect(result).toBe(map);
|
||
|
|
expect(map.size).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('ignores set when disposed', () => {
|
||
|
|
map.dispose();
|
||
|
|
map.set('a', 1);
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('TTL expiration', () => {
|
||
|
|
it('expires entries after TTL', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(1001);
|
||
|
|
expect(map.get('a')).toBeUndefined();
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('entries are accessible before TTL', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(500);
|
||
|
|
expect(map.get('a')).toBe(1);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('get refreshes TTL when refreshOnGet is true', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: true });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(800);
|
||
|
|
map.get('a'); // refreshes to now
|
||
|
|
vi.advanceTimersByTime(800); // 800ms after refresh, still within TTL
|
||
|
|
expect(map.get('a')).toBe(1);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('get does not refresh TTL when refreshOnGet is false', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: false });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(800);
|
||
|
|
map.get('a'); // does NOT refresh
|
||
|
|
vi.advanceTimersByTime(300); // 1100ms total, past TTL
|
||
|
|
expect(map.get('a')).toBeUndefined();
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('has', () => {
|
||
|
|
it('returns true for non-expired keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
expect(map.has('a')).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false for missing keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.has('nonexistent')).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false and deletes expired keys', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(1001);
|
||
|
|
expect(map.has('a')).toBe(false);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('peek', () => {
|
||
|
|
it('returns value without refreshing TTL', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(800);
|
||
|
|
expect(map.peek('a')).toBe(1); // does not refresh
|
||
|
|
vi.advanceTimersByTime(300); // 1100ms total
|
||
|
|
expect(map.peek('a')).toBeUndefined();
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns undefined for missing keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.peek('nonexistent')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('deletes expired entries on peek', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(1001);
|
||
|
|
map.peek('a');
|
||
|
|
// The internal entry should be deleted
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('delete', () => {
|
||
|
|
it('removes an existing key', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
expect(map.delete('a')).toBe(true);
|
||
|
|
expect(map.has('a')).toBe(false);
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false for non-existent key', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.delete('nonexistent')).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not call onExpire on manual delete', () => {
|
||
|
|
const onExpire = vi.fn();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, onExpire });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.delete('a');
|
||
|
|
expect(onExpire).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('clear', () => {
|
||
|
|
it('removes all entries', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
map.clear();
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
expect(map.get('a')).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('touch', () => {
|
||
|
|
it('refreshes TTL without returning value', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(800);
|
||
|
|
expect(map.touch('a')).toBe(true);
|
||
|
|
vi.advanceTimersByTime(800); // 1600ms total, but 800ms since touch
|
||
|
|
expect(map.has('a')).toBe(true);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false for non-existent keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.touch('nonexistent')).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns false for expired keys', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(1001);
|
||
|
|
expect(map.touch('a')).toBe(false);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getAge', () => {
|
||
|
|
it('returns age since creation', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 10000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(3000);
|
||
|
|
const age = map.getAge('a');
|
||
|
|
expect(age).toBe(3000);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns undefined for missing keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.getAge('nonexistent')).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getRemainingTtl', () => {
|
||
|
|
it('returns remaining time before expiration', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(300);
|
||
|
|
const remaining = map.getRemainingTtl('a');
|
||
|
|
expect(remaining).toBe(700);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 0 for expired entries (before cleanup removes them)', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
// Use a long cleanup interval so the entry is not auto-removed
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 60000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(2000);
|
||
|
|
expect(map.getRemainingTtl('a')).toBe(0);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns undefined after cleanup removes expired entry', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(2000); // cleanup fires and removes 'a'
|
||
|
|
expect(map.getRemainingTtl('a')).toBeUndefined();
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns undefined for missing keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.getRemainingTtl('nonexistent')).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('cleanup', () => {
|
||
|
|
it('removes expired entries and returns count', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
vi.advanceTimersByTime(500);
|
||
|
|
map.set('c', 3);
|
||
|
|
vi.advanceTimersByTime(600); // 'a' and 'b' at 1100ms, 'c' at 600ms
|
||
|
|
const removed = map.cleanup();
|
||
|
|
expect(removed).toBe(2);
|
||
|
|
expect(map.size).toBe(1);
|
||
|
|
expect(map.has('c')).toBe(true);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('calls onExpire for each expired entry', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const onExpire = vi.fn();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, onExpire });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
vi.advanceTimersByTime(1001);
|
||
|
|
map.cleanup();
|
||
|
|
expect(onExpire).toHaveBeenCalledTimes(2);
|
||
|
|
expect(onExpire).toHaveBeenCalledWith('a', 1);
|
||
|
|
expect(onExpire).toHaveBeenCalledWith('b', 2);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 0 when nothing is expired', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
expect(map.cleanup()).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('periodic cleanup runs automatically', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const onExpire = vi.fn();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(1500); // past TTL, and cleanup interval fires
|
||
|
|
expect(onExpire).toHaveBeenCalledWith('a', 1);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('iteration', () => {
|
||
|
|
it('iterates non-expired entries via for..of', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 5000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
const entries: [string, number][] = [];
|
||
|
|
for (const [k, v] of map) {
|
||
|
|
entries.push([k, v]);
|
||
|
|
}
|
||
|
|
expect(entries).toEqual([
|
||
|
|
['a', 1],
|
||
|
|
['b', 2],
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('skips expired entries during iteration', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
vi.advanceTimersByTime(500);
|
||
|
|
map.set('b', 2);
|
||
|
|
vi.advanceTimersByTime(600); // 'a' expired at 1100ms, 'b' at 600ms
|
||
|
|
|
||
|
|
const entries: [string, number][] = [];
|
||
|
|
for (const [k, v] of map) {
|
||
|
|
entries.push([k, v]);
|
||
|
|
}
|
||
|
|
expect(entries).toEqual([['b', 2]]);
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keys() yields non-expired keys', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 5000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
expect([...map.keys()]).toEqual(['a', 'b']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('values() yields non-expired values', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 5000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.set('b', 2);
|
||
|
|
expect([...map.values()]).toEqual([1, 2]);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('dispose', () => {
|
||
|
|
it('marks map as disposed', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
expect(map.isDisposed).toBe(false);
|
||
|
|
map.dispose();
|
||
|
|
expect(map.isDisposed).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clears all entries on dispose', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.dispose();
|
||
|
|
expect(map.size).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is idempotent', () => {
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||
|
|
map.dispose();
|
||
|
|
map.dispose(); // should not throw
|
||
|
|
expect(map.isDisposed).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stops periodic cleanup after dispose', () => {
|
||
|
|
vi.useFakeTimers();
|
||
|
|
const onExpire = vi.fn();
|
||
|
|
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire });
|
||
|
|
map.set('a', 1);
|
||
|
|
map.dispose();
|
||
|
|
vi.advanceTimersByTime(2000);
|
||
|
|
// onExpire should not be called because timer was stopped
|
||
|
|
expect(onExpire).not.toHaveBeenCalled();
|
||
|
|
vi.useRealTimers();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|