fix: 完善聊天解析与导出体验

- 修复引用消息名称和图片布局
- 明确单会话图片测试日志范围
- 支持导出文件附件
- 完善图片批测、会话刷新和安全退出
This commit is contained in:
Wxw-Gu
2026-08-04 12:03:07 +08:00
parent d76727875d
commit c70e49bf16
41 changed files with 2320 additions and 221 deletions
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ConversationSidebarHeader } from '../../src/renderer/src/components/conversation/ConversationSidebarHeader'
describe('ConversationSidebarHeader', () => {
it('refreshes the conversation list from the archive header', async () => {
const onRefresh = vi.fn()
render(
<ConversationSidebarHeader
totalCount={1306}
searchValue=""
onSearchChange={vi.fn()}
refreshing={false}
onRefresh={onRefresh}
/>
)
expect(screen.getByText('1306 个会话')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '刷新会话列表' }))
expect(onRefresh).toHaveBeenCalledOnce()
})
it('disables the refresh button while contacts are loading', () => {
render(
<ConversationSidebarHeader
totalCount={12}
searchValue="测试"
onSearchChange={vi.fn()}
refreshing
onRefresh={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '正在刷新会话列表' })).toBeDisabled()
expect(screen.getByDisplayValue('测试')).toBeInTheDocument()
})
})
+21
View File
@@ -9,6 +9,7 @@ vi.mock('../../src/renderer/src/components/image-loader', () => ({
}))
import { ImageBubble } from '../../src/renderer/src/components/ImageBubble'
import { RichMessageBubble } from '../../src/renderer/src/components/RichMessageBubble'
const thumbnail =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
@@ -50,4 +51,24 @@ describe('ImageBubble', () => {
await userEvent.click(screen.getByText('加载失败'))
expect(await screen.findByAltText('图片')).toBeVisible()
})
it('places a quoted image on the line below the quoted sender', async () => {
requestImage.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
const { container } = render(
<RichMessageBubble
contentData={{
type: 'quote',
content: '回复内容',
quotedContent: '[图片]',
quotedSender: '测试群成员',
quotedImageMd5: 'fixture-image'
}}
sessionId="fixture-session"
/>
)
expect(screen.getByText('测试群成员')).toBeInTheDocument()
expect(container.querySelector('.quoted-message')).toHaveClass('quoted-message-image')
expect(await screen.findByAltText('图片')).toBeVisible()
})
})
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ImageTestSection } from '../../src/renderer/src/features/settings/image-decryption/ImageTestSection'
import { initialImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/imageDecryptionReducer'
import type { ImageBatchTestState } from '../../src/renderer/src/features/settings/image-decryption/types'
const emptyBatchTest: ImageBatchTestState = {
running: false,
stopRequested: false,
elapsedMs: 0,
items: []
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('ImageTestSection', () => {
it('keeps the file step successful when decryption fails and copies diagnostics', async () => {
const onCopyLog = vi.fn()
render(
<ImageTestSection
state={{
...initialImageDecryptionState,
phase: 'test-failed',
selectedUserMd5: 'conversation-md5',
contacts: [
{
md5: 'conversation-md5',
m_nsUsrName: 'fixture-user',
m_nsNickName: '测试会话',
type: 'user'
}
],
testResult: {
success: false,
code: 'DECRYPT_FAILED',
error: '图片密钥与当前账号不匹配',
fileFound: true,
decrypted: false,
readable: false,
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)'
}
}}
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={vi.fn()}
onStopBatchTest={vi.fn()}
onCopyLog={onCopyLog}
onSave={vi.fn()}
/>
)
expect(screen.getByText('找到图片文件').closest('li')).toHaveClass('image-step-ok')
expect(screen.getByText('解密成功').closest('li')).toHaveClass('image-step-fail')
expect(screen.getByText('图片可以读取').closest('li')).toHaveClass('image-step-skipped')
expect(screen.getByText('仅当前会话的单次测试日志')).toHaveAttribute(
'title',
expect.stringContaining('不包含批量测试结果')
)
await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
expect(onCopyLog).toHaveBeenCalledOnce()
})
it('disables log copying before a test result exists', () => {
render(
<ImageTestSection
state={initialImageDecryptionState}
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={vi.fn()}
onStopBatchTest={vi.fn()}
onCopyLog={vi.fn()}
onSave={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '复制日志' })).toBeDisabled()
})
it('filters groups and contacts before starting a batch test', async () => {
const onBatchTest = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(
<ImageTestSection
state={{
...initialImageDecryptionState,
contacts: [
{
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '测试群聊',
type: 'group'
},
{
md5: 'user-md5',
m_nsUsrName: 'wxid_friend',
m_nsNickName: '测试联系人',
type: 'user'
}
]
}}
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={onBatchTest}
onStopBatchTest={vi.fn()}
onCopyLog={vi.fn()}
onSave={vi.fn()}
/>
)
await userEvent.click(screen.getByRole('button', { name: '群聊 1' }))
expect(screen.getByRole('option', { name: '测试群聊' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '测试联系人' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '测试筛选结果(1' }))
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('测试 1 个会话'))
expect(onBatchTest).toHaveBeenCalledWith([
expect.objectContaining({ md5: 'group-md5', type: 'group' })
])
})
it('shows per-conversation batch result and elapsed time', () => {
render(
<ImageTestSection
state={initialImageDecryptionState}
batchTest={{
running: false,
stopRequested: false,
elapsedMs: 1320,
items: [
{
contact: {
md5: 'success-md5',
m_nsUsrName: 'success@chatroom',
m_nsNickName: '成功群聊',
type: 'group'
},
status: 'success',
elapsedMs: 120
},
{
contact: {
md5: 'no-image-md5',
m_nsUsrName: 'wxid_no_image',
m_nsNickName: '没有图片的联系人',
type: 'user'
},
status: 'no-image',
elapsedMs: 88,
error: '最近 300 条消息中没有图片'
}
]
}}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={vi.fn()}
onStopBatchTest={vi.fn()}
onCopyLog={vi.fn()}
onSave={vi.fn()}
/>
)
expect(screen.getByText('成功群聊')).toBeInTheDocument()
expect(screen.getByText('没有图片的联系人')).toBeInTheDocument()
expect(screen.getByText('成功 1')).toBeInTheDocument()
expect(screen.getByText('无图片 1')).toBeInTheDocument()
expect(screen.getByText(/2\/2 · 已耗时 1\.3 秒/)).toBeInTheDocument()
})
})
+2 -1
View File
@@ -327,7 +327,8 @@ handle('image:testConfig', () => ({
success: true,
fileFound: true,
decrypted: true,
readable: true
readable: true,
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)\n测试结果:成功(SUCCESS'
}))
handle('image:clearConfig', () => ({ success: true }))
handle('image:getDecoderStatus', () => ({
+17 -3
View File
@@ -1,11 +1,12 @@
import { dirname, join } from 'path'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({
documents: '',
accountRoot: '',
videoPath: '',
messages: [] as Message[],
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
@@ -18,7 +19,9 @@ vi.mock('electron', () => ({
}))
vi.mock('../../src/main/services/chat-service', () => ({
listMessages: () => structuredClone(state.messages),
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
}),
getContactAvatars: () => ({})
}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
@@ -92,12 +95,16 @@ const message = (overrides: Partial<Message>): Message => ({
describe('media export flow', () => {
beforeEach(() => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
state.accountRoot = join(state.documents, 'fixture-account')
state.videoPath = join(state.documents, 'fixture.mp4')
writeFileSync(
state.videoPath,
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
)
state.imageLookups = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
state.messages = [
message({
id: 'voice-ok',
@@ -123,6 +130,11 @@ describe('media export flow', () => {
id: 'video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
}),
message({
id: 'file',
type: '文件',
contentData: { type: 'share', typeVal: '6', title: '测试附件.txt', url: '' }
})
]
})
@@ -143,7 +155,7 @@ describe('media export flow', () => {
name: '脱敏会话',
format: 'html',
outputName: 'fixture',
kinds: ['voice', 'image', 'video'],
kinds: ['voice', 'image', 'video', 'file'],
includeMedia: true,
preferOriginal: true,
fallbackThumbnail: true,
@@ -161,8 +173,10 @@ describe('media export flow', () => {
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
'ftyp'
)
expect(readFileSync(join(outputDir, 'media/file_5_测试附件.txt'), 'utf8')).toBe('附件内容')
expect(html).toContain('src="voices/voice_1_1.wav"')
expect(html).toContain('src="media/video_4.mp4"')
expect(html).toContain('href="media/file_5_测试附件.txt" download')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false,
+32 -1
View File
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WechatDb } from '../../src/main/wechat-db'
import { listContactsAsync, setChatDb } from '../../src/main/services/chat-service'
import {
closeChatDbForQuit,
isReady,
listContactsAsync,
setChatDb
} from '../../src/main/services/chat-service'
describe('chat service contacts', () => {
afterEach(() => setChatDb(null))
@@ -36,4 +41,30 @@ describe('chat service contacts', () => {
})
expect(contacts[0]?.m_nsNickName).toBe('测试群聊')
})
it('detaches the database immediately and awaits native cleanup on quit', async () => {
let finishClose: ((value: boolean) => void) | undefined
const closeAsync = vi.fn(
() =>
new Promise<boolean>((resolve) => {
finishClose = resolve
})
)
const fakeDb = {
close: vi.fn(),
closeAsync
} as unknown as WechatDb
setChatDb(fakeDb)
const closing = closeChatDbForQuit()
expect(isReady()).toBe(false)
expect(closeAsync).toHaveBeenCalledOnce()
finishClose?.(true)
await expect(closing).resolves.toBe(true)
const lateDb = { close: vi.fn() } as unknown as WechatDb
expect(setChatDb(lateDb)).toBe(false)
expect(lateDb.close).toHaveBeenCalledOnce()
})
})
+9
View File
@@ -38,6 +38,14 @@ describe('export media', () => {
id: 'missing',
type: '语音',
exportMediaError: '语音文件缺失:本地未找到语音数据'
}),
baseMessage({
id: 'file',
type: '文件',
contentData: { type: 'share', typeVal: '6', title: '示例附件.zip', url: '' },
exportMediaType: 'file',
exportMediaName: '示例附件.zip',
exportMediaUrl: 'media/file_4_示例附件.zip'
})
])
@@ -46,6 +54,7 @@ describe('export media', () => {
)
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(html).toContain('class="file-attachment" href="media/file_4_示例附件.zip" download')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
})
+30
View File
@@ -0,0 +1,30 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, describe, expect, it } from 'vitest'
import { FileAssetService } from '../../src/main/file-asset-service'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('file asset service', () => {
it('prefers the message month and supports duplicate download suffixes', () => {
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-file-asset-'))
roots.push(accountRoot)
const august = join(accountRoot, 'msg', 'file', '2026-08')
const july = join(accountRoot, 'msg', 'file', '2026-07')
mkdirSync(august, { recursive: true })
mkdirSync(july, { recursive: true })
writeFileSync(join(july, '产品说明.txt'), 'old')
writeFileSync(join(august, '产品说明(1).txt'), 'current')
const service = new FileAssetService({ getAccountRoot: () => accountRoot })
const result = service.resolve('产品说明.txt', new Date(2026, 7, 4).getTime() / 1000)
expect(result).toMatchObject({ success: true, fileName: '产品说明(1).txt' })
expect(result.filePath).toBe(realpathSync(join(august, '产品说明(1).txt')))
})
})
+80 -6
View File
@@ -16,6 +16,7 @@ import { ImageDecryptService } from '../../src/main/image-decrypt-service'
const aesKey = '0123456789abcdef'
const xorKey = 0x40
const originalResourcesPath = process.resourcesPath
function writeV2Dat(file: string): Buffer {
const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
@@ -35,22 +36,95 @@ function writeV2Dat(file: string): Buffer {
}
describe('DAT image decryption', () => {
beforeAll(() => mkdirSync(root, { recursive: true }))
afterAll(() => rmSync(root, { recursive: true, force: true }))
beforeAll(() => {
mkdirSync(root, { recursive: true })
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: root })
})
afterAll(() => {
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath
})
rmSync(root, { recursive: true, force: true })
})
it('decrypts a synthetic V2 AES/raw/XOR fixture', () => {
const file = join(root, 'fixture.dat')
const expected = writeV2Dat(file)
expect(new ImageDecryptService('0x40', aesKey).decryptImage(file)).toEqual(expected)
const service = new ImageDecryptService('0x40', aesKey)
expect(service.decryptImage(file)).toEqual(expected)
expect(service.decryptImageToBase64(file)).toMatch(/^data:image\/png;base64,/)
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'SUCCESS',
datVersion: 2,
imageFormat: 'PNG'
})
})
it('rejects the wrong AES key and unsupported legacy signatures accurately', () => {
const file = join(root, 'fixture.dat')
writeV2Dat(file)
expect(new ImageDecryptService('0x40', 'fedcba9876543210').decryptImage(file)).toBeNull()
const wrongKeyService = new ImageDecryptService('0x40', 'fedcba9876543210')
expect(wrongKeyService.decryptImage(file)).toBeNull()
expect(wrongKeyService.getLastDecodeDiagnostic()).toMatchObject({
code: 'AES_DECRYPT_FAILED',
datVersion: 2
})
const legacy = join(root, 'legacy.dat')
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull()
writeFileSync(legacy, Buffer.from([0x12, 0x34, 0x56, 0x78]))
const legacyService = new ImageDecryptService('0x40', aesKey)
expect(legacyService.decryptImage(legacy)).toBeNull()
expect(legacyService.getLastDecodeDiagnostic()).toMatchObject({
code: 'UNSUPPORTED_DAT_VERSION',
datVersion: 0
})
})
it('finds modern _M variants and reads plain images stored with a DAT extension', async () => {
const accountRoot = join(root, 'modern-account')
const sessionId = '77705c31c50e8a4242a9d527fe9433de'
const imageDirectory = join(accountRoot, 'msg', 'attach', sessionId, '2025-10', 'Img')
mkdirSync(imageDirectory, { recursive: true })
const imageBase = '9718e38ad90f57f9e833d17ff2373abd'
const mediumFile = join(imageDirectory, `${imageBase}_M.dat`)
writeFileSync(mediumFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
const service = new ImageDecryptService('0x40', aesKey)
await expect(
service.findImageFileAsync(undefined, imageBase, {
allowThumbnail: false,
accountDir: accountRoot,
sessionId
})
).resolves.toBe(mediumFile)
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'DIRECT_IMAGE',
imageFormat: 'PNG'
})
await expect(service.decryptImageToBase64WithFallbackAsync(mediumFile, true)).resolves.toEqual(
expect.objectContaining({ filePath: mediumFile })
)
const thumbnailBase = '37a9000000000000000000000000ceaa'
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: false,
accountDir: accountRoot,
sessionId
})
).resolves.toBeNull()
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: true,
accountDir: accountRoot,
sessionId
})
).resolves.toBe(thumbnailFile)
})
})
@@ -0,0 +1,117 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
const root = mkdtempSync(join(tmpdir(), 'wxe-image-diagnostic-'))
vi.mock('electron', () => ({
app: {
getPath: () => root,
getVersion: () => '2.1.7-test'
}
}))
vi.mock('../../src/main/image-decrypt-service', () => ({
ImageDecryptService: class {},
inspectImageDecoderStatus: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => ({}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
validateImageKeyRequest: vi.fn()
}))
vi.mock('../../src/main/services/wechat-process-status', () => ({
isWechatRunning: vi.fn()
}))
import { buildImageTestDiagnosticLog } from '../../src/main/services/image-decryption-status-service'
describe('image decryption diagnostic log', () => {
beforeAll(() => mkdirSync(join(root, 'private-account', 'msg', 'attach'), { recursive: true }))
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('keeps useful failure details without exposing keys or absolute paths', () => {
const resourceRoot = join(root, 'private-account')
const aesKey = '0123456789abcdef'
const imageMd5 = '1234567890abcdef1234567890abcdef'
const log = buildImageTestDiagnosticLog({
request: {
userMd5: 'conversation-secret',
resourceRoot,
xorKey: '0x40',
aesKey
},
result: {
success: false,
code: 'DECRYPT_FAILED',
error: '图片密钥与当前账号不匹配,或图片文件已损坏',
fileFound: true,
decrypted: false,
readable: false,
isThumbnail: false
},
startedAt: Date.now() - 25,
testedImage: {
md5: imageMd5,
datName: `${imageMd5}_h.dat`,
sessionId: 'wxid_private_session',
selection: '自动测试样本'
},
filePath: join(
resourceRoot,
'msg',
'attach',
imageMd5,
'2026-08',
'Img',
`${imageMd5}_h.dat`
),
decodeDiagnostic: {
code: 'AES_DECRYPT_FAILED',
detail: 'AES 解密校验失败,密钥可能与当前账号不匹配',
datVersion: 2,
fileSize: 2048
}
})
expect(log).toContain('AES_DECRYPT_FAILED')
expect(log).toContain('WeChat 4.0 V2')
expect(log).toContain('内容未记录')
expect(log).not.toContain(aesKey)
expect(log).not.toContain(resourceRoot)
expect(log).not.toContain(imageMd5)
expect(log).not.toContain('conversation-secret')
expect(log).not.toContain('wxid_private_session')
})
it('describes plain images with a DAT extension without reporting an unsupported protocol', () => {
const resourceRoot = join(root, 'private-account')
const filePath = join(resourceRoot, 'msg', 'attach', 'plain-image_M.dat')
writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
const log = buildImageTestDiagnosticLog({
request: {
userMd5: 'conversation-secret',
resourceRoot,
xorKey: '0x40',
aesKey: '0123456789abcdef'
},
result: {
success: true,
fileFound: true,
decrypted: true,
readable: true,
isThumbnail: false
},
startedAt: Date.now() - 10,
filePath,
decodeDiagnostic: {
code: 'DIRECT_IMAGE',
detail: 'DAT 文件内容是可直接读取的图片',
fileSize: 8,
imageFormat: 'PNG'
}
})
expect(log).toContain('DAT 协议:明文图片(无需 DAT 解密)')
expect(log).not.toContain('不受支持/旧版格式')
})
})
+13
View File
@@ -28,6 +28,19 @@ describe('message parser', () => {
}
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
49
)
expect(parsed).toMatchObject({
type: 'quote',
quotedSender: 'wxid_fixture_member',
quotedContent: '被引用内容'
})
})
it('uses an explicit unknown type for unsupported messages', () => {
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
type: 'unknown',
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { Message } from '../../src/shared/types'
import { enrichQuotedMessages } from '../../src/renderer/src/utils/quoted-messages'
const message = (overrides: Partial<Message>): Message => ({
id: 'fixture',
from: 'user',
type: '普通文本',
datetime: '2026-08-04 12:00:00',
content: '',
isSender: false,
createTime: 1_785_816_000,
...overrides
})
describe('quoted message enrichment', () => {
it('maps an internal quoted sender id to the loaded group member name', () => {
const quoted = message({
id: 'quote',
contentData: {
type: 'quote',
content: '回复',
quotedContent: '[图片]',
quotedSender: 'wxid_fixture_member',
quotedImageMd5: 'a'.repeat(32)
}
})
const [result] = enrichQuotedMessages([quoted], [quoted], (senderId) =>
senderId === 'wxid_fixture_member' ? '测试群成员' : undefined
)
expect(result.contentData).toMatchObject({ quotedSender: '测试群成员' })
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client } from '../../src/main/wcdb4-client'
function setPrivate(target: object, key: string, value: unknown): void {
Reflect.set(target, key, value)
}
describe('Wcdb4Client shutdown', () => {
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
const shutdown = vi.fn(() => 0)
const inFlight = new Set<Promise<unknown>>()
let finishCall: (() => void) | undefined
const pending = new Promise<void>((resolve) => {
finishCall = resolve
})
inFlight.add(pending)
void pending.then(() => inFlight.delete(pending))
setPrivate(client, 'nativeCallsInFlight', inFlight)
setPrivate(client, 'handle', 1)
setPrivate(client, 'wcdbShutdown', shutdown)
setPrivate(client, 'monitorStarted', false)
setPrivate(client, 'displayNameCache', new Map())
setPrivate(client, 'avatarCache', new Map())
setPrivate(client, 'sessionStatusCache', new Map())
setPrivate(client, 'groupNicknameCache', new Map())
const closing = client.closeAsync(1_000)
expect(shutdown).not.toHaveBeenCalled()
finishCall?.()
await expect(closing).resolves.toBe(true)
if (process.platform === 'win32') {
expect(shutdown).not.toHaveBeenCalled()
} else {
expect(shutdown).toHaveBeenCalledOnce()
}
})
})