c7644ade69
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig; fix composable + test type errors (writable computed returns, null guards, overload-compatible signatures, typed test helpers) — all type-level.
271 lines
7.5 KiB
TypeScript
271 lines
7.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { effectScope, nextTick, reactive, ref } from 'vue';
|
|
import { debouncedWatch, watchDebounced } from '.';
|
|
|
|
describe(watchDebounced, () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('does not fire before the source changes', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
|
|
|
vi.advanceTimersByTime(200);
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('defers the callback by the debounce delay', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
|
|
|
count.value = 1;
|
|
expect(cb).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(50);
|
|
expect(cb).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(50);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
|
});
|
|
|
|
it('coalesces rapid changes into a single trailing call', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
|
|
|
count.value = 1;
|
|
vi.advanceTimersByTime(80);
|
|
count.value = 2;
|
|
vi.advanceTimersByTime(80);
|
|
count.value = 3;
|
|
|
|
expect(cb).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
// The filtered callback receives the args of the latest watch trigger:
|
|
// new value 3, and old value 2 (the source value just before the last change).
|
|
expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function));
|
|
});
|
|
|
|
it('fires synchronously with no debounce (debounce = 0)', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { flush: 'sync' });
|
|
|
|
count.value = 1;
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
|
});
|
|
|
|
it('enforces maxWait under sustained changes', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, maxWait: 250, flush: 'sync' });
|
|
|
|
// Keep changing before the debounce timer can ever settle.
|
|
count.value = 1;
|
|
vi.advanceTimersByTime(80);
|
|
count.value = 2;
|
|
vi.advanceTimersByTime(80);
|
|
count.value = 3;
|
|
vi.advanceTimersByTime(80);
|
|
// 240ms elapsed, debounce has reset each time, but maxWait is 250ms.
|
|
expect(cb).not.toHaveBeenCalled();
|
|
|
|
count.value = 4;
|
|
vi.advanceTimersByTime(20);
|
|
// maxWait (250ms) elapsed -> forced invocation with the latest trigger args.
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function));
|
|
});
|
|
|
|
it('does not double-fire when maxWait and debounce settle together', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, maxWait: 200, flush: 'sync' });
|
|
|
|
count.value = 1;
|
|
// debounce settles at 100ms; maxWait would be at 200ms but is cleared.
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(200);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('supports a reactive debounce delay', () => {
|
|
const count = ref(0);
|
|
const delay = ref(100);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: delay, flush: 'sync' });
|
|
|
|
count.value = 1;
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
|
|
delay.value = 300;
|
|
count.value = 2;
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(200);
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('works with a getter source', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(() => count.value * 2, cb, { debounce: 100, flush: 'sync' });
|
|
|
|
count.value = 5;
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function));
|
|
});
|
|
|
|
it('works with an array of sources', () => {
|
|
const a = ref(0);
|
|
const b = ref('x');
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced([a, b], cb, { debounce: 100, flush: 'sync' });
|
|
|
|
a.value = 1;
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function));
|
|
});
|
|
|
|
it('works with a reactive object source and deep option', () => {
|
|
const state = reactive({ nested: { value: 0 } });
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(state, cb, { debounce: 100, deep: true, flush: 'sync' });
|
|
|
|
state.nested.value = 1;
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('fires immediately with the immediate option', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, immediate: true, flush: 'sync' });
|
|
|
|
// immediate runs through the filter synchronously only when debounce=0;
|
|
// with a positive debounce the immediate run is also debounced.
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.any(Function));
|
|
});
|
|
|
|
it('respects a custom flush timing', async () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
watchDebounced(count, cb, { debounce: 100, flush: 'post' });
|
|
|
|
count.value = 1;
|
|
await nextTick();
|
|
expect(cb).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(100);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns a handle that stops watching', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
|
|
const stop = watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
|
|
|
stop();
|
|
|
|
count.value = 1;
|
|
vi.advanceTimersByTime(200);
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stops watching when the owning scope is disposed', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
const scope = effectScope();
|
|
|
|
scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' }));
|
|
|
|
scope.stop();
|
|
|
|
count.value = 1;
|
|
vi.advanceTimersByTime(200);
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('honours a caller-supplied eventFilter over debounce/maxWait', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
const passthrough = vi.fn((invoke: () => void) => invoke());
|
|
|
|
watchDebounced(count, cb, {
|
|
debounce: 1000,
|
|
eventFilter: passthrough,
|
|
flush: 'sync',
|
|
});
|
|
|
|
count.value = 1;
|
|
// The custom filter invokes immediately, bypassing the debounce timer.
|
|
expect(passthrough).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
|
});
|
|
|
|
it('passes onCleanup to the callback', () => {
|
|
const count = ref(0);
|
|
const cleanup = vi.fn();
|
|
|
|
watchDebounced(count, (_value, _old, onCleanup) => {
|
|
onCleanup(cleanup);
|
|
}, { debounce: 100, flush: 'sync' });
|
|
|
|
count.value = 1;
|
|
vi.advanceTimersByTime(100);
|
|
count.value = 2;
|
|
vi.advanceTimersByTime(100);
|
|
|
|
// cleanup registered on the first settled run fires before the second.
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('exposes debouncedWatch as an alias', () => {
|
|
expect(debouncedWatch).toBe(watchDebounced);
|
|
});
|
|
|
|
it('runs in a non-DOM scope without touching globals (SSR-safe)', () => {
|
|
const count = ref(0);
|
|
const cb = vi.fn();
|
|
const scope = effectScope();
|
|
|
|
expect(() => {
|
|
scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' }));
|
|
}).not.toThrow();
|
|
|
|
scope.stop();
|
|
});
|
|
});
|