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
797 lines
29 KiB
TypeScript
797 lines
29 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { JsonObject, JsonValue } from '@snowluma/common/json';
|
|
import type { QQEventVariant } from '@snowluma/protocol/events';
|
|
import {
|
|
formatEvent,
|
|
formatGroup,
|
|
formatMessageSegments,
|
|
formatReply,
|
|
formatUser,
|
|
type ReplyEventLookup,
|
|
} from '@snowluma/protocol/format';
|
|
import type { IdentityService } from '@snowluma/protocol/identity-service';
|
|
|
|
function stubIdentity(
|
|
opts: {
|
|
groups?: Record<number, { groupName?: string }>;
|
|
members?: Record<string, { card?: string; nickname?: string }>;
|
|
friends?: Record<number, { remark?: string; nickname?: string }>;
|
|
} = {},
|
|
): IdentityService {
|
|
return {
|
|
findGroup: (groupId: number) => opts.groups?.[groupId] ?? null,
|
|
findGroupMember: (groupId: number, uin: number) =>
|
|
opts.members?.[`${groupId}:${uin}`] ?? null,
|
|
findFriend: (uin: number) => opts.friends?.[uin] ?? null,
|
|
} as IdentityService;
|
|
}
|
|
|
|
function throwingIdentity(): IdentityService {
|
|
return {
|
|
findGroup: () => {
|
|
throw new Error('identity unavailable');
|
|
},
|
|
findGroupMember: () => {
|
|
throw new Error('identity unavailable');
|
|
},
|
|
findFriend: () => {
|
|
throw new Error('identity unavailable');
|
|
},
|
|
} as IdentityService;
|
|
}
|
|
|
|
function stubStore(events: Record<number, JsonObject>): ReplyEventLookup {
|
|
return {
|
|
findEvent: (messageId: number) => events[messageId] ?? null,
|
|
};
|
|
}
|
|
|
|
describe('formatGroup', () => {
|
|
it('renders [name(id)] from a cached groupName', () => {
|
|
const identity = stubIdentity({ groups: { 778899: { groupName: '雪夜茶话' } } });
|
|
expect(formatGroup(identity, 778899)).toBe('[雪夜茶话(778899)]');
|
|
});
|
|
|
|
it('trims groupName before wrapping', () => {
|
|
const identity = stubIdentity({ groups: { 778899: { groupName: ' 雪夜茶话 ' } } });
|
|
expect(formatGroup(identity, 778899)).toBe('[雪夜茶话(778899)]');
|
|
});
|
|
|
|
it('falls back to the bare id when groupName is blank after trim', () => {
|
|
const identity = stubIdentity({ groups: { 778899: { groupName: ' ' } } });
|
|
expect(formatGroup(identity, 778899)).toBe('778899');
|
|
});
|
|
|
|
it('falls back to the bare id on cache miss', () => {
|
|
expect(formatGroup(stubIdentity(), 778899)).toBe('778899');
|
|
});
|
|
|
|
it('maps 0 and other non-positive ids without a roster lookup', () => {
|
|
const identity = stubIdentity({ groups: { 0: { groupName: 'ghost' } } });
|
|
expect(formatGroup(identity, 0)).toBe('0');
|
|
expect(formatGroup(identity, -12)).toBe('-12');
|
|
expect(formatGroup(identity, Number.NaN)).toBe('0');
|
|
});
|
|
|
|
it('falls back to the bare id when findGroup throws', () => {
|
|
expect(formatGroup(throwingIdentity(), 778899)).toBe('778899');
|
|
});
|
|
});
|
|
|
|
describe('formatUser', () => {
|
|
it('prefers a group-member card over nickname', () => {
|
|
const identity = stubIdentity({
|
|
members: { '778899:20001': { card: '冬至', nickname: '小满' } },
|
|
});
|
|
expect(formatUser(identity, 778899, 20001)).toBe('[冬至(20001)]');
|
|
});
|
|
|
|
it('uses the member nickname when card is empty after trim', () => {
|
|
const identity = stubIdentity({
|
|
members: { '778899:20001': { card: ' ', nickname: '小满' } },
|
|
});
|
|
expect(formatUser(identity, 778899, 20001)).toBe('[小满(20001)]');
|
|
});
|
|
|
|
it('skips the member roster when groupId is missing or non-positive', () => {
|
|
const identity = stubIdentity({
|
|
members: { '778899:20001': { card: '冬至' } },
|
|
friends: { 20001: { remark: '谷雨', nickname: '小满' } },
|
|
});
|
|
expect(formatUser(identity, undefined, 20001)).toBe('[谷雨(20001)]');
|
|
expect(formatUser(identity, 0, 20001)).toBe('[谷雨(20001)]');
|
|
expect(formatUser(identity, -3, 20001)).toBe('[谷雨(20001)]');
|
|
});
|
|
|
|
it('falls through to the friend nickname when remark is blank', () => {
|
|
const identity = stubIdentity({
|
|
friends: { 30001: { remark: ' ', nickname: '惊蛰' } },
|
|
});
|
|
expect(formatUser(identity, undefined, 30001)).toBe('[惊蛰(30001)]');
|
|
});
|
|
|
|
it('uses a friend after a group-member miss', () => {
|
|
const identity = stubIdentity({
|
|
friends: { 20002: { remark: '白露' } },
|
|
});
|
|
expect(formatUser(identity, 778899, 20002)).toBe('[白露(20002)]');
|
|
});
|
|
|
|
it('returns the bare uin when nothing is cached', () => {
|
|
expect(formatUser(stubIdentity(), 778899, 20001)).toBe('20001');
|
|
});
|
|
|
|
it('returns the uid when uin is missing or non-positive', () => {
|
|
expect(formatUser(stubIdentity(), 778899, 0, 'u_winter')).toBe('u_winter');
|
|
expect(formatUser(stubIdentity(), 778899, -8, 'u_neg')).toBe('u_neg');
|
|
});
|
|
|
|
it('returns 0 when both uin and uid are missing', () => {
|
|
expect(formatUser(stubIdentity(), undefined, 0)).toBe('0');
|
|
expect(formatUser(stubIdentity(), undefined, -8)).toBe('0');
|
|
});
|
|
|
|
it('returns the bare uin when identity lookups throw', () => {
|
|
expect(formatUser(throwingIdentity(), 778899, 20001)).toBe('20001');
|
|
expect(formatUser(throwingIdentity(), undefined, 20001)).toBe('20001');
|
|
});
|
|
});
|
|
|
|
describe('formatMessageSegments', () => {
|
|
it('renders a raw string and truncates past 50 characters', () => {
|
|
expect(formatMessageSegments('清明')).toBe('清明');
|
|
expect(formatMessageSegments('雨'.repeat(51))).toBe(`${'雨'.repeat(50)}...`);
|
|
});
|
|
|
|
it('returns [空消息] for an empty string or a non-array payload', () => {
|
|
expect(formatMessageSegments('')).toBe('[空消息]');
|
|
expect(formatMessageSegments([])).toBe('[空消息]');
|
|
expect(formatMessageSegments(null)).toBe('[空消息]');
|
|
expect(formatMessageSegments(42)).toBe('[空消息]');
|
|
expect(formatMessageSegments({ type: 'text' })).toBe('[空消息]');
|
|
});
|
|
|
|
it('skips non-object segments and still joins the rest', () => {
|
|
expect(
|
|
formatMessageSegments([
|
|
null,
|
|
7,
|
|
'skip',
|
|
['nested'],
|
|
{ type: 'text', data: { text: '立夏' } },
|
|
{ type: 'text', data: { text: '小暑' } },
|
|
]),
|
|
).toBe('立夏 小暑');
|
|
});
|
|
|
|
it('treats a missing or non-object data field as empty segment data', () => {
|
|
expect(formatMessageSegments([{ type: 'text' }])).toBe('[空消息]');
|
|
expect(formatMessageSegments([{ type: 'at', data: null }])).toBe('@');
|
|
expect(formatMessageSegments([{ type: 'at', data: ['all'] }])).toBe('@');
|
|
expect(formatMessageSegments([{ type: 'image', data: 'pic' }])).toBe('[图片]');
|
|
});
|
|
|
|
it('renders each known segment type with its preview token', () => {
|
|
expect(
|
|
formatMessageSegments([
|
|
{ type: 'text', data: { text: 'hi' } },
|
|
{ type: 'image', data: {} },
|
|
{ type: 'image', data: { emoji_id: 'e1', summary: '捂脸' } },
|
|
{ type: 'image', data: { emoji_id: 'e2' } },
|
|
{ type: 'face', data: { id: 1 } },
|
|
{ type: 'mface', data: { text: '旺柴' } },
|
|
{ type: 'mface', data: {} },
|
|
{ type: 'at', data: { qq: 20001 } },
|
|
{ type: 'at', data: { qq: 'all' } },
|
|
{ type: 'at', data: {} },
|
|
{ type: 'reply', data: { id: 4100 } },
|
|
{ type: 'reply', data: {} },
|
|
{ type: 'record', data: {} },
|
|
{ type: 'video', data: {} },
|
|
{ type: 'file', data: { name: 'notes.txt' } },
|
|
{ type: 'file', data: { file: 'old.bin' } },
|
|
{ type: 'file', data: {} },
|
|
{ type: 'xml', data: {} },
|
|
{ type: 'markdown', data: {} },
|
|
{ type: 'inline_keyboard', data: {} },
|
|
{ type: 'forward', data: {} },
|
|
{ type: 'poke', data: {} },
|
|
{ type: 'flash_file', data: { title: '图纸.dwg' } },
|
|
{ type: 'flash_file', data: {} },
|
|
{ type: 'mystery', data: {} },
|
|
{},
|
|
]),
|
|
).toBe(
|
|
[
|
|
'hi',
|
|
'[图片]',
|
|
'[捂脸]',
|
|
'[表情]',
|
|
'[表情]',
|
|
'[旺柴]',
|
|
'[表情]',
|
|
'@20001',
|
|
'@全体成员',
|
|
'@',
|
|
'[回复:4100]',
|
|
'[回复:]',
|
|
'[语音]',
|
|
'[视频]',
|
|
'[文件:notes.txt]',
|
|
'[文件:old.bin]',
|
|
'[文件]',
|
|
'[XML]',
|
|
'[Markdown]',
|
|
'[交互按钮]',
|
|
'[聊天记录]',
|
|
'[窗口抖动]',
|
|
'[闪传文件:图纸.dwg]',
|
|
'[闪传文件]',
|
|
'[mystery]',
|
|
'[]',
|
|
].join(' '),
|
|
);
|
|
});
|
|
|
|
it('truncates file names and flash-file titles to 20 characters', () => {
|
|
const longName = 'abcdefghijABCDEFGHIJxyz';
|
|
expect(formatMessageSegments([{ type: 'file', data: { name: longName } }])).toBe(
|
|
'[文件:abcdefghijABCDEFGHIJ...]',
|
|
);
|
|
expect(formatMessageSegments([{ type: 'flash_file', data: { title: longName } }])).toBe(
|
|
'[闪传文件:abcdefghijABCDEFGHIJ...]',
|
|
);
|
|
});
|
|
|
|
it('treats an empty file name as absent even if file is also empty', () => {
|
|
expect(formatMessageSegments([{ type: 'file', data: { name: '' } }])).toBe('[文件]');
|
|
});
|
|
|
|
it('extracts json card titles from the documented ARK locations in order', () => {
|
|
const ranked = JSON.stringify({
|
|
meta: {
|
|
detail_1: { title: '小程序名' },
|
|
detail: { title: '详情名' },
|
|
news: { title: '新闻名' },
|
|
},
|
|
prompt: '提示文案',
|
|
});
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: ranked } }])).toBe(
|
|
'[卡片:小程序名]',
|
|
);
|
|
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: { detail: { title: '图文分享' } } }) },
|
|
},
|
|
]),
|
|
).toBe('[卡片:图文分享]');
|
|
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: { news: { title: '头条' } } }) },
|
|
},
|
|
]),
|
|
).toBe('[卡片:头条]');
|
|
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: { mannonce: { title: '群公告' } } }) },
|
|
},
|
|
]),
|
|
).toBe('[卡片:群公告]');
|
|
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: { music: { title: '夜曲' } } }) },
|
|
},
|
|
]),
|
|
).toBe('[卡片:夜曲]');
|
|
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: { video: { title: '短片' } } }) },
|
|
},
|
|
]),
|
|
).toBe('[卡片:短片]');
|
|
});
|
|
|
|
it('uses the single remaining meta section title when the key is unknown', () => {
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: {
|
|
data: JSON.stringify({ meta: { share_card: { title: '自定义卡片' } } }),
|
|
},
|
|
},
|
|
]),
|
|
).toBe('[卡片:自定义卡片]');
|
|
});
|
|
|
|
it('falls back to prompt, then desc, when meta has no usable title', () => {
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: {
|
|
data: JSON.stringify({
|
|
meta: { left: { title: '' }, right: { title: ' ' } },
|
|
prompt: ' 请升级客户端 ',
|
|
}),
|
|
},
|
|
},
|
|
]),
|
|
).toBe('[卡片:请升级客户端]');
|
|
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ desc: ' 简介文案 ' }) },
|
|
},
|
|
]),
|
|
).toBe('[卡片:简介文案]');
|
|
});
|
|
|
|
it('truncates card titles to 30 characters', () => {
|
|
const prompt = '卡'.repeat(31);
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ prompt }) },
|
|
},
|
|
]),
|
|
).toBe(`[卡片:${'卡'.repeat(30)}...]`);
|
|
});
|
|
|
|
it('falls back to [JSON] when the payload has no extractable title', () => {
|
|
expect(formatMessageSegments([{ type: 'json', data: {} }])).toBe('[JSON]');
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: 1 } }])).toBe('[JSON]');
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '' } }])).toBe('[JSON]');
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '{broken' } }])).toBe('[JSON]');
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: 'null' } }])).toBe('[JSON]');
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '12' } }])).toBe('[JSON]');
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '[1]' } }])).toBe('[JSON]');
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: ['x'], prompt: '' }) },
|
|
},
|
|
]),
|
|
).toBe('[JSON]');
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: {
|
|
data: JSON.stringify({ meta: { a: { title: 9 }, b: { title: true } } }),
|
|
},
|
|
},
|
|
]),
|
|
).toBe('[JSON]');
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ meta: { only: ['not-object'] } }) },
|
|
},
|
|
]),
|
|
).toBe('[JSON]');
|
|
expect(
|
|
formatMessageSegments([
|
|
{
|
|
type: 'json',
|
|
data: { data: JSON.stringify({ foo: 1 }) },
|
|
},
|
|
]),
|
|
).toBe('[JSON]');
|
|
});
|
|
|
|
it('returns [消息渲染异常] when walking the segment list throws', () => {
|
|
const broken = new Proxy([] as JsonValue[], {
|
|
get(target, prop, receiver) {
|
|
if (prop === Symbol.iterator) {
|
|
throw new Error('segment exploded');
|
|
}
|
|
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
expect(formatMessageSegments(broken)).toBe('[消息渲染异常]');
|
|
});
|
|
});
|
|
|
|
describe('formatReply', () => {
|
|
const identity = stubIdentity({
|
|
groups: { 778899: { groupName: '雪夜茶话' } },
|
|
members: { '778899:20001': { card: '冬至' } },
|
|
});
|
|
|
|
it('renders [回复:0] when the reply id is missing', async () => {
|
|
expect(await formatReply(stubStore({}), identity, 0)).toBe('[回复:0]');
|
|
expect(await formatReply(stubStore({}), identity, Number.NaN)).toBe('[回复:0]');
|
|
});
|
|
|
|
it('renders [回复:<id>] when the store has no matching event', async () => {
|
|
expect(await formatReply(stubStore({}), identity, 4100)).toBe('[回复:4100]');
|
|
});
|
|
|
|
it('resolves a group reply from the identity roster', async () => {
|
|
const store = stubStore({
|
|
4100: {
|
|
message_type: 'group',
|
|
group_id: 778899,
|
|
user_id: 20001,
|
|
message: [{ type: 'text', data: { text: '霜降好' } }],
|
|
},
|
|
});
|
|
expect(await formatReply(store, identity, 4100)).toBe('[回复 [冬至(20001)]: 霜降好]');
|
|
});
|
|
|
|
it('coerces string user_id / group_id and uses the stored sender card on cache miss', async () => {
|
|
const store = stubStore({
|
|
4101: {
|
|
message_type: 'group',
|
|
group_id: '778899',
|
|
user_id: '20003',
|
|
sender: { card: '处暑', nickname: 'ignored' },
|
|
message: '晚风',
|
|
},
|
|
});
|
|
expect(await formatReply(store, stubIdentity(), 4101)).toBe('[回复 [处暑(20003)]: 晚风]');
|
|
});
|
|
|
|
it('does not treat private messages as group and ignores a whitespace-only card', async () => {
|
|
const store = stubStore({
|
|
4102: {
|
|
message_type: 'private',
|
|
group_id: 778899,
|
|
user_id: 20001,
|
|
sender: { card: ' ', nickname: '寒露' },
|
|
message: [{ type: 'text', data: { text: '私聊一句' } }],
|
|
},
|
|
});
|
|
// Fallback is `(card || nickname).trim()`: a whitespace card is truthy
|
|
// before trim, so the nickname is not used.
|
|
expect(await formatReply(store, stubIdentity(), 4102)).toBe('[回复 20001: 私聊一句]');
|
|
});
|
|
|
|
it('uses sender.nickname when card is absent', async () => {
|
|
const store = stubStore({
|
|
4102: {
|
|
message_type: 'private',
|
|
user_id: 20001,
|
|
sender: { nickname: '寒露' },
|
|
message: [{ type: 'text', data: { text: '私聊一句' } }],
|
|
},
|
|
});
|
|
expect(await formatReply(store, stubIdentity(), 4102)).toBe(
|
|
'[回复 [寒露(20001)]: 私聊一句]',
|
|
);
|
|
});
|
|
|
|
it('keeps the bare uin when sender is not a usable object', async () => {
|
|
const noSender = stubStore({
|
|
4103: {
|
|
message_type: 'private',
|
|
user_id: 20004,
|
|
sender: null,
|
|
message: 'x',
|
|
},
|
|
});
|
|
expect(await formatReply(noSender, stubIdentity(), 4103)).toBe('[回复 20004: x]');
|
|
|
|
const arraySender = stubStore({
|
|
4104: {
|
|
message_type: 'private',
|
|
user_id: 20004,
|
|
sender: ['寒露'],
|
|
message: 'y',
|
|
},
|
|
});
|
|
expect(await formatReply(arraySender, stubIdentity(), 4104)).toBe('[回复 20004: y]');
|
|
});
|
|
|
|
it('maps non-numeric user_id to 0 and skips the sender-name fallback', async () => {
|
|
const store = stubStore({
|
|
4105: {
|
|
message_type: 'private',
|
|
user_id: 'not-a-uin',
|
|
sender: { nickname: '不该出现' },
|
|
message: 'z',
|
|
},
|
|
});
|
|
expect(await formatReply(store, stubIdentity(), 4105)).toBe('[回复 0: z]');
|
|
});
|
|
|
|
it('truncates the body preview to 30 characters after segment rendering', async () => {
|
|
const store = stubStore({
|
|
4106: {
|
|
message_type: 'private',
|
|
user_id: 20004,
|
|
message: [{ type: 'text', data: { text: '正'.repeat(80) } }],
|
|
},
|
|
});
|
|
expect(await formatReply(store, stubIdentity(), 4106)).toBe(
|
|
`[回复 20004: ${'正'.repeat(30)}...]`,
|
|
);
|
|
});
|
|
|
|
it('renders referenced reply segments as tokens instead of recursing', async () => {
|
|
const store = stubStore({
|
|
4107: {
|
|
message_type: 'private',
|
|
user_id: 20004,
|
|
message: [
|
|
{ type: 'reply', data: { id: 1 } },
|
|
{ type: 'text', data: { text: '外层' } },
|
|
],
|
|
},
|
|
});
|
|
expect(await formatReply(store, stubIdentity(), 4107)).toBe('[回复 20004: [回复:1] 外层]');
|
|
});
|
|
|
|
it('returns [回复:<id>] when findEvent throws', async () => {
|
|
const store: ReplyEventLookup = {
|
|
findEvent: () => {
|
|
throw new Error('store unavailable');
|
|
},
|
|
};
|
|
expect(await formatReply(store, identity, 4100)).toBe('[回复:4100]');
|
|
});
|
|
});
|
|
|
|
describe('formatEvent', () => {
|
|
const identity = stubIdentity({
|
|
groups: { 778899: { groupName: '雪夜茶话' } },
|
|
members: {
|
|
'778899:20001': { card: '冬至' },
|
|
'778899:20002': { card: '小满' },
|
|
'778899:20003': { nickname: '谷雨' },
|
|
},
|
|
friends: {
|
|
30001: { nickname: '惊蛰' },
|
|
30002: { remark: '白露' },
|
|
},
|
|
});
|
|
|
|
it('returns null for kinds that have dedicated message-log paths', () => {
|
|
expect(formatEvent(identity, { kind: 'group_message' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'friend_message' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'temp_message' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'group_file_upload' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'friend_add' } as QQEventVariant)).toBeNull();
|
|
});
|
|
|
|
it('returns null for kinds with no notice-line renderer', () => {
|
|
expect(formatEvent(identity, { kind: 'bot_offline' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'ptt_trans_result' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'group_card_change' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(identity, { kind: 'friend_input_status' } as QQEventVariant)).toBeNull();
|
|
});
|
|
|
|
it('renders group_recall', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_recall',
|
|
groupId: 778899,
|
|
authorUin: 20001,
|
|
operatorUin: 20002,
|
|
} as QQEventVariant),
|
|
).toBe('群撤回 [雪夜茶话(778899)] | [冬至(20001)] 被 [小满(20002)] 撤回');
|
|
});
|
|
|
|
it('renders friend_recall', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'friend_recall',
|
|
userUin: 30001,
|
|
} as QQEventVariant),
|
|
).toBe('私聊撤回 [惊蛰(30001)] 撤回了消息');
|
|
});
|
|
|
|
it('renders group_member_join including a uid-only joiner', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_member_join',
|
|
groupId: 778899,
|
|
userUin: 20003,
|
|
userUid: 'u_guyu',
|
|
} as QQEventVariant),
|
|
).toBe('入群 [谷雨(20003)] 加入 [雪夜茶话(778899)]');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_member_join',
|
|
groupId: 778899,
|
|
userUin: 0,
|
|
userUid: 'u_only',
|
|
} as QQEventVariant),
|
|
).toBe('入群 u_only 加入 [雪夜茶话(778899)]');
|
|
});
|
|
|
|
it('renders group_member_leave for leave, kick, and disband', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_member_leave',
|
|
groupId: 778899,
|
|
userUin: 20001,
|
|
leaveType: 'leave',
|
|
} as QQEventVariant),
|
|
).toBe('退群 [冬至(20001)] 退出 [雪夜茶话(778899)]');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_member_leave',
|
|
groupId: 778899,
|
|
userUin: 20001,
|
|
leaveType: 'kick',
|
|
} as QQEventVariant),
|
|
).toBe('退群 [冬至(20001)] 被踢出 [雪夜茶话(778899)]');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_member_leave',
|
|
groupId: 778899,
|
|
userUin: 20001,
|
|
leaveType: 'disband',
|
|
} as QQEventVariant),
|
|
).toBe('退群 [冬至(20001)] 随群解散 [雪夜茶话(778899)]');
|
|
});
|
|
|
|
it('renders group_mute with the duration in seconds', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_mute',
|
|
groupId: 778899,
|
|
userUin: 20001,
|
|
duration: 600,
|
|
} as QQEventVariant),
|
|
).toBe('禁言 [雪夜茶话(778899)] | [冬至(20001)] 600秒');
|
|
});
|
|
|
|
it('renders group_admin set and unset', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_admin',
|
|
groupId: 778899,
|
|
userUin: 20002,
|
|
set: true,
|
|
} as QQEventVariant),
|
|
).toBe('管理 [雪夜茶话(778899)] | [小满(20002)] +管理员');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_admin',
|
|
groupId: 778899,
|
|
userUin: 20002,
|
|
set: false,
|
|
} as QQEventVariant),
|
|
).toBe('管理 [雪夜茶话(778899)] | [小满(20002)] -管理员');
|
|
});
|
|
|
|
it('renders friend_poke and group_poke', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'friend_poke',
|
|
peerUin: 30001,
|
|
senderUin: 30002,
|
|
targetUin: 30001,
|
|
} as QQEventVariant),
|
|
).toBe('私聊戳 [惊蛰(30001)] | [白露(30002)] -> [惊蛰(30001)]');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_poke',
|
|
groupId: 778899,
|
|
userUin: 20001,
|
|
targetUin: 20002,
|
|
} as QQEventVariant),
|
|
).toBe('群戳 [雪夜茶话(778899)] | [冬至(20001)] -> [小满(20002)]');
|
|
});
|
|
|
|
it('renders friend_request and group_invite', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'friend_request',
|
|
fromUin: 30002,
|
|
message: '加个好友',
|
|
} as QQEventVariant),
|
|
).toBe('好友请求 [白露(30002)]: 加个好友');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_invite',
|
|
fromUin: 30001,
|
|
groupId: 778899,
|
|
} as QQEventVariant),
|
|
).toBe('群邀请 [惊蛰(30001)] -> [雪夜茶话(778899)]');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_invite',
|
|
fromUin: 30001,
|
|
invitedUin: 30002,
|
|
groupId: 778899,
|
|
} as QQEventVariant),
|
|
).toBe('群邀请 [惊蛰(30001)] 邀 [白露(30002)] -> [雪夜茶话(778899)]');
|
|
});
|
|
|
|
it('renders group_essence set and unset', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_essence',
|
|
groupId: 778899,
|
|
set: true,
|
|
} as QQEventVariant),
|
|
).toBe('精华 [雪夜茶话(778899)] | +精华');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_essence',
|
|
groupId: 778899,
|
|
set: false,
|
|
} as QQEventVariant),
|
|
).toBe('精华 [雪夜茶话(778899)] | -精华');
|
|
});
|
|
|
|
it('renders group_msg_emoji_like add and remove', () => {
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_msg_emoji_like',
|
|
groupId: 778899,
|
|
operatorUin: 20001,
|
|
operatorUid: 'u_dongzhi',
|
|
emojiId: '76',
|
|
msgSeq: 8801,
|
|
isAdd: true,
|
|
} as QQEventVariant),
|
|
).toBe('表情回应 [雪夜茶话(778899)] | [冬至(20001)] +[76] msgSeq=8801');
|
|
|
|
expect(
|
|
formatEvent(identity, {
|
|
kind: 'group_msg_emoji_like',
|
|
groupId: 778899,
|
|
operatorUin: 0,
|
|
operatorUid: 'u_dongzhi',
|
|
emojiId: 'ok',
|
|
msgSeq: 8802,
|
|
isAdd: false,
|
|
} as QQEventVariant),
|
|
).toBe('表情回应 [雪夜茶话(778899)] | u_dongzhi -[ok] msgSeq=8802');
|
|
});
|
|
|
|
it('degrades to numeric ids when the roster is empty', () => {
|
|
expect(
|
|
formatEvent(stubIdentity(), {
|
|
kind: 'group_recall',
|
|
groupId: 778899,
|
|
authorUin: 20001,
|
|
operatorUin: 20002,
|
|
} as QQEventVariant),
|
|
).toBe('群撤回 778899 | 20001 被 20002 撤回');
|
|
});
|
|
|
|
it('returns null when reading the event kind throws', () => {
|
|
const broken = {
|
|
get kind() {
|
|
throw new Error('event exploded');
|
|
},
|
|
} as unknown as QQEventVariant;
|
|
expect(formatEvent(identity, broken)).toBeNull();
|
|
});
|
|
});
|