feat: 新增群员统计与退群事件,并把本地索引集中到设置页

- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
This commit is contained in:
Wxw-Gu
2026-09-20 21:46:37 +08:00
parent b6d287a299
commit 766cd94788
44 changed files with 3949 additions and 24 deletions
@@ -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()
})
})
+1 -1
View File
@@ -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())
})
})
+195
View File
@@ -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())
})
})
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
GROUP_EXIT_EVENT_MESSAGE_TYPE,
isGroupExitEventMessage,
mergeGroupExitEvents,
toGroupExitEventMessage
} from '../../src/shared/group-exit-event-message'
import type { GroupExitMonitorEvent } from '../../src/shared/group-exit-monitor'
import type { Message } from '../../src/shared/types'
const at = (year: number, month: number, day: number, hour = 0, minute = 0): number =>
new Date(year, month - 1, day, hour, minute).getTime()
const makeEvent = (overrides: Partial<GroupExitMonitorEvent> = {}): GroupExitMonitorEvent => ({
id: 'evt-1',
contactId: 'g'.repeat(32),
roomId: 'room@chatroom',
groupName: '研发群',
memberWxid: 'wxid_a',
memberName: '老张',
previousCount: 10,
currentCount: 9,
delta: -1,
message: '老张退出了研发群',
detectedAt: at(2026, 9, 20, 14, 30),
...overrides
})
const makeMessage = (id: string, createTimeSec: number): Message => ({
id,
from: 'user',
type: '文本',
datetime: '2026-09-20 14:00',
content: 'hello',
isSender: false,
createTime: createTimeSec
})
describe('toGroupExitEventMessage', () => {
it('把事件映射成系统分支的消息,并带专用 type 标记', () => {
const message = toGroupExitEventMessage(makeEvent())
expect(message.id).toBe('group-exit:evt-1')
expect(message.from).toBe('system')
expect(message.type).toBe(GROUP_EXIT_EVENT_MESSAGE_TYPE)
expect(message.content).toBe('老张退出了研发群')
expect(message.isSender).toBe(false)
expect(message.senderId).toBe('wxid_a')
expect(message.sessionId).toBe('room@chatroom')
expect(isGroupExitEventMessage(message)).toBe(true)
})
it('⚠️ createTime 必须从毫秒换算成秒(全项目 Message.createTime 都是秒)', () => {
const detectedAt = at(2026, 9, 20, 14, 30)
const message = toGroupExitEventMessage(makeEvent({ detectedAt }))
// 直接塞毫秒会让事件被排到几万年之后,或者从时间轴上消失。
expect(message.createTime).toBe(Math.floor(detectedAt / 1000))
expect(message.createTime).toBeLessThan(1e11)
})
it('真实微信系统消息不会被误判为退群事件', () => {
const systemMessage: Message = {
id: 'm1',
from: 'system',
type: '系统消息',
datetime: '2026-09-20 14:00',
content: '某某加入了群聊',
isSender: false
}
expect(isGroupExitEventMessage(systemMessage)).toBe(false)
})
})
describe('mergeGroupExitEvents', () => {
it('按时间升序并入,事件落在正确位置', () => {
const messages = [
makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000)),
makeMessage('m2', Math.floor(at(2026, 9, 20, 16) / 1000))
]
const events = [makeEvent({ detectedAt: at(2026, 9, 20, 14, 30) })]
const merged = mergeGroupExitEvents(messages, events)
expect(merged.map((message) => message.id)).toEqual(['m1', 'group-exit:evt-1', 'm2'])
})
it('重复合并是幂等的(广播会反复刷新,不能越插越多)', () => {
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000))]
const events = [makeEvent()]
const once = mergeGroupExitEvents(messages, events)
const twice = mergeGroupExitEvents(once, events)
expect(twice).toHaveLength(2)
expect(twice.map((message) => message.id)).toEqual(once.map((message) => message.id))
})
it('没有事件时原样返回同一个引用(避免无谓的重渲染)', () => {
const messages = [makeMessage('m1', 1_700_000_000)]
expect(mergeGroupExitEvents(messages, [])).toBe(messages)
})
it('多个事件各自成组、保持时间顺序', () => {
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 8) / 1000))]
const events = [
makeEvent({ id: 'e1', detectedAt: at(2026, 9, 20, 12) }),
makeEvent({ id: 'e2', detectedAt: at(2026, 9, 20, 15) })
]
const merged = mergeGroupExitEvents(messages, events)
expect(merged.map((message) => message.id)).toEqual([
'm1',
'group-exit:e1',
'group-exit:e2'
])
})
})
@@ -1,4 +1,4 @@
import { mkdtempSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
import { existsSync, mkdtempSync, readFileSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -394,6 +394,7 @@ describe('GroupExitMonitorService', () => {
await service.start(true)
const statePath = join(mocks.userData, 'group-exit-monitor.json')
const eventsPath = join(mocks.userData, 'group-exit-monitor-events.jsonl')
await vi.waitFor(() => {
expect(readJsonSync(statePath).snapshots[0].members[1]).toMatchObject({
wxid: departed.wxid,
@@ -401,7 +402,10 @@ describe('GroupExitMonitorService', () => {
})
})
const baseline = readJsonSync(statePath)
expect(baseline.events).toEqual([])
// 事件**不再写在状态文件里**(它们走 append-only 的 JSONL),
// 所以这里断言的是「两条存储都没有事件」,而不是只看状态文件。
expect(baseline.events).toBeUndefined()
expect(existsSync(eventsPath) ? readFileSync(eventsPath, 'utf8').trim() : '').toBe('')
expect(baseline.snapshots).toEqual([
expect.objectContaining({
roomId: 'room@chatroom',
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import {
formatActiveMemberStats,
formatSilentMemberStats,
formatStatsDate,
formatStatsDateTime,
resolveGroupStatsRangeStart,
type GroupMemberStatsResult
} from '../../src/shared/group-stats'
/** 用本地时区构造时间,避免测试受运行机器时区影响。 */
const at = (year: number, month: number, day: number, hour = 0): number =>
new Date(year, month - 1, day, hour).getTime()
const base = (overrides: Partial<GroupMemberStatsResult> = {}): GroupMemberStatsResult => ({
conversationId: 'c'.repeat(32),
startTime: at(2026, 6, 15),
endTime: at(2026, 9, 4),
freshness: 'fresh',
complete: true,
memberCount: 48,
activeMemberCount: 45,
silentMemberCount: 3,
activeMembers: [],
silentMembers: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: at(2026, 6, 15),
limitations: [],
...overrides
})
describe('GroupStats formatter', () => {
it('未发言模板按编号列出,并带区间与人数前缀', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [
{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' },
{ senderId: 'wxid_2', displayName: '十二', groupNickname: '' },
{ senderId: 'wxid_3', displayName: '湘江中路', groupNickname: '' }
]
})
)
expect(text).toContain('未发言统计')
expect(text).toContain('2026-06-15 至 2026-09-04')
expect(text).toContain('1. 轩轩')
expect(text).toContain('2. 十二')
expect(text).toContain('3. 湘江中路')
// 纯文本:不能出现 Markdown 标记
expect(text).not.toContain('**')
expect(text).not.toContain('##')
})
it('未发言模板保留 limitation 文案', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }],
limitations: ['统计基于当前群成员名单与本地已归档消息。']
})
)
expect(text).toContain('注:统计基于当前群成员名单与本地已归档消息。')
})
it('未发言为空时给出明确文案而不是空白', () => {
const text = formatSilentMemberStats(base())
expect(text).toContain('(无)')
})
it('未发言模板支持 limit 并说明省略人数', () => {
const silentMembers = Array.from({ length: 5 }, (_, index) => ({
senderId: `wxid_${index}`,
displayName: `成员${index}`,
groupNickname: ''
}))
const text = formatSilentMemberStats(base({ silentMembers }), { limit: 2 })
expect(text).toContain('1. 成员0')
expect(text).toContain('2. 成员1')
expect(text).not.toContain('3. 成员2')
expect(text).toContain('其余 3 人未列出')
})
it('活跃模板带条数', () => {
const text = formatActiveMemberStats(
base({
activeMembers: [
{
senderId: 'wxid_1',
displayName: '张三',
groupNickname: '',
messageCount: 328,
lastMessageTime: at(2026, 9, 20, 12)
},
{
senderId: 'wxid_2',
displayName: '李四',
groupNickname: '',
messageCount: 217,
lastMessageTime: at(2026, 9, 19, 12)
}
]
})
)
expect(text).toContain('群聊活跃统计')
expect(text).toContain('统计时间:2026-06-15 至 2026-09-04')
expect(text).toContain('1. 张三 328 条')
expect(text).toContain('2. 李四 217 条')
})
it('群昵称与主名不同时显示成「微信名(群昵称)」', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [
{ senderId: 'wxid_1', displayName: '张三', groupNickname: '老张' },
// 两者相同时不重复展示,避免噪声
{ senderId: 'wxid_2', displayName: '李四', groupNickname: '李四' },
// 没有群昵称时只显示主名
{ senderId: 'wxid_3', displayName: '王五', groupNickname: '' }
]
})
)
expect(text).toContain('1. 张三(老张)')
expect(text).toContain('2. 李四')
expect(text).not.toContain('李四(李四)')
expect(text).toContain('3. 王五')
})
it('日期格式化使用本地时区且补零', () => {
expect(formatStatsDate(at(2026, 1, 5))).toBe('2026-01-05')
expect(formatStatsDateTime(at(2026, 1, 5, 9))).toBe('2026-01-05 09:00')
})
it('快捷范围按滑动窗口折算起点', () => {
const end = at(2026, 9, 20)
expect(resolveGroupStatsRangeStart('7d', end)).toBe(end - 7 * 24 * 60 * 60 * 1000)
expect(resolveGroupStatsRangeStart('30d', end)).toBe(end - 30 * 24 * 60 * 60 * 1000)
expect(resolveGroupStatsRangeStart('90d', end)).toBe(end - 90 * 24 * 60 * 60 * 1000)
})
it('「全部」不放下界(startTime = 0)', () => {
expect(resolveGroupStatsRangeStart('all', at(2026, 9, 20))).toBe(0)
})
it('选「全部」时区间文案用本机第一条消息的真实日期,而不是 1970-01-01', () => {
const text = formatSilentMemberStats(
base({
startTime: 0,
firstMessageTime: at(2020, 2, 2),
endTime: at(2026, 9, 20),
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
})
)
expect(text).toContain('2020-02-02 至 2026-09-20')
expect(text).not.toContain('1970')
expect(text).not.toContain('全部历史')
})
it('选「全部」但取不到第一条消息时,退化成「全部历史」且不假装从 1970 开始', () => {
const text = formatSilentMemberStats(
base({
startTime: 0,
firstMessageTime: null,
endTime: at(2026, 9, 20),
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
})
)
expect(text).toContain('全部历史')
expect(text).not.toContain('1970')
})
})
+297
View File
@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* 群员统计服务的差集 / freshness 行为。
*
* 依赖全部 mock:这一层要证明的是**编排规则**(谁进活跃榜、谁算未发言、
* 什么情况下结论不完整),不是 SQL 正确性 —— 后者由
* `knowledge-store-member-stats.test.ts` 用真实 SQLite 覆盖。
*/
const chatMock = vi.hoisted(() => ({
resolveGroupRoomId: vi.fn(),
getGroupMemberIdsAsync: vi.fn(),
getGroupMemberNamesAsync: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => chatMock)
import { GroupStatsService } from '../../src/main/services/group-stats-service'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION
} from '../../src/shared/group-stats'
import type { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
import type { KnowledgeMemberStatsResult } from '../../src/shared/knowledge'
const USER_MD5 = 'a'.repeat(32)
const ROOM_ID = 'room@chatroom'
const NOW = 1_700_000_000_000
const DAY = 24 * 60 * 60 * 1000
type Member = {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
const member = (wxid: string, nickname: string): Member => ({
wxid,
nickname,
groupNickname: '',
wechatNickname: '',
remark: '',
avatar: ''
})
const statsResult = (
overrides: Partial<KnowledgeMemberStatsResult> = {}
): KnowledgeMemberStatsResult => ({
conversationId: USER_MD5,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: NOW - 30 * DAY,
indexLatestAt: NOW,
...overrides
})
function makeKnowledge(options: {
result?: KnowledgeMemberStatsResult | null
sourceLatestAt?: number | null
retryResult?: KnowledgeMemberStatsResult | null
} = {}): KnowledgeSearchService {
const first = options.result === undefined ? statsResult() : options.result
const retry = options.retryResult === undefined ? first : options.retryResult
let call = 0
return {
memberStats: vi.fn(async () => {
call += 1
return {
result: call === 1 ? first : retry,
sourceLatestAt: options.sourceLatestAt === undefined ? NOW : options.sourceLatestAt
}
}),
requestCatchUp: vi.fn(() => ({ triggered: true, inProgress: true })),
waitForIndexingComplete: vi.fn(async () => true)
} as unknown as KnowledgeSearchService
}
const run = (
knowledge: KnowledgeSearchService,
startTime = NOW - 30 * DAY,
endTime = NOW
) => new GroupStatsService(knowledge).getMemberStats({ userMd5: USER_MD5, startTime, endTime })
beforeEach(() => {
chatMock.resolveGroupRoomId.mockReset().mockReturnValue(ROOM_ID)
chatMock.getGroupMemberIdsAsync.mockReset().mockResolvedValue({
roomId: ROOM_ID,
memberIds: []
})
chatMock.getGroupMemberNamesAsync.mockReset().mockResolvedValue([])
})
describe('GroupStatsService · 差集', () => {
it('5 名成员里 3 人有发言、2 人未发言', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', '阿一'),
member('wxid_b', '阿二'),
member('wxid_c', '阿三'),
member('wxid_d', '阿四'),
member('wxid_e', '阿五')
])
const knowledge = makeKnowledge({
result: statsResult({
totalMessages: 16,
senders: [
{ senderId: 'wxid_a', messageCount: 10, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 2000 },
{ senderId: 'wxid_c', messageCount: 1, lastMessageTime: NOW - 3000 }
]
})
})
const result = await run(knowledge)
expect(result.memberCount).toBe(5)
expect(result.activeMemberCount).toBe(3)
expect(result.silentMemberCount).toBe(2)
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
'wxid_a',
'wxid_b',
'wxid_c'
])
expect(result.silentMembers.map((item) => item.senderId).sort()).toEqual([
'wxid_d',
'wxid_e'
])
})
it('历史消息的 sender 已退群时,不进当前成员活跃榜,并留下 limitation', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
const knowledge = makeKnowledge({
result: statsResult({
senders: [
{ senderId: 'wxid_a', messageCount: 5, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_left', messageCount: 3, lastMessageTime: NOW - 2000 }
]
})
})
const result = await run(knowledge)
expect(result.activeMembers.map((item) => item.senderId)).toEqual(['wxid_a'])
expect(result.activeMemberCount).toBe(1)
expect(result.limitations.some((text) => text.includes('已不在当前群成员名单中'))).toBe(true)
})
it('成员改昵称不影响统计:join 用 wxid,展示用当前昵称', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '改过的名字')])
const knowledge = makeKnowledge({
result: statsResult({
senders: [{ senderId: 'wxid_a', messageCount: 7, lastMessageTime: NOW - 500 }]
})
})
const result = await run(knowledge)
expect(result.activeMembers).toHaveLength(1)
expect(result.activeMembers[0].displayName).toBe('改过的名字')
expect(result.activeMembers[0].senderId).toBe('wxid_a')
expect(result.silentMemberCount).toBe(0)
})
it('sender 缺失的消息只计入诊断,不硬映射给任何成员', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', '阿一'),
member('wxid_b', '阿二')
])
const knowledge = makeKnowledge({
result: statsResult({
senders: [{ senderId: 'wxid_a', messageCount: 4, lastMessageTime: NOW - 1000 }],
unattributedMessages: 9
})
})
const result = await run(knowledge)
expect(result.unattributedMessages).toBe(9)
// 未归属消息既不能把人抬进活跃榜,也不能把人踢进未发言名单之外。
expect(result.activeMemberCount).toBe(1)
expect(result.silentMemberCount).toBe(1)
expect(result.activeMembers[0].senderId).toBe('wxid_a')
expect(result.silentMembers[0].senderId).toBe('wxid_b')
})
it('活跃榜按条数降序,同数按最后发言时间降序', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', 'A'),
member('wxid_b', 'B'),
member('wxid_c', 'C')
])
const knowledge = makeKnowledge({
result: statsResult({
senders: [
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 9000 },
{ senderId: 'wxid_c', messageCount: 5, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_a', messageCount: 9, lastMessageTime: NOW - 5000 }
]
})
})
const result = await run(knowledge)
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
'wxid_a',
'wxid_c',
'wxid_b'
])
})
})
describe('GroupStatsService · freshness', () => {
it('索引追平时 complete = true', async () => {
const result = await run(makeKnowledge({ sourceLatestAt: NOW }))
expect(result.freshness).toBe('fresh')
expect(result.complete).toBe(true)
expect(result.limitations).not.toContain(GROUP_STATS_STALE_LIMITATION)
})
it('索引落后时 complete = false,绝不输出完整结论', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
sourceLatestAt: NOW,
// 追赶后仍然落后 —— 必须保持不完整。
retryResult: statsResult({ indexLatestAt: NOW - 3 * DAY })
})
const result = await run(knowledge)
expect(result.freshness).toBe('stale')
expect(result.complete).toBe(false)
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
})
it('落后时先触发有界追赶,追平后回到完整结论', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
sourceLatestAt: NOW,
retryResult: statsResult({ indexLatestAt: NOW })
})
const result = await run(knowledge)
expect(knowledge.requestCatchUp).toHaveBeenCalled()
expect(knowledge.waitForIndexingComplete).toHaveBeenCalled()
expect(result.freshness).toBe('fresh')
expect(result.complete).toBe(true)
})
it('口径缺失(indexLatestAt 为 null)按 unknown 处理,不得当作完整', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: null }),
sourceLatestAt: null
})
const result = await run(knowledge)
expect(result.freshness).toBe('unknown')
expect(result.complete).toBe(false)
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
})
it('结果里始终带边界说明', async () => {
const result = await run(makeKnowledge())
expect(result.limitations).toContain(GROUP_STATS_LIMITATION)
})
})
describe('GroupStatsService · 异常输入', () => {
it('不是群会话时返回空结果,不编造统计', async () => {
chatMock.resolveGroupRoomId.mockReturnValue(null)
const result = await run(makeKnowledge())
expect(result.memberCount).toBe(0)
expect(result.activeMemberCount).toBe(0)
expect(result.silentMemberCount).toBe(0)
expect(result.complete).toBe(false)
expect(result.freshness).toBe('unknown')
expect(chatMock.getGroupMemberIdsAsync).not.toHaveBeenCalled()
})
it('Knowledge 返回 null(派生库不可用)时不崩溃,且不完整', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
const result = await run(makeKnowledge({ result: null, sourceLatestAt: null }))
expect(result.memberCount).toBe(1)
expect(result.silentMemberCount).toBe(1)
expect(result.complete).toBe(false)
})
})
@@ -0,0 +1,185 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
import {
DEFAULT_KNOWLEDGE_CHUNKER,
DEFAULT_KNOWLEDGE_FTS_CONFIG,
type KnowledgeIndexRequest,
type KnowledgeMessageKind,
type KnowledgeSourceMessage
} from '../../src/shared/knowledge'
/**
* 「按发送者聚合」SQL 的正确性。
*
* 这一层必须用**真实 SQLite**:系统消息排除、`sender_id IS NULL` 的归属、
* 时间窗闭区间,三条规则都写在 SQL 里 —— mock 掉数据库就等于什么都没验证。
*/
const ACCOUNT = 'acct-stats-test'
const CONV = 'c'.repeat(32)
const OTHER_CONV = 'd'.repeat(32)
const DAY = 24 * 60 * 60 * 1000
const BASE = new Date(2026, 5, 15, 12).getTime()
let root = ''
let store: KnowledgeStore | null = null
const message = (
id: string,
senderId: string | undefined,
kind: KnowledgeMessageKind,
createTime: number,
conversationId = CONV
): KnowledgeSourceMessage => ({
accountId: ACCOUNT,
conversationId,
messageId: id,
createTime,
senderId,
kind,
// 必须带可索引文本,否则会被 normalizer 的可索引性判定挡在索引之外,
// 测试就会因为「消息压根没入库」而假通过。
text: `payload-${id}`
})
async function seed(messages: KnowledgeSourceMessage[]): Promise<void> {
store = new KnowledgeStore(root, ACCOUNT, DEFAULT_KNOWLEDGE_FTS_CONFIG)
const conversations = Array.from(new Set(messages.map((item) => item.conversationId))).map(
(conversationId) => ({
conversationId,
completeSnapshot: true,
messages: messages.filter((item) => item.conversationId === conversationId),
sourceHighWaterTime: BASE + 10 * DAY
})
)
const request: KnowledgeIndexRequest = {
accountId: ACCOUNT,
databaseRoot: root,
conversations,
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
}
await store.index(request, undefined, () => undefined)
}
const query = (startTime: number, endTime: number, conversationId = CONV) =>
store!.memberStats({ conversationId, startTime, endTime })
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'tm-knowledge-stats-'))
})
afterEach(() => {
try {
store?.close()
} finally {
store = null
if (root) rmSync(root, { recursive: true, force: true })
}
})
describe('KnowledgeStore.memberStats', () => {
it('按 sender 聚合出条数与最后发言时间,并按条数降序', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', 'wxid_a', 'text', BASE + 5000),
message('m3', 'wxid_a', 'text', BASE + 3000),
message('m4', 'wxid_b', 'text', BASE + 2000)
])
const result = query(BASE, BASE + DAY)
expect(result.totalMessages).toBe(4)
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 3, lastMessageTime: BASE + 5000 },
{ senderId: 'wxid_b', messageCount: 1, lastMessageTime: BASE + 2000 }
])
})
it('系统消息不参与任何成员的发言统计', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', 'wxid_a', 'system', BASE + 2000),
message('m3', undefined, 'system', BASE + 3000)
])
const result = query(BASE, BASE + DAY)
// 只有那条 text 算发言;系统消息既不计入总数,也不进 senders。
expect(result.totalMessages).toBe(1)
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
])
expect(result.excludedSystemMessages).toBe(2)
expect(result.unattributedMessages).toBe(0)
})
it('sender_id 缺失的消息只计入诊断,不归属任何成员', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', undefined, 'text', BASE + 2000),
message('m3', undefined, 'other', BASE + 3000)
])
const result = query(BASE, BASE + DAY)
expect(result.unattributedMessages).toBe(2)
expect(result.totalMessages).toBe(3)
// 未归属消息**不得**变成一个空 sender 的分组。
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
])
})
it('时间窗口是闭区间,窗口外的消息不参与', async () => {
await seed([
message('before', 'wxid_a', 'text', BASE - 1),
message('start', 'wxid_a', 'text', BASE),
message('inside', 'wxid_a', 'text', BASE + 5000),
message('end', 'wxid_a', 'text', BASE + DAY),
message('after', 'wxid_a', 'text', BASE + DAY + 1)
])
const result = query(BASE, BASE + DAY)
// 闭区间:两头都算在内。
expect(result.totalMessages).toBe(3)
expect(result.senders[0].messageCount).toBe(3)
expect(result.senders[0].lastMessageTime).toBe(BASE + DAY)
})
it('只统计指定会话,不串到其它群', async () => {
await seed([
message('mine', 'wxid_a', 'text', BASE + 1000),
message('theirs', 'wxid_z', 'text', BASE + 1000, OTHER_CONV)
])
const result = query(BASE, BASE + DAY, CONV)
expect(result.totalMessages).toBe(1)
expect(result.senders.map((item) => item.senderId)).toEqual(['wxid_a'])
})
it('窗口内没有消息时返回空聚合而不是报错', async () => {
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
const result = query(BASE + 100 * DAY, BASE + 101 * DAY)
expect(result.totalMessages).toBe(0)
expect(result.senders).toEqual([])
expect(result.unattributedMessages).toBe(0)
expect(result.indexLatestAt).not.toBeNull()
})
it('conversationId 为空时安全返回空结果', async () => {
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
const result = store!.memberStats({ conversationId: '', startTime: BASE, endTime: BASE + DAY })
expect(result.totalMessages).toBe(0)
expect(result.senders).toEqual([])
})
})