Files
WechatExplorer/tests/unit/knowledge-store-member-stats.test.ts
Wxw-Gu f5633a29cb feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
2026-09-20 21:47:06 +08:00

186 lines
5.9 KiB
TypeScript

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([])
})
})