fix(protocol): isolate catalog lookups and event subscriptions

This commit is contained in:
手瓜一十雪
2026-10-18 13:31:10 +08:00
parent fb39af6c28
commit 95a2d0d257
7 changed files with 384 additions and 206 deletions
@@ -37,7 +37,7 @@ describe('BridgeManager subscriptions', () => {
});
it('keeps subscription handles independent when an observer is reused', () => {
const listener = { started: vi.fn() };
const listener = { started: vi.fn(), closed: vi.fn() };
const unsubscribeFirst = manager.subscribeSessions(listener);
const unsubscribeSecond = manager.subscribeSessions(listener);
unsubscribeFirst();
@@ -45,9 +45,13 @@ describe('BridgeManager subscriptions', () => {
manager.onHookLogin(101, '10001', sender);
expect(listener.started).toHaveBeenCalledOnce();
manager.onPidDisconnected(101);
expect(listener.closed).toHaveBeenCalledOnce();
unsubscribeSecond();
manager.onHookLogin(202, '20002', sender);
manager.onPidDisconnected(202);
expect(listener.started).toHaveBeenCalledOnce();
expect(listener.closed).toHaveBeenCalledOnce();
});
it('applies listener changes to the next event while preserving dispatch order', () => {
@@ -1,36 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { BridgeManager } from '../src/bridge/manager';
describe('BridgeManager session subscriptions', () => {
it('unsubscribes repeated registrations independently', async () => {
const manager = new BridgeManager();
const started = vi.fn();
const closed = vi.fn();
const offStarted = manager.subscribeSessions({ started });
manager.subscribeSessions({ started });
const offClosed = manager.subscribeSessions({ closed });
manager.subscribeSessions({ closed });
offStarted();
offStarted();
offClosed();
offClosed();
manager.onHookLogin(4001, '12345', {} as never);
manager.onPidDisconnected(4001);
expect(started).toHaveBeenCalledTimes(1);
expect(closed).toHaveBeenCalledTimes(1);
await manager.dispose();
});
it('keeps the current fan-out stable when a listener unsubscribes a peer', async () => {
const manager = new BridgeManager();
const peer = vi.fn();
manager.subscribeSessions({ started: () => offPeer() });
const offPeer = manager.subscribeSessions({ started: peer });
manager.onHookLogin(4002, '12345', {} as never);
manager.onHookLogin(4003, '23456', {} as never);
expect(peer).toHaveBeenCalledTimes(1);
await manager.dispose();
});
});
+16 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SysFaceStore, type SysFaceCatalogStorage } from '@snowluma/protocol/sys-face-store';
import { BridgeManager } from '../src/bridge/manager';
import { bindSystemFaceCatalog } from '../src/sys-face-catalog';
@@ -9,6 +10,20 @@ const storage: SysFaceCatalogStorage = {
};
describe('bindSystemFaceCatalog', () => {
let manager: BridgeManager;
beforeEach(() => {
vi.spyOn(IdentityService, 'openForUin').mockImplementation((uin) =>
IdentityService.memory(uin),
);
manager = new BridgeManager();
});
afterEach(async () => {
await manager.dispose();
vi.restoreAllMocks();
});
it('starts one shared catalog prewarm from the first logged-in account', async () => {
let fetches = 0;
const store = new SysFaceStore({
@@ -36,7 +51,6 @@ describe('bindSystemFaceCatalog', () => {
];
},
});
const manager = new BridgeManager();
bindSystemFaceCatalog(manager, { store, storage });
const packetSender = { sendPacket: vi.fn() } as never;
+29 -34
View File
@@ -17,13 +17,8 @@ export interface EventBusOptions {
const DEFAULT_LOGGER = createLogger('Bridge.Events');
export class BridgeEventBus {
// Per-kind subscribers are stored as `Set<unknown>` so the container
// doesn't carry a contravariant function type that fights TS variance
// rules. The runtime invariant is straightforward: handlers stored under
// key `K` only ever see `EventOf<K>` events, which is what the single
// narrowing cast at the dispatch site below relies on.
private readonly handlers = new Map<EventKind, Set<unknown>>();
private readonly anyHandlers = new Set<AnyEventHandler>();
private readonly handlers = new Map<EventKind, Set<{ handler: unknown }>>();
private readonly anyHandlers = new Set<{ handler: AnyEventHandler }>();
private readonly onError: (kind: EventKind | '*', err: unknown) => void;
constructor(opts: EventBusOptions = {}) {
@@ -42,7 +37,7 @@ export class BridgeEventBus {
}
}
/** Subscribe to one kind. Returns an unsubscribe function. */
/** Each subscription owns its unsubscribe function. */
on<K extends EventKind>(kind: K, handler: EventHandler<K>): () => void {
let set = this.handlers.get(kind);
@@ -51,24 +46,35 @@ export class BridgeEventBus {
this.handlers.set(kind, set);
}
set.add(handler);
const subscription = { handler };
set.add(subscription);
return () => {
this.handlers.get(kind)?.delete(handler);
this.handlers.get(kind)?.delete(subscription);
};
}
/** Subscribe to every kind (low-volume callers like loggers). */
onAny(handler: AnyEventHandler): () => void {
this.anyHandlers.add(handler);
const subscription = { handler };
this.anyHandlers.add(subscription);
return () => {
this.anyHandlers.delete(handler);
this.anyHandlers.delete(subscription);
};
}
/** Remove every registration of this handler for the given kind. */
off<K extends EventKind>(kind: K, handler: EventHandler<K>): void {
this.handlers.get(kind)?.delete(handler);
const subscriptions = this.handlers.get(kind);
if (subscriptions) {
for (const subscription of subscriptions) {
if (subscription.handler === handler) {
subscriptions.delete(subscription);
}
}
}
}
has(kind: EventKind): boolean {
@@ -85,27 +91,20 @@ export class BridgeEventBus {
* Dispatch an event in parallel to every subscriber for its kind plus every
* onAny handler. Rejections are isolated per-handler — one bad subscriber
* never blocks the others, and the returned promise resolves once every
* subscriber settles.
* subscriber settles. Subscription changes apply to the next event.
*/
async emit<K extends EventKind>(event: EventOf<K>): Promise<void> {
const kindHandlers = this.handlers.get(event.kind);
const kindHandlers = [...(this.handlers.get(event.kind) ?? [])];
const anyHandlers = [...this.anyHandlers];
const tasks: Promise<void>[] = [];
if ((!kindHandlers || kindHandlers.size === 0) && this.anyHandlers.size === 0) {
return;
for (const { handler } of kindHandlers) {
// Registration keys guarantee this handler accepts the event's kind.
const typed = handler as EventHandler<K>;
tasks.push(invoke(this.onError, event.kind, () => typed(event)));
}
const tasks: Promise<unknown>[] = [];
if (kindHandlers) {
for (const handler of kindHandlers) {
// Single narrowing cast — the only place we re-assert that handlers
// stored under `event.kind` accept `EventOf<event.kind>` events.
const typed = handler as EventHandler<K>;
tasks.push(invoke(this.onError, event.kind, () => typed(event)));
}
}
for (const handler of this.anyHandlers) {
for (const { handler } of anyHandlers) {
tasks.push(invoke(this.onError, '*', () => handler(event)));
}
@@ -119,11 +118,7 @@ async function invoke(
fn: () => unknown,
): Promise<void> {
try {
const ret = fn();
if (ret && typeof (ret as Promise<unknown>).then === 'function') {
await ret;
}
await fn();
} catch (err) {
onError(kind, err);
}
+128 -131
View File
@@ -2,6 +2,7 @@
// 0x9154_1; this store makes that catalog durable, queryable, and safe to use
// from the asynchronous send path.
import { errorMessage } from '@snowluma/common/errors';
import { createLogger } from '@snowluma/common/logger';
import {
FetchSysFaces,
@@ -41,11 +42,14 @@ export interface SysFaceStoreOptions {
now?: () => number;
}
type FaceLookup = { promise: Promise<SysFaceEntry | null> } | { entry: SysFaceEntry | null };
interface IndexedCatalog {
packs: SysFacePackEntry[];
byId: Map<string, SysFaceEntry>;
entryCount: number;
overlapCount: number;
lookups: Map<number, FaceLookup>;
}
/** Use animation metadata when available; any numeric id has a small-face encoding. */
@@ -76,14 +80,10 @@ export class SysFaceStore {
private readonly fetchCatalog: (sender: OidbSender) => Promise<SysFacePackEntry[]>;
private readonly now: () => number;
private readonly fetchFace: NonNullable<SysFaceStoreOptions['fetchFace']>;
private readonly singleFaces = new Map<number, SysFaceEntry | null>();
private readonly singleInflight = new Map<number, Promise<SysFaceEntry | null>>();
private cacheLoadCompleted = false;
private cacheLoadInflight: Promise<boolean> | null = null;
private cacheLoad: Promise<void> | null = null;
private refreshInflight: Promise<SysFacePackEntry[]> | null = null;
private refreshedInProcess = false;
private prewarmInflight: Promise<void> | null = null;
private prewarmed = false;
private prewarmTask: Promise<void> | null = null;
constructor(options: SysFaceStoreOptions = {}) {
this.storage = options.storage ?? null;
@@ -95,12 +95,7 @@ export class SysFaceStore {
/** Configure persistence before the shared store has been used. */
configureStorage(storage: SysFaceCatalogStorage): void {
if (
this.cacheLoadCompleted ||
this.cacheLoadInflight ||
this.catalog ||
this.refreshInflight
) {
if (this.cacheLoad || this.catalog || this.refreshInflight) {
throw new Error('system face catalog storage must be configured before first use');
}
@@ -111,11 +106,14 @@ export class SysFaceStore {
* obtained a full catalog and want to reuse the same query/send semantics. */
load(packs: SysFacePackEntry[]): void {
this.catalog = indexCatalog(validateCatalog(packs));
this.singleFaces.clear();
}
lookup(faceId: number): SysFaceEntry | null {
return this.catalog?.byId.get(String(faceId)) ?? this.singleFaces.get(faceId) ?? null;
const lookup = this.catalog?.lookups.get(faceId);
return (
this.catalog?.byId.get(String(faceId)) ??
(lookup && 'entry' in lookup ? lookup.entry : null)
);
}
/** Case-insensitive query over id, description, aliases, and pack name. */
@@ -166,47 +164,27 @@ export class SysFaceStore {
/** Load durable state, fetching only when no usable catalog exists. */
async ensureReady(sender: OidbSender): Promise<void> {
if (this.refreshInflight) {
await this.refreshInflight;
return;
}
await (this.refreshInflight ?? this.loadCache());
if (this.catalog) {
return;
}
const loaded = await this.loadCache();
if (!loaded) {
if (this.refreshInflight || !this.catalog) {
await this.refresh(sender);
}
}
/** Login hook: restore the durable snapshot for immediate reads, then refresh
* from QQ exactly once per process. Multiple accounts share the same job. */
/** Restore once, then refresh from the first logged-in account. */
prewarm(sender: OidbSender): Promise<void> {
if (this.prewarmed) {
return Promise.resolve();
}
if (this.prewarmInflight) {
return this.prewarmInflight;
}
this.prewarmInflight = (async () => {
return (this.prewarmTask ??= (async () => {
try {
await this.loadCache();
} catch {
// loadCache already emitted the path and failure. A fresh server fetch
// is the authoritative repair for a corrupt rebuildable snapshot.
// The failed restore is logged; the server refresh repairs the cache.
}
await this.refresh(sender);
this.prewarmed = true;
})().finally(() => {
this.prewarmInflight = null;
});
return this.prewarmInflight;
})().catch((error) => {
this.prewarmTask = null;
throw error;
}));
}
/** Fetch the current authoritative catalog. Concurrent callers share one
@@ -225,9 +203,9 @@ export class SysFaceStore {
fetchedAt: this.now(),
packs: validated,
};
this.catalog = indexCatalog(validated);
const catalog = indexCatalog(validated);
this.catalog = catalog;
this.refreshedInProcess = true;
this.singleFaces.clear();
if (this.storage) {
await this.storage.save(snapshot);
@@ -235,9 +213,9 @@ export class SysFaceStore {
log.info(
'system face catalog refreshed: entries=%d uniqueFaces=%d overlaps=%d packs=%d elapsedMs=%d',
this.catalog.entryCount,
this.catalog.byId.size,
this.catalog.overlapCount,
catalog.entryCount,
catalog.byId.size,
catalog.overlapCount,
validated.length,
this.now() - startedAt,
);
@@ -247,7 +225,7 @@ export class SysFaceStore {
log.error(
'system face catalog refresh failed: elapsedMs=%d error=%s',
this.now() - startedAt,
error instanceof Error ? error.message : String(error),
errorMessage(error),
);
throw error;
})
@@ -275,51 +253,76 @@ export class SysFaceStore {
}
}
if (this.singleFaces.has(faceId)) {
return this.singleFaces.get(faceId)!;
const catalog = this.catalog;
if (!catalog) {
throw new Error('system face catalog is unavailable after readiness completed');
}
const pending = this.singleInflight.get(faceId);
const lookup = catalog.lookups.get(faceId);
if (pending) {
return pending;
if (lookup && 'entry' in lookup) {
return lookup.entry;
}
const request = this.fetchFace(sender, faceId)
.then((entry) => {
if (entry) {
if (entry.qSid !== String(faceId)) {
throw new Error(
`system face lookup id mismatch: requested=${faceId} received=${entry.qSid}`,
);
}
const request =
lookup?.promise ??
this.fetchSingleFace(sender, faceId)
.then((entry) => {
catalog.lookups.set(faceId, { entry });
return entry;
})
.catch((error) => {
catalog.lookups.delete(faceId);
throw error;
});
catalog.lookups.set(faceId, { promise: request });
validateCatalog([{ packName: '', emojis: [entry] }]);
faceWireFor(entry, faceId);
try {
const entry = await request;
if (this.catalog === catalog) {
return entry;
}
} catch (error) {
if (this.catalog === catalog) {
throw error;
}
}
// A replacement catalog owns its own lookups; callers follow the current one.
return this.resolve(sender, faceId);
}
private async fetchSingleFace(
sender: OidbSender,
faceId: number,
): Promise<SysFaceEntry | null> {
try {
const entry = await this.fetchFace(sender, faceId);
if (entry) {
if (entry.qSid !== String(faceId)) {
throw new Error(
`system face lookup id mismatch: requested=${faceId} received=${entry.qSid}`,
);
}
this.singleFaces.set(faceId, entry);
log.debug(
'system face lookup completed: id=%d found=%s animated=%s',
faceId,
entry !== null,
entry !== null && isSuperFaceEntry(entry),
);
return entry;
})
.catch((error) => {
log.error(
'system face lookup failed: id=%d error=%s',
faceId,
error instanceof Error ? error.message : String(error),
);
throw error;
})
.finally(() => {
this.singleInflight.delete(faceId);
});
this.singleInflight.set(faceId, request);
return request;
validateCatalog([{ packName: '', emojis: [entry] }]);
faceWireFor(entry, faceId);
}
log.debug(
'system face lookup completed: id=%d found=%s animated=%s',
faceId,
entry !== null,
entry !== null && isSuperFaceEntry(entry),
);
return entry;
} catch (error) {
log.error('system face lookup failed: id=%d error=%s', faceId, errorMessage(error));
throw error;
}
}
async resolveWire(sender: OidbSender, faceId: number): Promise<FaceWire> {
@@ -349,61 +352,54 @@ export class SysFaceStore {
return this.catalog.packs;
}
private loadCache(): Promise<boolean> {
private loadCache(): Promise<void> {
if (this.catalog) {
return Promise.resolve(true);
return Promise.resolve();
}
if (this.cacheLoadCompleted) {
return Promise.resolve(false);
}
if (this.cacheLoadInflight) {
return this.cacheLoadInflight;
}
return (this.cacheLoad ??= this.restoreCache());
}
private async restoreCache(): Promise<void> {
if (!this.storage) {
this.cacheLoadCompleted = true;
return Promise.resolve(false);
return;
}
const startedAt = this.now();
this.cacheLoadInflight = this.storage
.load()
.then((snapshot) => {
this.cacheLoadCompleted = true;
if (!snapshot) {
return false;
}
try {
const snapshot = await this.storage.load();
validateSnapshot(snapshot);
this.catalog = indexCatalog(snapshot.packs);
log.info(
'system face catalog restored: entries=%d uniqueFaces=%d overlaps=%d packs=%d ' +
'fetchedAt=%d elapsedMs=%d',
this.catalog.entryCount,
this.catalog.byId.size,
this.catalog.overlapCount,
snapshot.packs.length,
snapshot.fetchedAt,
this.now() - startedAt,
);
return true;
})
.catch((error) => {
this.cacheLoadCompleted = true;
log.error(
'system face catalog restore failed: elapsedMs=%d error=%s',
this.now() - startedAt,
error instanceof Error ? error.message : String(error),
);
throw error;
})
.finally(() => {
this.cacheLoadInflight = null;
});
return this.cacheLoadInflight;
// A newer load or refresh supersedes this disk read.
if (this.catalog || !snapshot) {
return;
}
validateSnapshot(snapshot);
const catalog = indexCatalog(snapshot.packs);
this.catalog = catalog;
log.info(
'system face catalog restored: entries=%d uniqueFaces=%d overlaps=%d packs=%d ' +
'fetchedAt=%d elapsedMs=%d',
catalog.entryCount,
catalog.byId.size,
catalog.overlapCount,
snapshot.packs.length,
snapshot.fetchedAt,
this.now() - startedAt,
);
} catch (error) {
if (this.catalog) {
return;
}
log.error(
'system face catalog restore failed: elapsedMs=%d error=%s',
this.now() - startedAt,
errorMessage(error),
);
throw error;
}
}
}
@@ -574,6 +570,7 @@ function indexCatalog(packs: SysFacePackEntry[]): IndexedCatalog {
byId,
entryCount,
overlapCount: entryCount - byId.size,
lookups: new Map(),
};
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import type { FriendMessage, GroupMessage } from '@snowluma/protocol/events';
import { BridgeEventBus } from '../src/event-bus';
import type { FriendMessage, GroupMessage } from '../src/events';
function makeFriendMessage(): FriendMessage {
return {
@@ -33,6 +33,77 @@ function makeGroupMessage(): GroupMessage {
}
describe('BridgeEventBus', () => {
it('keeps the current kind and wildcard subscribers stable while dispatching', async () => {
const bus = new BridgeEventBus();
const peer = vi.fn();
const tap = vi.fn();
const later = vi.fn();
const laterTap = vi.fn();
bus.on('friend_message', () => {
offPeer();
offTap();
bus.on('friend_message', later);
bus.onAny(laterTap);
});
const offPeer = bus.on('friend_message', peer);
const offTap = bus.onAny(tap);
await bus.emit(makeFriendMessage());
expect(peer).toHaveBeenCalledOnce();
expect(tap).toHaveBeenCalledOnce();
expect(later).not.toHaveBeenCalled();
expect(laterTap).not.toHaveBeenCalled();
await bus.emit(makeFriendMessage());
expect(peer).toHaveBeenCalledOnce();
expect(tap).toHaveBeenCalledOnce();
expect(later).toHaveBeenCalledOnce();
expect(laterTap).toHaveBeenCalledOnce();
});
it('finishes the current dispatch when a handler clears subscriptions', async () => {
const bus = new BridgeEventBus();
const peer = vi.fn();
const tap = vi.fn();
bus.on('friend_message', () => bus.clear());
bus.on('friend_message', peer);
bus.onAny(tap);
await bus.emit(makeFriendMessage());
await bus.emit(makeFriendMessage());
expect(peer).toHaveBeenCalledOnce();
expect(tap).toHaveBeenCalledOnce();
});
it.each(['kind', 'any'] as const)('owns each %s subscription independently', async (type) => {
const bus = new BridgeEventBus();
const handler = vi.fn();
const subscribe = () =>
type === 'kind' ? bus.on('friend_message', handler) : bus.onAny(handler);
const off = subscribe();
subscribe();
off();
off();
await bus.emit(makeFriendMessage());
expect(handler).toHaveBeenCalledOnce();
bus.clear();
subscribe();
off();
await bus.emit(makeFriendMessage());
expect(handler).toHaveBeenCalledTimes(2);
});
it('off removes every registration of the named handler', async () => {
const bus = new BridgeEventBus();
const handler = vi.fn();
bus.on('friend_message', handler);
bus.on('friend_message', handler);
bus.off('friend_message', handler);
expect(bus.has('friend_message')).toBe(false);
await bus.emit(makeFriendMessage());
expect(handler).not.toHaveBeenCalled();
});
it('routes events only to subscribers of the matching kind', async () => {
const bus = new BridgeEventBus();
const friend = vi.fn();
+133
View File
@@ -26,6 +26,16 @@ import {
import { buildSendElems } from '../src/element-builder';
import { FetchSysFace } from '../src/oidb-services/sys-faces/fetch-sys-face';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function entry(
qSid: string,
aniStickerType: number | null,
@@ -296,6 +306,79 @@ describe('FetchSysFaces.deserialize', () => {
describe('SysFaceStore — persistent catalog and query seam', () => {
const sender = {} as never;
it.each(['snapshot', 'read error'] as const)(
'does not replace a refreshed catalog with a late cache %s',
async (result) => {
const cache = deferred<SysFaceCatalogSnapshot | null>();
const store = new SysFaceStore({
storage: { load: () => cache.promise, save: async () => {} },
fetchCatalog: async () => CATALOG,
});
const ready = store.ensureReady(sender);
await store.refresh(sender);
if (result === 'snapshot') {
cache.resolve({ schemaVersion: 1, fetchedAt: 1, packs: CATALOG.slice(0, 1) });
} else {
cache.reject(new Error('obsolete cache read failed'));
}
await ready;
await expect(store.getCatalog(sender)).resolves.toEqual(CATALOG);
expect(store.lookup(392)?.qSid).toBe('392');
},
);
it('waits for a refresh that started during the disk read', async () => {
const cache = deferred<SysFaceCatalogSnapshot | null>();
const remote = deferred<SysFacePackEntry[]>();
const store = new SysFaceStore({
storage: { load: () => cache.promise, save: async () => {} },
fetchCatalog: () => remote.promise,
});
let ready = false;
const reading = store.ensureReady(sender).then(() => {
ready = true;
});
const refreshing = store.refresh(sender);
cache.resolve({ schemaVersion: 1, fetchedAt: 1, packs: CATALOG.slice(0, 1) });
await new Promise((resolve) => setImmediate(resolve));
expect(ready).toBe(false);
remote.resolve(CATALOG);
await Promise.all([reading, refreshing]);
expect(store.lookup(392)?.qSid).toBe('392');
});
it('retains a failed cache read until an explicit refresh repairs the catalog', async () => {
const load = vi.fn(async () => {
throw new Error('corrupt cache');
});
const fetchCatalog = vi.fn(async () => CATALOG);
const store = new SysFaceStore({ storage: { load, save: async () => {} }, fetchCatalog });
await expect(store.ensureReady(sender)).rejects.toThrow('corrupt cache');
await expect(store.ensureReady(sender)).rejects.toThrow('corrupt cache');
expect(load).toHaveBeenCalledOnce();
expect(fetchCatalog).not.toHaveBeenCalled();
await store.refresh(sender);
await expect(store.getCatalog(sender)).resolves.toEqual(CATALOG);
});
it('retries a failed login prewarm, then shares the completed job', async () => {
const load = vi.fn(async () => null);
const fetchCatalog = vi
.fn()
.mockRejectedValueOnce(new Error('network failed'))
.mockResolvedValueOnce(CATALOG);
const store = new SysFaceStore({ storage: { load, save: async () => {} }, fetchCatalog });
await expect(store.prewarm(sender)).rejects.toThrow('network failed');
const retry = store.prewarm(sender);
expect(store.prewarm(sender)).toBe(retry);
await retry;
await store.prewarm(sender);
expect(load).toHaveBeenCalledOnce();
expect(fetchCatalog).toHaveBeenCalledTimes(2);
});
it('accepts face id 0 from the authoritative catalog', async () => {
const zero = entry('0', null, null, null, '/惊讶');
const store = new SysFaceStore({
@@ -738,6 +821,56 @@ describe('makeFaceElem (via buildSendElems) — three-way wire encoding', () =>
});
describe('system faces outside visible panels', () => {
it.each(['result', 'error'] as const)(
'ignores an older catalog’s lookup %s after refresh',
async (outcome) => {
const stale = deferred<SysFaceEntry | null>();
const started = deferred<void>();
const latest = entry('507', 1, 4, 108);
const fetchFace = vi
.fn()
.mockImplementationOnce(() => {
started.resolve();
return stale.promise;
})
.mockResolvedValue(latest);
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });
const first = store.resolveWire({} as never, 507);
await started.promise;
await store.refresh({} as never);
const second = store.resolveWire({} as never, 507);
if (outcome === 'result') {
stale.resolve(entry('507', 1, 4, 107));
} else {
stale.reject(new Error('outdated lookup failed'));
}
const expected = { kind: 'super', packId: '4', stickerId: '108', stickerType: 1 };
await expect(first).resolves.toEqual(expected);
await expect(second).resolves.toEqual(expected);
expect(fetchFace).toHaveBeenCalledTimes(2);
expect(store.lookup(507)).toEqual(latest);
},
);
it('uses an explicitly loaded catalog when an older single-face lookup completes', async () => {
const stale = deferred<SysFaceEntry | null>();
const started = deferred<void>();
const fetchFace = vi.fn(() => {
started.resolve();
return stale.promise;
});
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });
const result = store.resolveWire({} as never, 507);
await started.promise;
const latest = entry('507', 1, 4, 108);
store.load([{ packName: '最新', emojis: [latest] }]);
stale.resolve(entry('507', 1, 4, 107));
await expect(result).resolves.toMatchObject({ stickerId: '108' });
expect(store.lookup(507)).toEqual(latest);
});
it('resolves missing animation metadata once and shares concurrent lookups', async () => {
const fetchFace = vi.fn(async () => entry('507', 1, 4, 107));
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });