import { describe, expect, it } from 'vitest'
import { parseMessageContent } from '../../src/main/message-parser'
describe('message parser', () => {
it('parses image, voice and sticker messages without confusing their types', () => {
expect(parseMessageContent('
', 3)).toMatchObject({
type: 'image',
md5: '0123456789abcdef0123456789abcdef'
})
expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' })
expect(parseMessageContent('', 34)).toEqual({ type: 'voice' })
expect(
parseMessageContent(
'',
47
)
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
})
it('reads the WeChat voice length in milliseconds and keeps fractional seconds', () => {
// 真机实测(2026-09-20):voicelength 才是毫秒时长,length 是编码数据长度——别取错。
// 属性值取自一条真机采样的语音(1.6 秒,微信气泡显示 2")。
const parsed = parseMessageContent(
'',
34
)
expect(parsed).toEqual({ type: 'voice', duration: 1.6 })
// 误取 length 会得到 6.672 秒(把 2" 的语音显示成 0:07)——这条断言就是防这个回归。
expect(parsed).not.toEqual({ type: 'voice', duration: 6.672 })
// 微信四舍五入到整秒,取整必须在显示层做,不能在解析层丢精度。
expect(parseMessageContent('', 34)).toEqual({
type: 'voice',
duration: 4.211
})
})
it('leaves the voice duration undefined when the payload is missing or unusable', () => {
expect(parseMessageContent('', 34)).toEqual({ type: 'voice' })
expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' })
expect(parseMessageContent('', 34)).toEqual({
type: 'voice'
})
expect(parseMessageContent('', 34)).toEqual({
type: 'voice'
})
expect(parseMessageContent('', 34)).toEqual({
type: 'voice'
})
})
it('keeps video metadata when WeChat omits every MD5 field', () => {
const parsed = parseMessageContent(
'',
43
)
expect(parsed).toEqual({
type: 'video',
md5: undefined,
newMd5: undefined,
rawMd5: undefined,
byteLength: 6402169,
duration: 30,
width: 224,
height: 398
})
})
it('parses merged forwards and preserves nested visible text', () => {
const parsed = parseMessageContent(
'19转发多条内容测试成员脱敏内容',
49
)
expect(parsed.type).toBe('forwardBundle')
if (parsed.type === 'forwardBundle') {
expect(parsed.title).toBe('转发多条内容')
expect(parsed.items.map((item) => item.text).join(' ')).toContain('脱敏内容')
}
})
it.each([
['6', '测试附件.pdf'],
['74', '发送中的附件.zip']
])(
'keeps file app message type %s when attachment metadata contains record tags',
(typeVal, title) => {
const parsed = parseMessageContent(
`${typeVal}${title}1 MBlegacy metadata${title}`,
49
)
expect(parsed).toMatchObject({
type: 'share',
title,
typeVal
})
}
)
it('decodes XML entities in file titles used for attachment lookup', () => {
const parsed = parseMessageContent(
'6Check-in Voucher (Samabe Bali Suites & Villas).pdf',
49
)
expect(parsed).toMatchObject({
type: 'share',
title: 'Check-in Voucher (Samabe Bali Suites & Villas).pdf',
typeVal: '6'
})
})
it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent(
'5普通分享legacy metadata',
49
)
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('preserves every article in a public-account multi-article message', () => {
const parsed = parseMessageContent(
`5长江日报- 霍尔木兹海峡开放临时协议国际油价短期走势https://mp.weixin.qq.com/b
- 东野圭吾新作https://mp.weixin.qq.com/c
`,
49
)
expect(parsed).toMatchObject({ type: 'share', appname: '长江日报' })
if (parsed.type === 'share') {
expect(parsed.articles).toHaveLength(3)
expect(parsed.articles?.map((article) => article.title)).toEqual([
'女子吃酒席时意外发现',
'霍尔木兹海峡开放临时协议',
'东野圭吾新作'
])
}
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'57回复内容1123456789@chatroomwxid_fixture_member被引用内容',
49
)
expect(parsed).toMatchObject({
type: 'quote',
quotedSender: 'wxid_fixture_member',
quotedContent: '被引用内容'
})
})
it('renders the templated join-group notice instead of its hidden button label', () => {
// 微信 4.x 的 sysmsgtemplate: 为空、正文在 里用 $名称$ 引用 link,
// hidden="1" 的 link 是可点击按钮,不应作为正文。
const parsed = parseMessageContent(
[
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
''
].join(''),
10000
)
expect(parsed).toMatchObject({
type: 'system',
content: '"成员昵称"通过扫描你分享的二维码加入群聊'
})
})
it('keeps parsing the legacy delchatroommember join-group notice', () => {
const parsed = parseMessageContent(
[
'',
'',
'',
'qrcode',
'',
'',
''
].join(''),
10000
)
expect(parsed).toMatchObject({
type: 'system',
content: '"成员昵称"通过扫描你分享的二维码加入群聊'
})
})
it('uses an explicit unknown type for unsupported messages', () => {
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
type: 'unknown',
raw: 'opaque fixture payload',
messageType: 999
})
})
it('unmasks packed local_type low 32 bits', () => {
const packed = 1 + 5 * 0x100000000
expect(parseMessageContent('plain text body', packed)).toEqual({
type: 'text',
content: 'plain text body'
})
})
it('parses patMsg into system text', () => {
const xml = [
'',
'',
'',
'',
'',
'',
'',
''
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'system',
content: 'alice拍了拍bob的头像'
})
})
it('parses findernamecard as a read-only share card', () => {
const xml = [
'',
'51',
'',
'',
'',
'',
'',
''
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
title: '示例视频号',
appname: '视频号',
typeVal: '51'
})
})
it('falls back to product item share without a title', () => {
const xml = [
'',
'2000',
'',
'',
'',
'',
''
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
title: '测试商品',
des: '¥9.9',
typeVal: '2000'
})
})
it('renders friend verify local_type 37 as system text', () => {
const xml = ''
const parsed = parseMessageContent(xml, 37)
expect(parsed).toMatchObject({
type: 'system',
content: '新朋友 · 我是群里的'
})
})
it('parses music share cards from song/album fields', () => {
const xml = [
'',
'3',
'',
'',
'',
'',
'',
''
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
title: '晴天',
des: '周杰伦',
url: 'https://music.example/song',
appname: '音乐',
typeVal: '3'
})
})
it('parses subscribe template cards', () => {
const clean = [
'',
'',
'',
'',
'',
''
].join('')
const parsed = parseMessageContent(clean, 49)
expect(parsed).toMatchObject({
type: 'share',
title: '服务通知',
des: '您的订单已发货',
appname: '订阅消息',
typeVal: 'subscribe'
})
})
it('parses kefu template cards and falls back to system text', () => {
const card = [
'',
'',
'',
'',
''
].join('')
expect(parseMessageContent(card, 49)).toMatchObject({
type: 'share',
title: '店铺客服',
des: '点击查看订单',
typeVal: 'kefu'
})
const menu = ''
expect(parseMessageContent(menu, 49)).toMatchObject({
type: 'system',
content: '客服消息'
})
})
it('parses transfer wcpayinfo fields (type 2000)', () => {
// 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写)
const xml = [
'',
'',
'',
'2000',
'',
'',
'3',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'2',
'',
'',
'',
''
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
typeVal: '2000',
title: '微信转账',
transfer: {
paySubtype: '3',
amountText: '¥2900.00',
transcationId: '53010003370089202609134087894875',
transferId: '1000050001202609130232821248942',
invalidTime: '1789348128',
beginTransferTime: '1789261728',
effectiveDate: '1',
payMemo: '房租',
receiverUsername: 'wxid_receiver',
transferStatus: '2',
transferStatusText: '已收款',
transId: 'trans-fixture-1',
feeType: 'CNY',
refundBankType: '1'
}
})
})
it('parses red packet wcpayinfo fields (type 2001 / mmpayhb)', () => {
const xml = [
'',
'',
'',
'2001',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'1',
'1',
'2',
''
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'redPacket',
title: '中秋快乐,望重置',
pay: {
templateId: '7a2a165d31da7fce6dd77e05c300028a',
receiveTitle: '恭喜发财,大吉大利',
sendTitle: '中秋快乐,望重置',
sceneText: '微信红包',
senderDes: '查看红包',
receiverDes: '领取红包',
sendId: '1000039801202609247176036834007',
hbType: '1',
hbStatus: '1',
receiveStatus: '2',
redPacketStatusText: '已领取'
}
})
})
})