mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送 - 退群事件永久保存,并以本地推断条目并入档案消息流 - 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user