Files
Codeman/test/debouncer.test.ts
arkonandClaude Opus 4.6 8e5f95d6ea test: add unit tests for Debouncer, CleanupManager, and timer migration refactors
New test files for utilities that previously had no dedicated coverage,
plus migration tests validating the ralph-tracker and respawn-controller
timer refactorings work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:50:23 +01:00

331 lines
7.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Debouncer, KeyedDebouncer } from '../src/utils/debouncer.js';
describe('Debouncer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('fires callback after delay', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
deb.schedule(fn);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(99);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledOnce();
});
it('resets timer when schedule() called again — only last callback fires', () => {
const deb = new Debouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule(fn1);
vi.advanceTimersByTime(50);
deb.schedule(fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
it('multiple rapid schedule() calls only fires the last one', () => {
const deb = new Debouncer(100);
const callbacks = Array.from({ length: 5 }, () => vi.fn());
for (const cb of callbacks) {
deb.schedule(cb);
}
vi.advanceTimersByTime(100);
for (let i = 0; i < callbacks.length - 1; i++) {
expect(callbacks[i]).not.toHaveBeenCalled();
}
expect(callbacks[callbacks.length - 1]).toHaveBeenCalledOnce();
});
it('cancel() prevents pending callback from firing', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
deb.schedule(fn);
vi.advanceTimersByTime(50);
deb.cancel();
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
it('isPending returns true when scheduled, false after fire', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
expect(deb.isPending).toBe(false);
deb.schedule(fn);
expect(deb.isPending).toBe(true);
vi.advanceTimersByTime(100);
expect(deb.isPending).toBe(false);
});
it('isPending returns false after cancel', () => {
const deb = new Debouncer(100);
deb.schedule(() => {});
expect(deb.isPending).toBe(true);
deb.cancel();
expect(deb.isPending).toBe(false);
});
it('dispose() is alias for cancel()', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
deb.schedule(fn);
expect(deb.isPending).toBe(true);
deb.dispose();
expect(deb.isPending).toBe(false);
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
it('flush() cancels pending and runs provided function immediately', () => {
const deb = new Debouncer(100);
const scheduled = vi.fn();
const flushed = vi.fn();
deb.schedule(scheduled);
deb.flush(flushed);
expect(flushed).toHaveBeenCalledOnce();
expect(deb.isPending).toBe(false);
vi.advanceTimersByTime(100);
expect(scheduled).not.toHaveBeenCalled();
});
it('can be reused after firing', () => {
const deb = new Debouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule(fn1);
vi.advanceTimersByTime(100);
expect(fn1).toHaveBeenCalledOnce();
deb.schedule(fn2);
vi.advanceTimersByTime(100);
expect(fn2).toHaveBeenCalledOnce();
});
it('can be reused after cancel', () => {
const deb = new Debouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule(fn1);
deb.cancel();
deb.schedule(fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
});
describe('KeyedDebouncer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('fires per-key callback after delay', () => {
const deb = new KeyedDebouncer(100);
const fn = vi.fn();
deb.schedule('a', fn);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('independent keys fire at their own timing', () => {
const deb = new KeyedDebouncer(100);
const fnA = vi.fn();
const fnB = vi.fn();
deb.schedule('a', fnA);
vi.advanceTimersByTime(50);
deb.schedule('b', fnB);
vi.advanceTimersByTime(50);
expect(fnA).toHaveBeenCalledOnce();
expect(fnB).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(fnB).toHaveBeenCalledOnce();
});
it('rescheduling same key resets timer — only last callback fires', () => {
const deb = new KeyedDebouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule('a', fn1);
vi.advanceTimersByTime(50);
deb.schedule('a', fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
it('cancelKey() only cancels specific key', () => {
const deb = new KeyedDebouncer(100);
const fnA = vi.fn();
const fnB = vi.fn();
deb.schedule('a', fnA);
deb.schedule('b', fnB);
deb.cancelKey('a');
vi.advanceTimersByTime(100);
expect(fnA).not.toHaveBeenCalled();
expect(fnB).toHaveBeenCalledOnce();
});
it('has() returns correct state per key', () => {
const deb = new KeyedDebouncer(100);
expect(deb.has('a')).toBe(false);
deb.schedule('a', () => {});
expect(deb.has('a')).toBe(true);
expect(deb.has('b')).toBe(false);
vi.advanceTimersByTime(100);
expect(deb.has('a')).toBe(false);
});
it('has() returns false after cancelKey()', () => {
const deb = new KeyedDebouncer(100);
deb.schedule('a', () => {});
deb.cancelKey('a');
expect(deb.has('a')).toBe(false);
});
it('size reflects active timer count', () => {
const deb = new KeyedDebouncer(100);
expect(deb.size).toBe(0);
deb.schedule('a', () => {});
expect(deb.size).toBe(1);
deb.schedule('b', () => {});
expect(deb.size).toBe(2);
vi.advanceTimersByTime(100);
expect(deb.size).toBe(0);
});
it('keys() returns active keys', () => {
const deb = new KeyedDebouncer(100);
deb.schedule('x', () => {});
deb.schedule('y', () => {});
deb.schedule('z', () => {});
const activeKeys = Array.from(deb.keys());
expect(activeKeys).toEqual(['x', 'y', 'z']);
});
it('dispose() cancels all keys', () => {
const deb = new KeyedDebouncer(100);
const fnA = vi.fn();
const fnB = vi.fn();
deb.schedule('a', fnA);
deb.schedule('b', fnB);
expect(deb.size).toBe(2);
deb.dispose();
expect(deb.size).toBe(0);
vi.advanceTimersByTime(100);
expect(fnA).not.toHaveBeenCalled();
expect(fnB).not.toHaveBeenCalled();
});
it('flushAll() cancels all timers and calls flush fn per active key', () => {
const deb = new KeyedDebouncer(100);
const scheduled1 = vi.fn();
const scheduled2 = vi.fn();
const flushFn = vi.fn();
deb.schedule('a', scheduled1);
deb.schedule('b', scheduled2);
deb.flushAll(flushFn);
expect(deb.size).toBe(0);
expect(flushFn).toHaveBeenCalledTimes(2);
expect(flushFn).toHaveBeenCalledWith('a');
expect(flushFn).toHaveBeenCalledWith('b');
vi.advanceTimersByTime(100);
expect(scheduled1).not.toHaveBeenCalled();
expect(scheduled2).not.toHaveBeenCalled();
});
it('flushAll() with no active keys does nothing', () => {
const deb = new KeyedDebouncer(100);
const flushFn = vi.fn();
deb.flushAll(flushFn);
expect(flushFn).not.toHaveBeenCalled();
});
it('cancelKey() on non-existent key is a no-op', () => {
const deb = new KeyedDebouncer(100);
expect(() => deb.cancelKey('nonexistent')).not.toThrow();
});
it('can be reused after dispose()', () => {
const deb = new KeyedDebouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule('a', fn1);
deb.dispose();
deb.schedule('b', fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
});