feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useThrottleFn } from '.';
describe(useThrottleFn, () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('invokes immediately on the leading edge', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100);
throttled();
expect(fn).toHaveBeenCalledOnce();
});
it('ignores calls within the window by default (no trailing)', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100);
throttled();
throttled();
throttled();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledOnce();
});
it('invokes trailing when enabled', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true);
throttled('a');
throttled('b');
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith('b');
});
it('resolves the promise with the function result', async () => {
const throttled = useThrottleFn((x: number) => x + 1, 100);
await expect(throttled(1)).resolves.toBe(2);
});
it('skips the leading edge when leading is false', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true, false);
// first call only opens the window (no leading edge)
throttled('a');
expect(fn).not.toHaveBeenCalled();
// a call inside the same window schedules the trailing edge
throttled('b');
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenLastCalledWith('b');
});
describe('options object', () => {
it('accepts delay/trailing/leading via an options object', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, { delay: 100, trailing: true });
throttled('a');
throttled('b');
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith('b');
});
it('defaults delay to 200 when omitted', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, {});
throttled(); // leading edge
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(199);
throttled(); // still inside the 200ms window → dropped (trailing off)
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(1); // 200ms elapsed — window reached
throttled(); // new window opens on the leading edge
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('reactive delay', () => {
it('reads the current delay on each call', () => {
const fn = vi.fn();
const delay = ref(100);
const throttled = useThrottleFn(fn, delay);
throttled();
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(101);
throttled();
expect(fn).toHaveBeenCalledTimes(2);
delay.value = 1000;
vi.advanceTimersByTime(101);
throttled();
// window grew to 1000ms, so this call is throttled
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('zero delay', () => {
it('invokes synchronously on every call when delay <= 0', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 0);
throttled();
throttled();
throttled();
expect(fn).toHaveBeenCalledTimes(3);
});
});
describe('cancel', () => {
it('drops a pending trailing invocation', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true);
throttled('a');
throttled('b');
expect(fn).toHaveBeenCalledOnce();
throttled.cancel();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('resolves the pending promise with undefined by default', async () => {
const throttled = useThrottleFn((x: number) => x, 100, true);
throttled(1);
const pending = throttled(2);
throttled.cancel();
await expect(pending).resolves.toBeUndefined();
});
});
describe('rejectOnCancel', () => {
it('rejects a superseded trailing promise', async () => {
const throttled = useThrottleFn((x: number) => x, 100, true, true, true);
throttled(1);
const superseded = throttled(2);
// next call within the window supersedes the previous trailing promise
const latest = throttled(3);
await expect(superseded).rejects.toThrow();
vi.advanceTimersByTime(100);
await expect(latest).resolves.toBe(3);
});
it('rejects on explicit cancel', async () => {
const throttled = useThrottleFn((x: number) => x, 100, true, true, true);
throttled(1);
const pending = throttled(2);
throttled.cancel();
await expect(pending).rejects.toThrow();
});
});
describe('flush', () => {
it('invokes the pending trailing call immediately', async () => {
const fn = vi.fn((x: number) => x);
const throttled = useThrottleFn(fn, 100, true);
throttled(1);
const pending = throttled(2);
expect(fn).toHaveBeenCalledOnce();
throttled.flush();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith(2);
await expect(pending).resolves.toBe(2);
});
it('is a no-op when nothing is pending', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true);
expect(() => throttled.flush()).not.toThrow();
expect(fn).not.toHaveBeenCalled();
});
});
it('preserves the calling context (this)', () => {
const obj = {
value: 42,
method: vi.fn(function (this: { value: number }) {
return this.value;
}),
};
const throttled = useThrottleFn(obj.method, 100);
throttled.call(obj);
expect(obj.method).toHaveReturnedWith(42);
});
});