mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +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())
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user