Files
jellytau/src/lib/utils/debounce.test.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

298 lines
8.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* Utility function to create debounced functions
* Used in GenericMediaListPage for search input debouncing
*/
export function createDebouncedFunction<T extends (...args: any[]) => any>(
fn: T,
delayMs: number = 300,
) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
fn(...args);
timeout = null;
}, delayMs);
};
}
describe("Debounce Utility", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("Basic Debouncing", () => {
it("should delay function execution", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
debouncedFn("test");
// Should not be called immediately
expect(mockFn).not.toHaveBeenCalled();
// Advance time by 300ms
vi.advanceTimersByTime(300);
// Now it should be called
expect(mockFn).toHaveBeenCalledWith("test");
expect(mockFn).toHaveBeenCalledTimes(1);
});
it("should not call function if timer is cleared before delay", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
debouncedFn("test");
vi.advanceTimersByTime(150);
// Call again before delay completes
debouncedFn("updated");
// First timeout should be cleared
vi.advanceTimersByTime(150);
// Should still not have been called
expect(mockFn).not.toHaveBeenCalled();
// Complete the second timeout
vi.advanceTimersByTime(300);
// Should be called once with latest value
expect(mockFn).toHaveBeenCalledWith("updated");
expect(mockFn).toHaveBeenCalledTimes(1);
});
it("should handle multiple rapid calls", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
// Rapid calls
debouncedFn("a");
vi.advanceTimersByTime(100);
debouncedFn("b");
vi.advanceTimersByTime(100);
debouncedFn("c");
vi.advanceTimersByTime(100);
// Should not be called yet
expect(mockFn).not.toHaveBeenCalled();
// Complete the final timeout
vi.advanceTimersByTime(300);
// Should be called once with the last value
expect(mockFn).toHaveBeenCalledWith("c");
expect(mockFn).toHaveBeenCalledTimes(1);
});
it("should call multiple times if calls are spaced out", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
debouncedFn("first");
vi.advanceTimersByTime(300);
// Should be called
expect(mockFn).toHaveBeenCalledWith("first");
expect(mockFn).toHaveBeenCalledTimes(1);
// Wait enough time and call again
vi.advanceTimersByTime(200);
debouncedFn("second");
vi.advanceTimersByTime(300);
// Should be called again
expect(mockFn).toHaveBeenCalledWith("second");
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe("Custom Delay", () => {
it("should respect custom delay values", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 500);
debouncedFn("test");
// 300ms shouldn't trigger
vi.advanceTimersByTime(300);
expect(mockFn).not.toHaveBeenCalled();
// But 500ms should
vi.advanceTimersByTime(200);
expect(mockFn).toHaveBeenCalledWith("test");
});
it("should handle zero delay", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 0);
debouncedFn("test");
vi.advanceTimersByTime(0);
expect(mockFn).toHaveBeenCalledWith("test");
});
});
describe("Search Use Case", () => {
it("should debounce search queries correctly", () => {
const mockSearch = vi.fn();
const debouncedSearch = createDebouncedFunction(mockSearch, 300);
// User types "t"
debouncedSearch("t");
expect(mockSearch).not.toHaveBeenCalled();
// User types "te" quickly
vi.advanceTimersByTime(100);
debouncedSearch("te");
expect(mockSearch).not.toHaveBeenCalled();
// User types "tes"
vi.advanceTimersByTime(100);
debouncedSearch("tes");
expect(mockSearch).not.toHaveBeenCalled();
// User types "test"
vi.advanceTimersByTime(100);
debouncedSearch("test");
expect(mockSearch).not.toHaveBeenCalled();
// Wait for debounce delay
vi.advanceTimersByTime(300);
// Should only call once with final value
expect(mockSearch).toHaveBeenCalledWith("test");
expect(mockSearch).toHaveBeenCalledTimes(1);
});
it("should cancel pending search if input clears quickly", () => {
const mockSearch = vi.fn();
const debouncedSearch = createDebouncedFunction(mockSearch, 300);
// User types "test"
debouncedSearch("test");
vi.advanceTimersByTime(100);
// User clears input
debouncedSearch("");
vi.advanceTimersByTime(100);
// User types again
debouncedSearch("new");
vi.advanceTimersByTime(300);
// Should only call with final value
expect(mockSearch).toHaveBeenCalledWith("new");
expect(mockSearch).toHaveBeenCalledTimes(1);
});
it("should work with async search functions", () => {
const mockAsyncSearch = vi.fn().mockResolvedValue([]);
const debouncedSearch = createDebouncedFunction(mockAsyncSearch, 300);
debouncedSearch("query");
vi.advanceTimersByTime(300);
expect(mockAsyncSearch).toHaveBeenCalledWith("query");
});
});
describe("Generic Parameter Handling", () => {
it("should preserve function parameters", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
const obj = { id: "123", name: "test" };
debouncedFn("string", 42, obj);
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledWith("string", 42, obj);
});
it("should handle functions with no parameters", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
debouncedFn();
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledWith();
});
it("should handle complex object parameters", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
const options = {
query: "test",
filters: { type: "Audio", limit: 100 },
sort: { by: "SortName", order: "Ascending" },
};
debouncedFn(options);
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledWith(options);
});
});
describe("Memory Management", () => {
it("should clean up timeout after execution", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 300);
debouncedFn("test");
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalled();
const callCount = mockFn.mock.calls.length;
// Call again shortly after
debouncedFn("test2");
vi.advanceTimersByTime(100);
// Additional calls within delay shouldn't cause multiple executions
debouncedFn("test3");
vi.advanceTimersByTime(300);
// Should only have been called 2 times total
expect(mockFn.mock.calls.length).toBe(2);
});
it("should handle repeated debouncing without memory leaks", () => {
const mockFn = vi.fn();
const debouncedFn = createDebouncedFunction(mockFn, 50);
// Simulate 100 rapid calls
for (let i = 0; i < 100; i++) {
debouncedFn(`call${i}`);
vi.advanceTimersByTime(10);
}
// Complete final timeout
vi.advanceTimersByTime(50);
// Should only be called once with the last value
expect(mockFn).toHaveBeenCalledWith("call99");
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
});