mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 05:27:48 +08:00
feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送 - 退群事件永久保存,并以本地推断条目并入档案消息流 - 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
|
||||
const groupContact: Contact = {
|
||||
m_nsUsrName: '研发群@chatroom',
|
||||
m_nsNickName: '研发群',
|
||||
md5: 'g'.repeat(32),
|
||||
type: 'group'
|
||||
}
|
||||
|
||||
const userContact: Contact = {
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
m_nsNickName: '好友',
|
||||
md5: 'u'.repeat(32),
|
||||
type: 'user'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;(window as unknown as { api: Record<string, unknown> }).api = {
|
||||
getGroupMemberStats: vi.fn(),
|
||||
getPersonalWechatSenderStatus: vi.fn().mockResolvedValue({ canSendText: false })
|
||||
}
|
||||
})
|
||||
|
||||
function renderHeader(contact: Contact, isGroupChat: boolean): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
loadedCount={10}
|
||||
filteredCount={10}
|
||||
contentFilter=""
|
||||
isAiLoading={false}
|
||||
onContentFilterChange={vi.fn()}
|
||||
onTestSend={vi.fn()}
|
||||
onOpenAiSettings={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function openMoreMenu(): Promise<void> {
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: '更多功能' }))
|
||||
await waitFor(() => expect(screen.getByText('刷新数据')).toBeTruthy())
|
||||
}
|
||||
|
||||
describe('ChatHeader · 群发言统计入口', () => {
|
||||
it('群聊显示「群发言统计」入口', async () => {
|
||||
renderHeader(groupContact, true)
|
||||
await openMoreMenu()
|
||||
expect(screen.getByText('群发言统计')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('非群聊不显示「群发言统计」入口', async () => {
|
||||
renderHeader(userContact, false)
|
||||
await openMoreMenu()
|
||||
expect(screen.queryByText('群发言统计')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -53,7 +53,7 @@ describe('chat menus', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '更多' })
|
||||
const trigger = screen.getByRole('button', { name: '更多功能' })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GroupMemberStatsDialog } from '../../src/renderer/src/components/group-stats/GroupMemberStatsDialog'
|
||||
import {
|
||||
GROUP_STATS_LIMITATION,
|
||||
GROUP_STATS_STALE_LIMITATION,
|
||||
type GroupMemberStatsResult
|
||||
} from '../../src/shared/group-stats'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
|
||||
const contact: Contact = {
|
||||
m_nsUsrName: '研发群@chatroom',
|
||||
m_nsNickName: '研发群',
|
||||
remark: '公司研发大群',
|
||||
md5: 'g'.repeat(32),
|
||||
type: 'group'
|
||||
}
|
||||
|
||||
const at = (year: number, month: number, day: number): number =>
|
||||
new Date(year, month - 1, day).getTime()
|
||||
|
||||
const baseResult = (
|
||||
overrides: Partial<GroupMemberStatsResult> = {}
|
||||
): GroupMemberStatsResult => ({
|
||||
conversationId: contact.md5,
|
||||
startTime: at(2026, 6, 15),
|
||||
endTime: at(2026, 9, 4),
|
||||
freshness: 'fresh',
|
||||
complete: true,
|
||||
memberCount: 5,
|
||||
activeMemberCount: 3,
|
||||
silentMemberCount: 2,
|
||||
activeMembers: [
|
||||
{
|
||||
senderId: 'wxid_a',
|
||||
displayName: '张三',
|
||||
groupNickname: '老张',
|
||||
messageCount: 328,
|
||||
lastMessageTime: at(2026, 9, 1)
|
||||
},
|
||||
{
|
||||
senderId: 'wxid_b',
|
||||
displayName: '李四',
|
||||
groupNickname: '',
|
||||
messageCount: 217,
|
||||
lastMessageTime: at(2026, 9, 2)
|
||||
},
|
||||
{
|
||||
senderId: 'wxid_c',
|
||||
displayName: '王五',
|
||||
groupNickname: '',
|
||||
messageCount: 196,
|
||||
lastMessageTime: at(2026, 9, 3)
|
||||
}
|
||||
],
|
||||
silentMembers: [
|
||||
{ senderId: 'wxid_d', displayName: '轩轩', groupNickname: '轩轩子' },
|
||||
{ senderId: 'wxid_e', displayName: '十二', groupNickname: '' }
|
||||
],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
firstMessageTime: at(2026, 6, 15),
|
||||
limitations: [GROUP_STATS_LIMITATION],
|
||||
...overrides
|
||||
})
|
||||
|
||||
const getGroupMemberStats = vi.fn()
|
||||
const getPersonalWechatSenderStatus = vi.fn()
|
||||
const sendPersonalWechatMessage = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
getGroupMemberStats.mockReset().mockResolvedValue(baseResult())
|
||||
getPersonalWechatSenderStatus.mockReset().mockResolvedValue({ canSendText: false })
|
||||
sendPersonalWechatMessage.mockReset().mockResolvedValue({ ok: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
;(window as unknown as { api: Record<string, unknown> }).api = {
|
||||
getGroupMemberStats,
|
||||
getPersonalWechatSenderStatus,
|
||||
sendPersonalWechatMessage
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const renderDialog = () =>
|
||||
render(<GroupMemberStatsDialog open onOpenChange={vi.fn()} contact={contact} />)
|
||||
|
||||
/**
|
||||
* Radix Tabs 只挂载 active 的内容,未发言名单要先切过去才存在。
|
||||
*
|
||||
* ⚠️ 必须先等 tab 出现:数据没加载完时整个结果区(含 Tabs)都还没渲染,
|
||||
* 直接 `getByRole('tab')` 会因为「DOM 里还没有 tab」而失败。
|
||||
*/
|
||||
const switchToSilentTab = async (): Promise<void> => {
|
||||
const user = userEvent.setup()
|
||||
const tab = await screen.findByRole('tab', { name: /未发言统计/ })
|
||||
await user.click(tab)
|
||||
await waitFor(() => expect(screen.getByText(/轩轩/)).toBeTruthy())
|
||||
}
|
||||
|
||||
describe('GroupMemberStatsDialog', () => {
|
||||
it('展示群备注与三个概览数字', async () => {
|
||||
renderDialog()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
|
||||
expect(screen.getByText('群备注:公司研发大群')).toBeTruthy()
|
||||
expect(screen.getByText('当前群成员')).toBeTruthy()
|
||||
expect(screen.getByText('发过言')).toBeTruthy()
|
||||
expect(screen.getByText('没发言')).toBeTruthy()
|
||||
expect(screen.getByText(/共 3 人/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('默认展示「发言排行」tab,且不渲染未发言名单', async () => {
|
||||
renderDialog()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
|
||||
expect(screen.getByText(/李四/)).toBeTruthy()
|
||||
// 未发言的人此时不应出现在 DOM 里,避免两个名单互相干扰
|
||||
expect(screen.queryByText(/轩轩/)).toBeNull()
|
||||
})
|
||||
|
||||
it('切到「未发言统计」tab 后才展示未发言名单', async () => {
|
||||
renderDialog()
|
||||
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
|
||||
|
||||
await switchToSilentTab()
|
||||
|
||||
expect(screen.getByText(/十二/)).toBeTruthy()
|
||||
expect(screen.getByText(/共 2 人/)).toBeTruthy()
|
||||
// 切过去之后也不应再看到发言榜的人
|
||||
expect(screen.queryByText(/张三/)).toBeNull()
|
||||
})
|
||||
|
||||
it('canSendText = false 时只提供复制,不出现发送按钮', async () => {
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
expect(screen.queryByRole('button', { name: '发送到当前群' })).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('canSendText = true 时出现发送按钮', async () => {
|
||||
getPersonalWechatSenderStatus.mockResolvedValue({ canSendText: true })
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
expect(screen.getByRole('button', { name: '发送到当前群' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('复制走 formatter 生成的完整文本', async () => {
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
// userEvent.setup() 会安装它自己的 clipboard stub,覆盖 beforeEach 里的定义,
|
||||
// 所以必须在 setup 之后再 spy,否则断言的是一个永远不会被调用的替身。
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText')
|
||||
await user.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalled())
|
||||
const text = clipboardSpy.mock.calls[0][0] as string
|
||||
expect(text).toContain('未发言统计')
|
||||
expect(text).toContain('1. 轩轩')
|
||||
expect(text).toContain('2. 十二')
|
||||
})
|
||||
|
||||
it('索引未追平时显示 warning 且不宣称完整', async () => {
|
||||
getGroupMemberStats.mockResolvedValue(
|
||||
baseResult({
|
||||
freshness: 'stale',
|
||||
complete: false,
|
||||
limitations: [GROUP_STATS_LIMITATION, GROUP_STATS_STALE_LIMITATION]
|
||||
})
|
||||
)
|
||||
renderDialog()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('本地索引尚未完全同步')
|
||||
})
|
||||
|
||||
it('索引未追平时提供「去建立索引」的去处', async () => {
|
||||
getGroupMemberStats.mockResolvedValue(
|
||||
baseResult({
|
||||
freshness: 'stale',
|
||||
complete: false,
|
||||
limitations: [GROUP_STATS_LIMITATION, GROUP_STATS_STALE_LIMITATION]
|
||||
})
|
||||
)
|
||||
const onOpenLocalIndexSettings = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<GroupMemberStatsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
contact={contact}
|
||||
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '去建立索引' }))
|
||||
|
||||
// 必须先关面板再跳,否则设置页会被这个 Dialog 挡在后面。
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(onOpenLocalIndexSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('没有提供跳转回调时不显示「去建立索引」', async () => {
|
||||
getGroupMemberStats.mockResolvedValue(
|
||||
baseResult({ freshness: 'stale', complete: false })
|
||||
)
|
||||
renderDialog()
|
||||
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByRole('button', { name: '去建立索引' })).toBeNull()
|
||||
})
|
||||
|
||||
it('发送失败时展示错误而不是静默失败', async () => {
|
||||
getPersonalWechatSenderStatus.mockResolvedValue({ canSendText: true })
|
||||
sendPersonalWechatMessage.mockRejectedValue(new Error('hook 未就绪'))
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: '发送到当前群' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('hook 未就绪')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocalIndexPage } from '../../src/renderer/src/features/settings/pages/LocalIndexPage'
|
||||
|
||||
const NOW = Date.now()
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const knowledgeStatus = (overrides: Record<string, unknown> = {}) => ({
|
||||
accountId: 'acct',
|
||||
state: 'ready',
|
||||
indexedMessageCount: 2_517_181,
|
||||
indexedChunkCount: 9000,
|
||||
sourceMessageCount: 2_517_181,
|
||||
processedMessages: 2_517_181,
|
||||
totalMessages: 2_517_181,
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 6.4 * 1024 ** 3,
|
||||
walBytes: 0,
|
||||
shmBytes: 0,
|
||||
indexLatestAt: NOW,
|
||||
sourceLatestAt: NOW,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const imageStatus = (overrides: Record<string, unknown> = {}) => ({
|
||||
progress: {
|
||||
state: 'idle',
|
||||
processed: 349_263,
|
||||
totalImageMessages: 349_263,
|
||||
percent: 100,
|
||||
indexed: 57_078,
|
||||
empty: 74_816,
|
||||
missing: 184_805,
|
||||
failed: 8
|
||||
},
|
||||
coverage: {
|
||||
// `established` / `complete` 是状态判定的两个开关
|
||||
// (`imageTextCoverageState`:!established→not_built,complete 才→complete)。
|
||||
// 漏任一个,卡片都会停在别的分支上。
|
||||
established: true,
|
||||
complete: true,
|
||||
indexed: 57_078,
|
||||
empty: 74_816,
|
||||
missing: 184_805,
|
||||
failed: 8,
|
||||
processed: 349_263,
|
||||
totalImageMessages: 349_263,
|
||||
systemicFailure: false,
|
||||
tiers: []
|
||||
},
|
||||
storage: {},
|
||||
counting: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
let api: Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
beforeEach(() => {
|
||||
api = {
|
||||
getKnowledgeStatus: vi.fn().mockResolvedValue(knowledgeStatus()),
|
||||
onKnowledgeStatus: vi.fn(() => () => undefined),
|
||||
startKnowledgeIndex: vi.fn().mockResolvedValue(knowledgeStatus({ state: 'syncing' })),
|
||||
cancelKnowledgeIndex: vi.fn().mockResolvedValue({ cancellable: true, cancelled: true }),
|
||||
getImageTextIndexStatus: vi.fn().mockResolvedValue(imageStatus()),
|
||||
onImageTextIndexStatus: vi.fn(() => () => undefined),
|
||||
countImageMessages: vi.fn().mockResolvedValue({
|
||||
totalImageMessages: 349_263,
|
||||
scannedConversations: 1375,
|
||||
failedConversations: 0
|
||||
}),
|
||||
startImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
pauseImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
resumeImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
cancelImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
resetImageTextIndexFailures: vi.fn().mockResolvedValue(imageStatus()),
|
||||
repairImageTextIndex: vi.fn().mockResolvedValue(imageStatus())
|
||||
}
|
||||
;(window as unknown as { api: Record<string, unknown> }).api = api
|
||||
})
|
||||
|
||||
const renderPage = (dbReady = true) =>
|
||||
render(<LocalIndexPage dbReady={dbReady} onNotice={vi.fn()} />)
|
||||
|
||||
describe('LocalIndexPage · 信息层级', () => {
|
||||
it('页面标题与两处索引卡片都在', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { name: '本地索引' })).toBeTruthy()
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '聊天记录索引' })).toBeTruthy())
|
||||
expect(screen.getByRole('heading', { name: '图片文字索引' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('聊天记录索引显示三个核心数字', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2,517,181')).toBeTruthy())
|
||||
expect(screen.getByText('已收录消息')).toBeTruthy()
|
||||
expect(screen.getByText('6.4 GB')).toBeTruthy()
|
||||
expect(screen.getByText('占用空间')).toBeTruthy()
|
||||
expect(screen.getByText('最后更新')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('图片文字索引显示四个 metric', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('57,078')).toBeTruthy())
|
||||
expect(screen.getByText('已识别文字')).toBeTruthy()
|
||||
expect(screen.getByText('74,816')).toBeTruthy()
|
||||
expect(screen.getByText('无文字图片')).toBeTruthy()
|
||||
expect(screen.getByText('184,805')).toBeTruthy()
|
||||
expect(screen.getByText('图片已清理')).toBeTruthy()
|
||||
expect(screen.getByText('8')).toBeTruthy()
|
||||
expect(screen.getByText('识别失败')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalIndexPage · 状态徽章', () => {
|
||||
it('索引追平时显示「可用 · 已追至最新」', async () => {
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('可用 · 已追至最新')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('索引落后时显示「可用 · 待追新」', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
knowledgeStatus({ indexLatestAt: NOW - 3 * DAY, sourceLatestAt: NOW })
|
||||
)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('可用 · 待追新')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('图片索引完成后显示「已完成」', async () => {
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('已完成')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalIndexPage · 运行态与进度', () => {
|
||||
it('图片索引运行中:显示进度条与暂停/取消,不再显示主操作按钮', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(
|
||||
imageStatus({
|
||||
progress: {
|
||||
state: 'running',
|
||||
processed: 184_235,
|
||||
totalImageMessages: 349_263,
|
||||
percent: 52,
|
||||
indexed: 1_000,
|
||||
empty: 500,
|
||||
missing: 0,
|
||||
failed: 0
|
||||
}
|
||||
})
|
||||
)
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByRole('progressbar')).toBeTruthy()
|
||||
expect(screen.getByText('184,235 / 349,263')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '暂停' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '更新索引' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalIndexPage · 操作绑定与禁用', () => {
|
||||
it('「同步最新记录」仍然走原来的 startKnowledgeIndex', async () => {
|
||||
renderPage()
|
||||
const button = await screen.findByRole('button', { name: '同步最新记录' })
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => expect(api.startKnowledgeIndex).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('数据库未连接时主按钮禁用并给出提示', async () => {
|
||||
renderPage(false)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('请先连接微信数据,然后再建立聊天记录索引。')).toBeTruthy())
|
||||
expect((screen.getByRole('button', { name: '同步最新记录' }) as HTMLButtonElement).disabled).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('「···」菜单里的修复动作仍绑定原逻辑', async () => {
|
||||
renderPage()
|
||||
const more = await screen.findByRole('button', { name: '更多操作' })
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(more)
|
||||
await user.click(await screen.findByText('图片内容搜不到?修复搜索索引'))
|
||||
|
||||
await waitFor(() => expect(api.repairImageTextIndex).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user