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.
298 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|