Files
readest/apps/readest-app/src/__tests__/utils/event.test.ts
T
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00

293 lines
10 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from 'vitest';
// We need to import the singleton, but also be able to test a fresh instance.
// The module exports a singleton `eventDispatcher`. Since the class is not
// exported, we test through the singleton and reset between tests.
import {
eventDispatcher,
markSettled,
onSettled,
__resetSettledEventsForTests,
} from '@/utils/event';
describe('EventDispatcher', () => {
// -----------------------------------------------------------------------
// Async listeners (on / off / dispatch)
// -----------------------------------------------------------------------
describe('async listeners', () => {
it('registers and dispatches an async listener', async () => {
const fn = vi.fn();
eventDispatcher.on('test-event', fn);
await eventDispatcher.dispatch('test-event', { foo: 'bar' });
expect(fn).toHaveBeenCalledOnce();
expect(fn.mock.calls[0]![0]).toBeInstanceOf(CustomEvent);
expect(fn.mock.calls[0]![0].detail).toEqual({ foo: 'bar' });
eventDispatcher.off('test-event', fn);
});
it('dispatches to multiple listeners in order', async () => {
const order: number[] = [];
const fn1 = vi.fn(() => {
order.push(1);
});
const fn2 = vi.fn(() => {
order.push(2);
});
eventDispatcher.on('multi', fn1);
eventDispatcher.on('multi', fn2);
await eventDispatcher.dispatch('multi');
expect(order).toEqual([1, 2]);
eventDispatcher.off('multi', fn1);
eventDispatcher.off('multi', fn2);
});
it('removes a listener with off()', async () => {
const fn = vi.fn();
eventDispatcher.on('rm-test', fn);
eventDispatcher.off('rm-test', fn);
await eventDispatcher.dispatch('rm-test');
expect(fn).not.toHaveBeenCalled();
});
it('off() on non-existent event does not throw', () => {
const fn = vi.fn();
expect(() => eventDispatcher.off('nonexistent', fn)).not.toThrow();
});
it('dispatch on event with no listeners does not throw', async () => {
await expect(eventDispatcher.dispatch('no-listeners', { x: 1 })).resolves.toBeUndefined();
});
it('dispatch with no detail sends undefined as detail argument', async () => {
const fn = vi.fn();
eventDispatcher.on('no-detail', fn);
await eventDispatcher.dispatch('no-detail');
// CustomEvent({ detail: undefined }) results in detail being null per spec
expect(fn.mock.calls[0]![0].detail).toBeNull();
eventDispatcher.off('no-detail', fn);
});
it('awaits async listeners sequentially', async () => {
const order: string[] = [];
const slowFn = vi.fn(async () => {
await new Promise((r) => setTimeout(r, 10));
order.push('slow');
});
const fastFn = vi.fn(() => {
order.push('fast');
});
eventDispatcher.on('seq', slowFn);
eventDispatcher.on('seq', fastFn);
await eventDispatcher.dispatch('seq');
// slow should complete before fast starts because dispatch awaits each
expect(order).toEqual(['slow', 'fast']);
eventDispatcher.off('seq', slowFn);
eventDispatcher.off('seq', fastFn);
});
});
// -----------------------------------------------------------------------
// Sync listeners (onSync / offSync / dispatchSync)
// -----------------------------------------------------------------------
describe('sync listeners', () => {
it('registers and dispatches a sync listener', () => {
const fn = vi.fn((_event: CustomEvent) => false);
eventDispatcher.onSync('sync-test', fn);
eventDispatcher.dispatchSync('sync-test', 42);
expect(fn).toHaveBeenCalledOnce();
expect(fn.mock.calls[0]![0].detail).toBe(42);
eventDispatcher.offSync('sync-test', fn);
});
it('returns true when a listener consumes the event', () => {
const consumer = vi.fn(() => true);
eventDispatcher.onSync('consume', consumer);
const result = eventDispatcher.dispatchSync('consume');
expect(result).toBe(true);
eventDispatcher.offSync('consume', consumer);
});
it('returns false when no listener consumes the event', () => {
const ignorer = vi.fn(() => false);
eventDispatcher.onSync('ignore', ignorer);
const result = eventDispatcher.dispatchSync('ignore');
expect(result).toBe(false);
eventDispatcher.offSync('ignore', ignorer);
});
it('stops at the first consumer (reverse order)', () => {
const fn1 = vi.fn(() => true);
const fn2 = vi.fn(() => false);
// fn1 is registered first, fn2 second. Dispatch iterates in reverse.
eventDispatcher.onSync('stop-early', fn1);
eventDispatcher.onSync('stop-early', fn2);
const result = eventDispatcher.dispatchSync('stop-early');
// fn2 (last registered) is called first in reverse, returns false
// then fn1 is called, returns true (consumed)
expect(fn2).toHaveBeenCalledOnce();
expect(fn1).toHaveBeenCalledOnce();
expect(result).toBe(true);
eventDispatcher.offSync('stop-early', fn1);
eventDispatcher.offSync('stop-early', fn2);
});
it('stops at consumer and does not call earlier listeners', () => {
const earlyFn = vi.fn(() => true);
const lateFn = vi.fn(() => true);
eventDispatcher.onSync('stop', earlyFn);
eventDispatcher.onSync('stop', lateFn);
eventDispatcher.dispatchSync('stop');
// lateFn (last registered) is called first in reverse, returns true -> stop
expect(lateFn).toHaveBeenCalledOnce();
expect(earlyFn).not.toHaveBeenCalled();
eventDispatcher.offSync('stop', earlyFn);
eventDispatcher.offSync('stop', lateFn);
});
it('removes a sync listener with offSync()', () => {
const fn = vi.fn(() => false);
eventDispatcher.onSync('off-sync', fn);
eventDispatcher.offSync('off-sync', fn);
eventDispatcher.dispatchSync('off-sync');
expect(fn).not.toHaveBeenCalled();
});
it('offSync on non-existent event does not throw', () => {
const fn = vi.fn(() => false);
expect(() => eventDispatcher.offSync('nope', fn)).not.toThrow();
});
it('dispatchSync on event with no listeners returns false', () => {
expect(eventDispatcher.dispatchSync('nobody-listens')).toBe(false);
});
});
// -----------------------------------------------------------------------
// Isolation between event names
// -----------------------------------------------------------------------
describe('event isolation', () => {
it('does not cross-fire between different event names', async () => {
const fn1 = vi.fn();
const fn2 = vi.fn();
eventDispatcher.on('event-a', fn1);
eventDispatcher.on('event-b', fn2);
await eventDispatcher.dispatch('event-a');
expect(fn1).toHaveBeenCalledOnce();
expect(fn2).not.toHaveBeenCalled();
eventDispatcher.off('event-a', fn1);
eventDispatcher.off('event-b', fn2);
});
it('sync and async listeners with same event name are independent', async () => {
const asyncFn = vi.fn();
const syncFn = vi.fn(() => false);
eventDispatcher.on('shared-name', asyncFn);
eventDispatcher.onSync('shared-name', syncFn);
await eventDispatcher.dispatch('shared-name');
expect(asyncFn).toHaveBeenCalledOnce();
expect(syncFn).not.toHaveBeenCalled();
eventDispatcher.dispatchSync('shared-name');
expect(syncFn).toHaveBeenCalledOnce();
expect(asyncFn).toHaveBeenCalledOnce(); // still only once
eventDispatcher.off('shared-name', asyncFn);
eventDispatcher.offSync('shared-name', syncFn);
});
});
});
describe('settled events (markSettled / onSettled)', () => {
afterEach(() => {
__resetSettledEventsForTests();
});
it('fires listeners that subscribed BEFORE settling', async () => {
const fn = vi.fn();
onSettled('boot', fn);
expect(fn).not.toHaveBeenCalled();
await markSettled('boot', { ready: true });
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith({ ready: true });
});
it('replays synchronously for listeners that subscribe AFTER settling', async () => {
await markSettled('boot', { ready: true });
const fn = vi.fn();
onSettled('boot', fn);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith({ ready: true });
});
it('fires multiple listeners; each fires exactly once', async () => {
const a = vi.fn();
const b = vi.fn();
onSettled('boot', a);
onSettled('boot', b);
await markSettled('boot');
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
// Idempotent: a second markSettled is a no-op.
await markSettled('boot');
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
});
it('auto-unsubscribes after fire — repeated dispatches do NOT re-fire', async () => {
const fn = vi.fn();
onSettled('boot', fn);
await markSettled('boot');
expect(fn).toHaveBeenCalledOnce();
// The bare eventDispatcher would normally fire again on re-dispatch;
// settled-event listeners must not.
await eventDispatcher.dispatch('boot', { stale: true });
expect(fn).toHaveBeenCalledOnce();
});
it('returned unsubscribe cancels a pre-settle subscription', async () => {
const fn = vi.fn();
const unsub = onSettled('boot', fn);
unsub();
await markSettled('boot');
expect(fn).not.toHaveBeenCalled();
});
it('returned unsubscribe is a no-op for late (already-replayed) subscribers', async () => {
await markSettled('boot');
const fn = vi.fn();
const unsub = onSettled('boot', fn);
expect(fn).toHaveBeenCalledOnce();
expect(() => unsub()).not.toThrow();
});
it('preserves typed payload through onSettled<T>', async () => {
interface BootPayload {
version: number;
}
let captured: BootPayload | null = null;
onSettled<BootPayload>('typed-boot', (p) => {
captured = p;
});
await markSettled('typed-boot', { version: 7 });
expect(captured).toEqual({ version: 7 });
});
it('different event names are independent', async () => {
const a = vi.fn();
const b = vi.fn();
onSettled('a', a);
onSettled('b', b);
await markSettled('a');
expect(a).toHaveBeenCalledOnce();
expect(b).not.toHaveBeenCalled();
});
});