SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,908 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { QFaceExtra, QSmallFaceExtra } from '@snowluma/proto-defs/element';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbFetchSysFacesReq,
|
||||
OidbFetchSysFacesResp,
|
||||
OidbFetchSysFaceReq,
|
||||
OidbFetchSysFaceResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/sys-faces';
|
||||
import {
|
||||
FetchSysFaces,
|
||||
findFaceEntity,
|
||||
isSuperFaceEntry,
|
||||
isSuperFaceId,
|
||||
type SysFaceEntry,
|
||||
type SysFacePackEntry,
|
||||
} from '../src/oidb-services/sys-faces/fetch-sys-faces';
|
||||
import {
|
||||
faceWireFor,
|
||||
SysFaceStore,
|
||||
sysFaceStore,
|
||||
type SysFaceCatalogSnapshot,
|
||||
type SysFaceCatalogStorage,
|
||||
} from '../src/sys-face-store';
|
||||
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,
|
||||
aniStickerPackId: number | null,
|
||||
aniStickerId: number | null,
|
||||
qDes = '',
|
||||
emojiNameAlias: string[] = [],
|
||||
): SysFaceEntry {
|
||||
return {
|
||||
qSid,
|
||||
qDes,
|
||||
emCode: '',
|
||||
qCid: null,
|
||||
aniStickerType,
|
||||
aniStickerPackId,
|
||||
aniStickerId,
|
||||
url: null,
|
||||
emojiNameAlias,
|
||||
aniStickerWidth: null,
|
||||
aniStickerHeight: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Animated faces include ordinary type-1 stickers as well as special types.
|
||||
const CATALOG: SysFacePackEntry[] = [
|
||||
{ packName: '经典', emojis: [entry('14', null, null, null, '/微笑', ['smile'])] },
|
||||
{
|
||||
packName: '超级表情',
|
||||
emojis: [
|
||||
entry('392', 3, 2, 38, '/龙年快乐'), // super → CommonElem 37
|
||||
entry('424', 1, 1, 52),
|
||||
entry('358', 2, 1, 33), // super (packId 1 but type 2) → CommonElem 37
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
class MemoryCatalogStorage implements SysFaceCatalogStorage {
|
||||
saved: SysFaceCatalogSnapshot[] = [];
|
||||
|
||||
constructor(private readonly initial: SysFaceCatalogSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<SysFaceCatalogSnapshot | null> {
|
||||
return this.initial;
|
||||
}
|
||||
|
||||
async save(snapshot: SysFaceCatalogSnapshot): Promise<void> {
|
||||
this.saved.push(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
describe('isSuperFaceEntry / isSuperFaceId', () => {
|
||||
it('recognizes every animated sticker type', () => {
|
||||
expect(isSuperFaceEntry(entry('x', 3, 2, 1))).toBe(true);
|
||||
expect(isSuperFaceEntry(entry('x', 2, 1, 1))).toBe(true); // type≠1
|
||||
expect(isSuperFaceEntry(entry('x', 1, 2, 1))).toBe(true); // pack≠1
|
||||
expect(isSuperFaceEntry(entry('x', 1, 1, 52))).toBe(true);
|
||||
expect(isSuperFaceEntry(entry('x', 0, 0, 0))).toBe(false);
|
||||
expect(isSuperFaceEntry(entry('x', null, null, null))).toBe(false); // not aniSticker
|
||||
});
|
||||
|
||||
it('isSuperFaceId walks the packs by id', () => {
|
||||
expect(isSuperFaceId(CATALOG, 392)).toBe(true);
|
||||
expect(isSuperFaceId(CATALOG, 424)).toBe(true);
|
||||
expect(isSuperFaceId(CATALOG, 14)).toBe(false);
|
||||
expect(isSuperFaceId(CATALOG, 99999)).toBe(false); // unknown
|
||||
});
|
||||
|
||||
it('findFaceEntity returns the matching emoji or null', () => {
|
||||
expect(findFaceEntity(CATALOG, 392)?.qSid).toBe('392');
|
||||
expect(findFaceEntity(CATALOG, 99999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('faceWireFor — classification', () => {
|
||||
it('super face → super wire with pack/sticker ids', () => {
|
||||
expect(faceWireFor(entry('392', 3, 2, 38), 392)).toEqual({
|
||||
kind: 'super',
|
||||
packId: '2',
|
||||
stickerId: '38',
|
||||
stickerType: 3,
|
||||
});
|
||||
});
|
||||
it('rejects incomplete super-face metadata instead of filling guessed ids', () => {
|
||||
expect(() => faceWireFor(entry('392', 3, 2, null), 392)).toThrow(
|
||||
/incomplete super-face metadata/,
|
||||
);
|
||||
});
|
||||
it('uses the animation metadata for type-1 stickers too', () => {
|
||||
expect(faceWireFor(entry('424', 1, 1, 52), 424)).toEqual({
|
||||
kind: 'super',
|
||||
packId: '1',
|
||||
stickerId: '52',
|
||||
stickerType: 1,
|
||||
});
|
||||
});
|
||||
it('permits uncatalogued ids using their small representation', () => {
|
||||
expect(faceWireFor(null, 14)).toEqual({ kind: 'classic' });
|
||||
expect(faceWireFor(undefined, 504)).toEqual({ kind: 'small' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FetchSysFaces protocol contract', () => {
|
||||
it('encodes the native 0x9154_1 request layout', () => {
|
||||
const body = FetchSysFaces.serialize({} as never, {});
|
||||
expect(body).toEqual({
|
||||
field1: 0,
|
||||
field2: 7,
|
||||
field3: 0,
|
||||
});
|
||||
|
||||
const decoded = protobuf_decode<OidbBase<OidbFetchSysFacesReq>>(
|
||||
FetchSysFaces.encode({ body }),
|
||||
);
|
||||
expect(decoded.body).toEqual({
|
||||
field1: null,
|
||||
field2: 7,
|
||||
field3: null,
|
||||
field4: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FetchSysFace protocol contract', () => {
|
||||
it('queries the requested id and preserves animation metadata from the response', async () => {
|
||||
const sendRawPacket = vi.fn(async (_command: string, _body: Uint8Array) => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: protobuf_encode<OidbBase<OidbFetchSysFaceResp>>({
|
||||
body: {
|
||||
emoji: {
|
||||
qSid: '507',
|
||||
qDes: '/test',
|
||||
aniStickerType: 1,
|
||||
aniStickerPackId: 4,
|
||||
aniStickerId: 107,
|
||||
aniStickerWidth: 200,
|
||||
aniStickerHeight: 240,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG });
|
||||
await expect(store.resolveWire({ sendRawPacket }, 507)).resolves.toEqual({
|
||||
kind: 'super',
|
||||
packId: '4',
|
||||
stickerId: '107',
|
||||
stickerType: 1,
|
||||
});
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [command, bytes] = sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x9155_1');
|
||||
expect(protobuf_decode<OidbBase<OidbFetchSysFaceReq>>(bytes)).toMatchObject({
|
||||
command: 0x9155,
|
||||
subCommand: 1,
|
||||
body: { qSid: '507' },
|
||||
});
|
||||
expect(store.lookup(507)).toMatchObject({ aniStickerWidth: 200, aniStickerHeight: 240 });
|
||||
});
|
||||
|
||||
it('does not invent metadata for an empty lookup result', () => {
|
||||
expect(FetchSysFace.deserialize({} as never, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FetchSysFaces.deserialize', () => {
|
||||
it('decodes every group in the third panel, including unnamed and hidden faces (#482)', () => {
|
||||
const wire = protobuf_encode<OidbFetchSysFacesResp>({
|
||||
specialMagicFace: {
|
||||
emojiList: [
|
||||
{ emojiPackName: 'MagicFace', emojiDetail: [{ qSid: '358' }] },
|
||||
{ emojiDetail: [{ qSid: '504', unknown10: 1 }, { qSid: '505' }] },
|
||||
{ emojiPackName: 'Limited', emojiDetail: [{ qSid: '506' }, { qSid: '507' }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
const packs = FetchSysFaces.deserialize(
|
||||
{} as never,
|
||||
protobuf_decode<OidbFetchSysFacesResp>(wire),
|
||||
);
|
||||
expect(packs.map((pack) => pack.packName)).toEqual(['MagicFace', 'MagicFace', 'Limited']);
|
||||
expect(packs.flatMap((pack) => pack.emojis.map((face) => face.qSid))).toEqual([
|
||||
'358',
|
||||
'504',
|
||||
'505',
|
||||
'506',
|
||||
'507',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flattens common + big + magic packs', () => {
|
||||
const packs = FetchSysFaces.deserialize(
|
||||
{} as never,
|
||||
{
|
||||
commonFace: {
|
||||
emojiList: [{ emojiPackName: '经典', emojiDetail: [{ qSid: '14' }] }],
|
||||
},
|
||||
specialBigFace: {
|
||||
emojiList: [
|
||||
{
|
||||
emojiPackName: '超级',
|
||||
emojiDetail: [{ qSid: '392', aniStickerType: 3, aniStickerPackId: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
specialMagicFace: { emojiList: [{ emojiDetail: [{ qSid: '999' }] }] },
|
||||
} as never,
|
||||
);
|
||||
expect(packs.map((p) => p.packName)).toEqual(['经典', '超级', 'MagicFace']);
|
||||
expect(packs[1].emojis[0].aniStickerType).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves Unicode catalog identifiers returned by QQ', () => {
|
||||
const packs = FetchSysFaces.deserialize(
|
||||
{} as never,
|
||||
{
|
||||
commonFace: {
|
||||
emojiList: [
|
||||
{
|
||||
emojiPackName: 'emoji',
|
||||
emojiDetail: [
|
||||
{ qSid: '😊', qDes: '/嘿嘿', qCid: 128522, emCode: '400832' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(packs).toEqual([
|
||||
{
|
||||
packName: 'emoji',
|
||||
emojis: [
|
||||
expect.objectContaining({
|
||||
qSid: '😊',
|
||||
qDes: '/嘿嘿',
|
||||
qCid: 128522,
|
||||
emCode: '400832',
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed when an id field uses an unsupported wire encoding', () => {
|
||||
const driftedFace = { qDes: '/未知表情' };
|
||||
Object.defineProperty(driftedFace, Symbol.for('snowluma.proton.unknownFields'), {
|
||||
value: {
|
||||
fields: [{ fieldNumber: 1, wireType: 0, count: 1, totalByteLength: 1 }],
|
||||
totalOccurrences: 1,
|
||||
omittedOccurrences: 0,
|
||||
omittedByteLength: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
FetchSysFaces.deserialize(
|
||||
{} as never,
|
||||
{
|
||||
specialMagicFace: { emojiList: [{ emojiDetail: [driftedFace] }] },
|
||||
} as never,
|
||||
),
|
||||
).toThrow(/unsupported wire encoding.*wireTypes=0/);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
storage: new MemoryCatalogStorage({
|
||||
schemaVersion: 1,
|
||||
fetchedAt: 123,
|
||||
packs: [{ packName: '经典', emojis: [zero] }],
|
||||
}),
|
||||
fetchCatalog: async () => {
|
||||
throw new Error('network must not be used');
|
||||
},
|
||||
});
|
||||
|
||||
await store.ensureReady(sender);
|
||||
|
||||
expect(store.lookup(0)?.qDes).toBe('/惊讶');
|
||||
expect(store.classify(0)).toEqual({ kind: 'classic' });
|
||||
});
|
||||
|
||||
it('loads a persisted catalog and resolves ids, descriptions, aliases, and pack names', async () => {
|
||||
const storage = new MemoryCatalogStorage({
|
||||
schemaVersion: 1,
|
||||
fetchedAt: 123,
|
||||
packs: CATALOG,
|
||||
});
|
||||
const store = new SysFaceStore({
|
||||
storage,
|
||||
fetchCatalog: async () => {
|
||||
throw new Error('network must not be used');
|
||||
},
|
||||
});
|
||||
|
||||
await store.ensureReady(sender);
|
||||
|
||||
expect(store.lookup(392)?.qDes).toBe('/龙年快乐');
|
||||
expect(store.search('龙年').map((face) => face.qSid)).toEqual(['392']);
|
||||
expect(store.search('SMILE').map((face) => face.qSid)).toEqual(['14']);
|
||||
expect(store.search('经典').map((face) => face.qSid)).toEqual(['14']);
|
||||
});
|
||||
|
||||
it('persists and searches Unicode catalog ids without exposing them as numeric face ids', async () => {
|
||||
const unicodeFace = entry('😊', null, null, null, '/嘿嘿');
|
||||
unicodeFace.qCid = 128522;
|
||||
unicodeFace.emCode = '400832';
|
||||
const catalog: SysFacePackEntry[] = [{ packName: 'emoji', emojis: [unicodeFace] }];
|
||||
const storage = new MemoryCatalogStorage();
|
||||
const store = new SysFaceStore({
|
||||
storage,
|
||||
now: () => 456,
|
||||
fetchCatalog: async () => catalog,
|
||||
});
|
||||
|
||||
await expect(store.refresh(sender)).resolves.toEqual(catalog);
|
||||
|
||||
expect(storage.saved[0]?.packs).toEqual(catalog);
|
||||
expect(store.search('😊').map((face) => face.qSid)).toEqual(['😊']);
|
||||
expect(store.search('嘿嘿').map((face) => face.qSid)).toEqual(['😊']);
|
||||
expect(store.lookup(128522)).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves one face across multiple catalog packs', async () => {
|
||||
const commonFace = entry('474', 1, 4, 76, '/给你一拳');
|
||||
commonFace.emCode = '10474';
|
||||
const specialFace: SysFaceEntry = {
|
||||
...commonFace,
|
||||
qDes: '/给你一拳(黄脸)',
|
||||
emojiNameAlias: ['punch'],
|
||||
};
|
||||
const catalog: SysFacePackEntry[] = [
|
||||
{ packName: '超级表情', emojis: [commonFace] },
|
||||
{ packName: 'QQ黄脸', emojis: [specialFace] },
|
||||
];
|
||||
const storage = new MemoryCatalogStorage();
|
||||
const store = new SysFaceStore({
|
||||
storage,
|
||||
now: () => 456,
|
||||
fetchCatalog: async () => catalog,
|
||||
});
|
||||
|
||||
await expect(store.refresh(sender)).resolves.toEqual(catalog);
|
||||
|
||||
expect(storage.saved[0]?.packs).toEqual(catalog);
|
||||
await expect(store.getCatalog(sender)).resolves.toEqual(catalog);
|
||||
expect(store.lookup(474)).toEqual(commonFace);
|
||||
expect(store.search('超级表情').map((face) => face.qSid)).toEqual(['474']);
|
||||
expect(store.search('QQ黄脸').map((face) => face.qSid)).toEqual(['474']);
|
||||
expect(store.search('punch').map((face) => face.qSid)).toEqual(['474']);
|
||||
|
||||
const restored = new SysFaceStore({
|
||||
storage: new MemoryCatalogStorage(storage.saved[0]!),
|
||||
fetchCatalog: async () => {
|
||||
throw new Error('network must not be used');
|
||||
},
|
||||
});
|
||||
await restored.ensureReady(sender);
|
||||
await expect(restored.getCatalog(sender)).resolves.toEqual(catalog);
|
||||
expect(restored.search('QQ黄脸').map((face) => face.qSid)).toEqual(['474']);
|
||||
});
|
||||
|
||||
it('still rejects empty identifiers and duplicates within one pack', () => {
|
||||
const store = new SysFaceStore();
|
||||
|
||||
expect(() =>
|
||||
store.load([{ packName: 'emoji', emojis: [entry('', null, null, null)] }]),
|
||||
).toThrow(/invalid system face id/);
|
||||
expect(() =>
|
||||
store.load([
|
||||
{
|
||||
packName: 'emoji',
|
||||
emojis: [entry('😊', null, null, null), entry('😊', null, null, null)],
|
||||
},
|
||||
]),
|
||||
).toThrow(/duplicate system face id/);
|
||||
expect(() =>
|
||||
store.load([
|
||||
{ packName: '超级表情', emojis: [entry('474', 1, 4, 76)] },
|
||||
{
|
||||
packName: 'QQ黄脸',
|
||||
emojis: [entry('474', 1, 4, 76), entry('474', 1, 4, 76)],
|
||||
},
|
||||
]),
|
||||
).toThrow(/duplicate system face id 474 within pack 1/);
|
||||
});
|
||||
|
||||
it('rejects cross-pack copies with conflicting send metadata', () => {
|
||||
const store = new SysFaceStore();
|
||||
|
||||
expect(() =>
|
||||
store.load([
|
||||
{ packName: '超级表情', emojis: [entry('474', 1, 4, 76)] },
|
||||
{ packName: 'QQ黄脸', emojis: [entry('474', 3, 2, 38)] },
|
||||
]),
|
||||
).toThrow(/conflicting system face send metadata.*pack 0.*pack 1/);
|
||||
});
|
||||
|
||||
it('allows non-addressable Unicode entries to carry panel-specific metadata', () => {
|
||||
const store = new SysFaceStore();
|
||||
|
||||
expect(() =>
|
||||
store.load([
|
||||
{ packName: 'emoji', emojis: [entry('😊', null, null, null, '/微笑')] },
|
||||
{ packName: '最近使用', emojis: [entry('😊', 3, 2, 38, '/最近使用')] },
|
||||
]),
|
||||
).not.toThrow();
|
||||
expect(store.search('最近使用').map((face) => face.qSid)).toEqual(['😊']);
|
||||
});
|
||||
|
||||
it('coalesces concurrent refreshes and persists the authoritative result once', async () => {
|
||||
const storage = new MemoryCatalogStorage();
|
||||
let fetches = 0;
|
||||
let release!: (packs: SysFacePackEntry[]) => void;
|
||||
const pending = new Promise<SysFacePackEntry[]>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const store = new SysFaceStore({
|
||||
storage,
|
||||
now: () => 456,
|
||||
fetchCatalog: async () => {
|
||||
fetches += 1;
|
||||
return pending;
|
||||
},
|
||||
});
|
||||
|
||||
const first = store.refresh(sender);
|
||||
const second = store.refresh(sender);
|
||||
release(CATALOG);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(fetches).toBe(1);
|
||||
expect(storage.saved).toEqual([
|
||||
{
|
||||
schemaVersion: 1,
|
||||
fetchedAt: 456,
|
||||
packs: CATALOG,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps usable faces when a server pack contains an id-less record', async () => {
|
||||
const storage = new MemoryCatalogStorage();
|
||||
const response = FetchSysFaces.deserialize(sender, {
|
||||
commonFace: {
|
||||
emojiList: [
|
||||
{ emojiPackName: '经典', emojiDetail: [{ qSid: '14', qDes: '/微笑' }] },
|
||||
{ emojiPackName: '小表情', emojiDetail: [{ qSid: '424', qDes: '/比心' }] },
|
||||
],
|
||||
},
|
||||
specialBigFace: {
|
||||
emojiList: [
|
||||
{
|
||||
emojiPackName: '超级表情',
|
||||
emojiDetail: [{ qSid: '392', qDes: '/龙年快乐' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
specialMagicFace: {
|
||||
emojiList: [
|
||||
{
|
||||
emojiDetail: [{ qDes: '/目录占位' }, { qSid: '999', qDes: '/魔法表情' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
const store = new SysFaceStore({
|
||||
storage,
|
||||
now: () => 456,
|
||||
fetchCatalog: async () => response,
|
||||
});
|
||||
|
||||
await expect(store.refresh(sender)).resolves.toHaveLength(4);
|
||||
expect(store.lookup(14)?.qDes).toBe('/微笑');
|
||||
expect(store.lookup(999)?.qDes).toBe('/魔法表情');
|
||||
expect(storage.saved[0]?.packs[3]?.emojis.map((face) => face.qSid)).toEqual(['999']);
|
||||
});
|
||||
|
||||
it('refreshes a cache miss once, then keeps an authoritative not-found result', async () => {
|
||||
const storage = new MemoryCatalogStorage({
|
||||
schemaVersion: 1,
|
||||
fetchedAt: 123,
|
||||
packs: CATALOG.slice(0, 1),
|
||||
});
|
||||
let fetches = 0;
|
||||
const store = new SysFaceStore({
|
||||
fetchFace: async () => null,
|
||||
storage,
|
||||
fetchCatalog: async () => {
|
||||
fetches += 1;
|
||||
return CATALOG;
|
||||
},
|
||||
});
|
||||
|
||||
expect((await store.resolve(sender, 392))?.qSid).toBe('392');
|
||||
expect(await store.resolve(sender, 99999)).toBeNull();
|
||||
expect(await store.resolve(sender, 99999)).toBeNull();
|
||||
expect(await store.resolveWire(sender, 99999)).toEqual({ kind: 'small' });
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
it('prewarms from login only once per process while sharing the result across accounts', async () => {
|
||||
let fetches = 0;
|
||||
const store = new SysFaceStore({
|
||||
storage: new MemoryCatalogStorage(),
|
||||
fetchCatalog: async () => {
|
||||
fetches += 1;
|
||||
return CATALOG;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
store.prewarm({} as never),
|
||||
store.prewarm({} as never),
|
||||
store.prewarm({} as never),
|
||||
]);
|
||||
await store.prewarm({} as never);
|
||||
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
it('waits for an in-flight login refresh before resolving a cached face', async () => {
|
||||
const staleCatalog: SysFacePackEntry[] = [
|
||||
{
|
||||
packName: '旧目录',
|
||||
emojis: [entry('392', 1, 1, 52)],
|
||||
},
|
||||
];
|
||||
let release!: (packs: SysFacePackEntry[]) => void;
|
||||
let markFetchStarted!: () => void;
|
||||
const fetchStarted = new Promise<void>((resolve) => {
|
||||
markFetchStarted = resolve;
|
||||
});
|
||||
const pending = new Promise<SysFacePackEntry[]>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const store = new SysFaceStore({
|
||||
storage: new MemoryCatalogStorage({
|
||||
schemaVersion: 1,
|
||||
fetchedAt: 123,
|
||||
packs: staleCatalog,
|
||||
}),
|
||||
fetchCatalog: async () => {
|
||||
markFetchStarted();
|
||||
return pending;
|
||||
},
|
||||
});
|
||||
|
||||
const prewarm = store.prewarm(sender);
|
||||
await fetchStarted;
|
||||
const resolved = store.resolveWire(sender, 392);
|
||||
let settled = false;
|
||||
void resolved.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
release(CATALOG);
|
||||
await expect(resolved).resolves.toMatchObject({ kind: 'super' });
|
||||
await prewarm;
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeFaceElem (via buildSendElems) — three-way wire encoding', () => {
|
||||
const ctx = { bridge: {} as never };
|
||||
|
||||
it.each(['0', '2', 'variant'])(
|
||||
'preserves an explicit animation result %s',
|
||||
async (resultId) => {
|
||||
sysFaceStore.load(CATALOG);
|
||||
const [element] = await buildSendElems([{ type: 'face', faceId: 392, resultId }], ctx);
|
||||
expect(protobuf_decode<QFaceExtra>(element.commonElem!.pbElem!)).toMatchObject({
|
||||
qsid: 392,
|
||||
resultId,
|
||||
randomType: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the default animation choice when no result is supplied', async () => {
|
||||
sysFaceStore.load(CATALOG);
|
||||
const [element] = await buildSendElems([{ type: 'face', faceId: 392 }], ctx);
|
||||
const payload = protobuf_decode<QFaceExtra>(element.commonElem!.pbElem!);
|
||||
expect(payload.resultId).toBeNull();
|
||||
expect(payload.randomType).toBe(1);
|
||||
});
|
||||
|
||||
it.each([14, 504])(
|
||||
'rejects an animation result without animation metadata for %i',
|
||||
async (faceId) => {
|
||||
sysFaceStore.load(CATALOG);
|
||||
await expect(
|
||||
buildSendElems([{ type: 'face', faceId, resultId: '0' }]),
|
||||
).rejects.toMatchObject({
|
||||
code: 'INVALID_FIELD',
|
||||
elementType: 'face',
|
||||
field: 'resultId',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects incompatible animation options before loading the catalog', async () => {
|
||||
await expect(
|
||||
buildSendElems([{ type: 'face', faceId: 392, large: false, resultId: '0' }], ctx),
|
||||
).rejects.toMatchObject({ code: 'INVALID_FIELD', field: 'resultId' });
|
||||
});
|
||||
|
||||
it('awaits an authoritative refresh when the first send misses the persisted catalog', async () => {
|
||||
sysFaceStore.load(CATALOG.slice(0, 1));
|
||||
let requests = 0;
|
||||
const responseData = protobuf_encode<OidbBase<OidbFetchSysFacesResp>>({
|
||||
body: {
|
||||
commonFace: {
|
||||
emojiList: [
|
||||
{
|
||||
emojiPackName: '经典',
|
||||
emojiDetail: [{ qSid: '14', qDes: '/微笑' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
specialBigFace: {
|
||||
emojiList: [
|
||||
{
|
||||
emojiPackName: '超级表情',
|
||||
emojiDetail: [
|
||||
{
|
||||
qSid: '392',
|
||||
qDes: '/龙年快乐',
|
||||
aniStickerType: 3,
|
||||
aniStickerPackId: 2,
|
||||
aniStickerId: 38,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const liveCtx = {
|
||||
bridge: {
|
||||
sendRawPacket: async () => {
|
||||
requests += 1;
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
responseData,
|
||||
};
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
|
||||
const [elem] = await buildSendElems([{ type: 'face', faceId: 392 }], liveCtx);
|
||||
|
||||
expect(requests).toBe(1);
|
||||
expect(elem.commonElem?.serviceType).toBe(37);
|
||||
});
|
||||
|
||||
it('encodes each face id by its catalog classification', async () => {
|
||||
sysFaceStore.load(CATALOG); // warm → ensureWarm no-ops, classify uses the catalog
|
||||
|
||||
const [superElem] = await buildSendElems([{ type: 'face', faceId: 392 }], ctx);
|
||||
expect(superElem.commonElem?.serviceType).toBe(37);
|
||||
const big = protobuf_decode<QFaceExtra>(superElem.commonElem!.pbElem!);
|
||||
expect(big.qsid).toBe(392);
|
||||
expect(big.packId).toBe('2');
|
||||
expect(big.stickerId).toBe('38');
|
||||
expect(superElem.commonElem?.businessType).toBe(3);
|
||||
|
||||
const [animated] = await buildSendElems([{ type: 'face', faceId: 424 }], ctx);
|
||||
expect(animated.commonElem?.serviceType).toBe(37);
|
||||
expect(protobuf_decode<QFaceExtra>(animated.commonElem!.pbElem!).stickerId).toBe('52');
|
||||
|
||||
const [smallElem] = await buildSendElems(
|
||||
[{ type: 'face', faceId: 424, large: false }],
|
||||
ctx,
|
||||
);
|
||||
expect(smallElem.commonElem?.serviceType).toBe(33);
|
||||
const small = protobuf_decode<QSmallFaceExtra>(smallElem.commonElem!.pbElem!);
|
||||
expect(small.faceId).toBe(424);
|
||||
|
||||
const [classicElem] = await buildSendElems([{ type: 'face', faceId: 14 }], ctx);
|
||||
expect(classicElem.face?.index).toBe(14);
|
||||
});
|
||||
|
||||
it('allows special animated faces to be sent small and normalizes newer business types', async () => {
|
||||
sysFaceStore.load([{ packName: 'special', emojis: [entry('504', 4, 1, 90)] }]);
|
||||
const [large] = await buildSendElems([{ type: 'face', faceId: 504, large: true }]);
|
||||
expect(large.commonElem?.businessType).toBe(1);
|
||||
expect(protobuf_decode<QFaceExtra>(large.commonElem!.pbElem!).stickerType).toBe(4);
|
||||
const [small] = await buildSendElems([{ type: 'face', faceId: 504, large: false }]);
|
||||
expect(small.commonElem?.serviceType).toBe(33);
|
||||
expect(protobuf_decode<QSmallFaceExtra>(small.commonElem!.pbElem!).faceId).toBe(504);
|
||||
});
|
||||
|
||||
it('sends uncatalogued ids without inventing animation metadata', async () => {
|
||||
sysFaceStore.load(CATALOG);
|
||||
const [element] = await buildSendElems([{ type: 'face', faceId: 504 }]);
|
||||
expect(element.commonElem?.serviceType).toBe(33);
|
||||
expect(protobuf_decode<QSmallFaceExtra>(element.commonElem!.pbElem!).faceId).toBe(504);
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
const [first, second] = await Promise.all([
|
||||
store.resolveWire({} as never, 507),
|
||||
store.resolveWire({} as never, 507),
|
||||
]);
|
||||
expect(first).toEqual({ kind: 'super', packId: '4', stickerId: '107', stickerType: 1 });
|
||||
expect(second).toEqual(first);
|
||||
expect(store.lookup(507)?.qSid).toBe('507');
|
||||
await store.resolveWire({} as never, 507);
|
||||
expect(fetchFace).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not silently send a small face after a lookup failure and permits retry', async () => {
|
||||
const fetchFace = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('lookup failed'))
|
||||
.mockResolvedValueOnce(entry('507', 1, 4, 107));
|
||||
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });
|
||||
await expect(store.resolveWire({} as never, 507)).rejects.toThrow('lookup failed');
|
||||
await expect(store.resolveWire({} as never, 507)).resolves.toMatchObject({ kind: 'super' });
|
||||
});
|
||||
|
||||
it('rejects mismatched or incomplete animation metadata', async () => {
|
||||
for (const face of [entry('506', 1, 4, 107), entry('507', 1, 4, null)]) {
|
||||
const store = new SysFaceStore({
|
||||
fetchCatalog: async () => CATALOG,
|
||||
fetchFace: async () => face,
|
||||
});
|
||||
await expect(store.resolveWire({} as never, 507)).rejects.toThrow();
|
||||
expect(store.lookup(507)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user