mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 05:27:48 +08:00
fix: 修复导出日期、语音和联系人名字问题
- 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。 - 语音转文字:不同账号、不同聊天里的语音不会再串到一起。 - 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。
This commit is contained in:
@@ -222,6 +222,8 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据
|
||||
// 时间范围控件在 Query Agent 主路径下移除
|
||||
expect(screen.queryByText('时间范围')).toBeNull()
|
||||
expect(screen.queryByText('不限时间')).toBeNull()
|
||||
expect(screen.queryByText('近 30 天')).toBeNull()
|
||||
expect(screen.getByText('时间由问题决定')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => {
|
||||
|
||||
@@ -58,6 +58,10 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
|
||||
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
|
||||
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
|
||||
retryScheduledReportSend: vi.fn(async () => ({
|
||||
success: true,
|
||||
data: { status: 'success' }
|
||||
})),
|
||||
testScheduledReportErrorNotification: vi.fn(async () => ({
|
||||
success: true,
|
||||
data: { status: 'failed', notificationStatus: 'sent' }
|
||||
@@ -214,4 +218,42 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
'success'
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms a manual resend and does not retry when the dialog is cancelled', async () => {
|
||||
const execution = {
|
||||
id: 'execution-1',
|
||||
taskId: task.id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
finishedAt: '2026-08-27T01:01:00.000Z',
|
||||
status: 'waiting_to_send' as const,
|
||||
sendTarget: group.m_nsUsrName,
|
||||
userMessage: '日报已生成,但微信暂不可用'
|
||||
}
|
||||
vi.mocked(window.api.listScheduledReportExecutions).mockResolvedValue([execution])
|
||||
render(
|
||||
<ScheduledReportsWorkspace
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('日报已生成,但微信暂不可用')).toBeVisible())
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
expect(within(dialog).getByText('目标:' + group.m_nsUsrName)).toBeVisible()
|
||||
expect(within(dialog).getByText('日报:技术交流 · 每日日报')).toBeVisible()
|
||||
expect(within(dialog).getByText(/日期:/)).toBeVisible()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
|
||||
expect(window.api.retryScheduledReportSend).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
|
||||
const secondDialog = await screen.findByRole('alertdialog')
|
||||
fireEvent.click(within(secondDialog).getByRole('button', { name: '确认重新发送' }))
|
||||
await waitFor(() =>
|
||||
expect(window.api.retryScheduledReportSend).toHaveBeenCalledWith(execution.id)
|
||||
)
|
||||
expect(window.api.retryScheduledReportSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -567,7 +567,51 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing
|
||||
}
|
||||
})
|
||||
|
||||
test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => {
|
||||
test('EXPORT-ARCHIVE-05 date navigation jumps across days and years', async ({ page }) => {
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-date-timeline-e2e-'))
|
||||
try {
|
||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||
mkdirSync(dirname(dataPath), { recursive: true })
|
||||
writeFileSync(join(outputDir, 'index.html'), renderExportPage('日期时间轴档案'), 'utf8')
|
||||
const timestamp = (value: string): number => Math.floor(new Date(value).getTime() / 1000)
|
||||
const messages = [
|
||||
archiveMessage('date-3', 'timeline', '日期时间轴档案', '十二月三十一日', timestamp('2025-12-31T12:00:00+08:00')),
|
||||
archiveMessage('date-1', 'timeline', '日期时间轴档案', '九月一日', timestamp('2026-09-01T12:00:00+08:00')),
|
||||
archiveMessage('date-2', 'timeline', '日期时间轴档案', '九月三日', timestamp('2026-09-03T12:00:00+08:00'))
|
||||
]
|
||||
writeFileSync(
|
||||
dataPath,
|
||||
'window.__WECHAT_EXPORT__ = ' + JSON.stringify({
|
||||
version: 1,
|
||||
sourceId: 'timeline',
|
||||
name: '日期时间轴档案',
|
||||
exportedAt: '2026-09-04T00:00:00.000Z',
|
||||
messages
|
||||
}) + ';\n',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
|
||||
const september = page.locator('.timeline-month[data-month="2026-09"]')
|
||||
await expect(september).toBeVisible()
|
||||
await september.click()
|
||||
await expect(page.locator('.timeline-day[data-date="2026-09-01"]')).toBeVisible()
|
||||
await expect(page.locator('.timeline-day[data-date="2026-09-03"]')).toBeVisible()
|
||||
await page.locator('.timeline-day[data-date="2026-09-01"]').click()
|
||||
await expect(page.locator('.timeline-day.active')).toHaveAttribute('data-date', '2026-09-01')
|
||||
await expect(page.locator('.message').filter({ hasText: '九月一日' })).toBeVisible()
|
||||
|
||||
await page.locator('.timeline-year[data-year="2025"]').click()
|
||||
await page.locator('.timeline-month[data-month="2025-12"]').click()
|
||||
await page.locator('.timeline-day[data-date="2025-12-31"]').click()
|
||||
await expect(page.locator('.timeline-day.active')).toHaveAttribute('data-date', '2025-12-31')
|
||||
await expect(page.locator('.message').filter({ hasText: '十二月三十一日' })).toBeVisible()
|
||||
} finally {
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('EXPORT-ARCHIVE-06 each message tab restores its previous scroll anchor', async ({ page }) => {
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-'))
|
||||
try {
|
||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||
|
||||
@@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({
|
||||
avatarMap: {} as Record<string, string>,
|
||||
messages: [] as Message[],
|
||||
messagesByUser: {} as Record<string, Message[]>,
|
||||
exportErrors: {} as Record<string, string>,
|
||||
exportReads: [] as string[],
|
||||
selfInfoReads: 0,
|
||||
groupSnapshotReads: [] as string[],
|
||||
@@ -97,6 +98,7 @@ vi.mock('../../src/main/services/chat-service', () => ({
|
||||
structuredClone(state.messagesByUser[userMd5] || state.messages),
|
||||
listMessagesForExport: async (userMd5: string) => {
|
||||
state.exportReads.push(userMd5)
|
||||
if (state.exportErrors[userMd5]) throw new Error(state.exportErrors[userMd5])
|
||||
return structuredClone(state.messagesByUser[userMd5] || state.messages)
|
||||
},
|
||||
getChatDb: () => ({
|
||||
@@ -279,6 +281,7 @@ describe('media export flow', () => {
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||
state.videoLookups = []
|
||||
state.messagesByUser = {}
|
||||
state.exportErrors = {}
|
||||
state.exportReads = []
|
||||
state.selfInfoReads = 0
|
||||
state.groupSnapshotReads = []
|
||||
@@ -1233,7 +1236,9 @@ describe('media export flow', () => {
|
||||
target('csv-user', '测试联系人')
|
||||
]
|
||||
state.messagesByUser = {
|
||||
'csv-group': [message({ id: 'group-text', content: '群聊消息' })],
|
||||
'csv-group': [
|
||||
message({ id: 'group-text', content: '群聊消息, 引号 \" 和换行\\n😀' })
|
||||
],
|
||||
'csv-user': [message({ id: 'user-text', content: '联系人消息' })]
|
||||
}
|
||||
|
||||
@@ -1261,11 +1266,71 @@ describe('media export flow', () => {
|
||||
expect(userFiles).toHaveLength(1)
|
||||
expect(groupFiles[0]).toMatch(/^测试群聊_\d{8}_\d{6}\.csv$/)
|
||||
expect(userFiles[0]).toMatch(/^测试联系人_\d{8}_\d{6}\.csv$/)
|
||||
expect(readFileSync(join(groupDir, groupFiles[0]), 'utf8')).toContain('群聊消息')
|
||||
const groupCsv = readFileSync(join(groupDir, groupFiles[0]))
|
||||
expect(groupCsv.subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf]))
|
||||
expect(groupCsv.toString('utf8')).toContain('\"群聊消息, 引号 \"\" 和换行\\n😀\"')
|
||||
expect(groupCsv.toString('utf8')).toContain('\r\n')
|
||||
expect(readFileSync(join(userDir, userFiles[0]), 'utf8')).toContain('联系人消息')
|
||||
expect(existsSync(join(outputDir, 'index.html'))).toBe(false)
|
||||
})
|
||||
|
||||
it('continues all-export after one conversation fails and records a truthful manifest', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const targets: ExportTarget[] = [
|
||||
target('all-fail-1', '成功会话一'),
|
||||
target('all-fail-2', '失败会话'),
|
||||
target('all-fail-3', '成功会话三')
|
||||
]
|
||||
state.messagesByUser = Object.fromEntries(
|
||||
targets.map((item) => [item.userMd5, [message({ id: item.userMd5, content: item.name })]])
|
||||
)
|
||||
state.exportErrors = { 'all-fail-2': '模拟单会话读取失败' }
|
||||
const progress: Array<{ phase: string; percent?: number; error?: string }> = []
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (_channel: string, item: (typeof progress)[number]) => progress.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runExport(
|
||||
{
|
||||
jobId: 'all-export-continue-after-failure',
|
||||
scope: 'all',
|
||||
targets,
|
||||
format: 'html',
|
||||
outputName: 'all-export-continue-after-failure',
|
||||
kinds: ['text'],
|
||||
includeMedia: false
|
||||
},
|
||||
win as never
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('失败会话')
|
||||
expect(result.outputPath).toBeTruthy()
|
||||
expect(state.exportReads).toEqual(['all-fail-1', 'all-fail-2', 'all-fail-3'])
|
||||
expect(existsSync(join(result.outputPath!, '联系人', '成功会话一', 'index.html'))).toBe(true)
|
||||
expect(existsSync(join(result.outputPath!, '联系人', '成功会话三', 'index.html'))).toBe(true)
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(result.outputPath!, '导出清单.json'), 'utf8')
|
||||
) as {
|
||||
status: string
|
||||
conversations: Array<{ id: string; status: string; error?: string }>
|
||||
}
|
||||
expect(manifest.status).toBe('failed')
|
||||
expect(manifest.conversations).toEqual([
|
||||
expect.objectContaining({ id: 'all-fail-1', status: 'completed' }),
|
||||
expect.objectContaining({
|
||||
id: 'all-fail-2',
|
||||
status: 'failed',
|
||||
error: '模拟单会话读取失败'
|
||||
}),
|
||||
expect.objectContaining({ id: 'all-fail-3', status: 'completed' })
|
||||
])
|
||||
expect(progress.at(-1)).toMatchObject({ phase: 'failed', percent: 100 })
|
||||
})
|
||||
|
||||
it('cancels an all-export task between conversations without starting the next database read', async () => {
|
||||
const { cancelExport, runExport } = await import('../../src/main/export-service')
|
||||
const jobId = 'all-export-cancel'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
displayContactName,
|
||||
filesystemSafeName,
|
||||
isVisibleName,
|
||||
visibleNamePart
|
||||
} from '../../src/shared/contact-name'
|
||||
|
||||
describe('contact name safety', () => {
|
||||
it('recognizes private-use, zero-width, and control-only labels as invisible', () => {
|
||||
expect(isVisibleName('\uE000\uE000')).toBe(false)
|
||||
expect(isVisibleName('\u200B\u200D')).toBe(false)
|
||||
expect(displayContactName({ m_nsNickName: '\uE000\uE000', remark: '备注名', wxid: 'wxid_a' })).toBe(
|
||||
'备注名'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves visible emoji and removes only non-rendering code points', () => {
|
||||
expect(visibleNamePart(' 👩💻\u200B ')).toBe('👩💻')
|
||||
expect(
|
||||
displayContactName({
|
||||
m_nsNickName: String.fromCodePoint(0xE0020),
|
||||
wechatId: 'wechat-alias'
|
||||
})
|
||||
).toBe(
|
||||
'wechat-alias'
|
||||
)
|
||||
})
|
||||
|
||||
it('sanitizes reserved path characters, trailing dots, device names, and length', () => {
|
||||
expect(filesystemSafeName('a/b:c*?"<>|. ')).toMatch(/^a_b_c_+$/)
|
||||
expect(filesystemSafeName('CON')).toBe('CON_')
|
||||
expect(filesystemSafeName('\uE000\uE000', '未命名联系人')).toBe('未命名联系人')
|
||||
expect(Array.from(filesystemSafeName('😀'.repeat(100))).length).toBe(80)
|
||||
})
|
||||
})
|
||||
@@ -51,4 +51,66 @@ describe('VoiceService batch lookup', () => {
|
||||
expect(resolveVoice).toHaveBeenNthCalledWith(1, 'session', 10, 100, '1000')
|
||||
expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001')
|
||||
})
|
||||
|
||||
it('keeps same local ids isolated by session and server identity, then reuses the exact cache entries', async () => {
|
||||
const getVoiceDataBatch = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ success: true, hex: '0102' },
|
||||
{ success: true, hex: '0304' },
|
||||
{ success: true, hex: '0506' }
|
||||
])
|
||||
const service = new VoiceService({ getVoiceDataBatch } as never, '/accounts/fixture-a')
|
||||
const decoder = (service as unknown as {
|
||||
decoderRegistry: { decode: ReturnType<typeof vi.fn> }
|
||||
}).decoderRegistry
|
||||
vi.spyOn(decoder, 'decode').mockImplementation(async (source: { data: Buffer }) => ({
|
||||
pcm: Buffer.from([source.data[0]]),
|
||||
sampleRate: 16_000,
|
||||
channels: 1
|
||||
}))
|
||||
|
||||
const references = [
|
||||
{ sessionId: 'session-a', localId: 7, createTime: 100, svrId: 'same-second-a' },
|
||||
{ sessionId: 'session-b', localId: 7, createTime: 100, svrId: 'same-second-a' },
|
||||
{ sessionId: 'session-a', localId: 7, createTime: 100, svrId: 'same-second-b' }
|
||||
]
|
||||
const first = await service.resolveVoices(references)
|
||||
expect(first.every((item) => item.success)).toBe(true)
|
||||
expect(new Set(first.map((item) => item.data)).size).toBe(3)
|
||||
expect(getVoiceDataBatch).toHaveBeenCalledOnce()
|
||||
|
||||
const second = await service.resolveVoices(references)
|
||||
expect(second).toEqual(first)
|
||||
expect(getVoiceDataBatch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('versions the in-memory namespace per account so a restarted or switched account cannot reuse old audio', async () => {
|
||||
const client = {
|
||||
getVoiceDataBatch: vi.fn().mockResolvedValue([{ success: true, hex: '0102' }]),
|
||||
getAccountRoot: () => '/accounts/fixture-a'
|
||||
}
|
||||
const first = new VoiceService(client as never)
|
||||
const second = new VoiceService(
|
||||
{ ...client, getAccountRoot: () => '/accounts/fixture-b' } as never
|
||||
)
|
||||
const decode = (service: VoiceService, value: number): void => {
|
||||
const decoder = (service as unknown as {
|
||||
decoderRegistry: { decode: ReturnType<typeof vi.fn> }
|
||||
}).decoderRegistry
|
||||
vi.spyOn(decoder, 'decode').mockResolvedValue({
|
||||
pcm: Buffer.from([value]),
|
||||
sampleRate: 16_000,
|
||||
channels: 1
|
||||
})
|
||||
}
|
||||
decode(first, 1)
|
||||
decode(second, 2)
|
||||
const firstCache = (first as unknown as { voiceCache: Map<string, string> }).voiceCache
|
||||
const secondCache = (second as unknown as { voiceCache: Map<string, string> }).voiceCache
|
||||
await expect(first.resolveVoices([{ sessionId: 'session', localId: 1, createTime: 2 }])).resolves.toHaveLength(1)
|
||||
await expect(second.resolveVoices([{ sessionId: 'session', localId: 1, createTime: 2 }])).resolves.toHaveLength(1)
|
||||
expect([...firstCache.keys()][0]).not.toBe([...secondCache.keys()][0])
|
||||
expect(client.getVoiceDataBatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user