mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
- 图片文字索引性能与进度诚实化 - 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源 - 问问微信:回答规则禁止未真实执行的多轮承诺 - 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
1307 lines
47 KiB
TypeScript
1307 lines
47 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const { chatState, getGroupSnapshotAsync, getGroupMemberNamesAsync, listContactsAsync, listMessagesAsync, getSourceLatestActivityMs, getConversationActivityMs, knowledgeService } =
|
||
vi.hoisted(() => ({
|
||
chatState: {
|
||
ready: false,
|
||
accountId: ''
|
||
},
|
||
getGroupSnapshotAsync: vi.fn(),
|
||
getGroupMemberNamesAsync: vi.fn(),
|
||
listContactsAsync: vi.fn(),
|
||
listMessagesAsync: vi.fn(),
|
||
getSourceLatestActivityMs: vi.fn(() => null as number | null),
|
||
// 源侧每会话最后活跃时间(epoch ms)。默认空表 = 没有任何会话可被增量跳过,
|
||
// 于是每个会话都会被真实处理 —— 这正是大部分用例想要的"全量"前提。
|
||
getConversationActivityMs: vi.fn(() => new Map<string, number>()),
|
||
knowledgeService: {
|
||
dispose: vi.fn().mockResolvedValue(undefined),
|
||
// 与生产契约一致:index() 必须返回 KnowledgeIndexResult(调用方要读 cancelled)。
|
||
index: vi.fn().mockResolvedValue({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 0,
|
||
indexedChunks: 0,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
}),
|
||
search: vi.fn(),
|
||
// per-conversation checkpoint 读取:默认空表(没有会话已覆盖到任何时间点)。
|
||
highWaterMarks: vi.fn().mockResolvedValue({}),
|
||
cancelIndex: vi.fn().mockResolvedValue(false),
|
||
status: vi.fn().mockResolvedValue({
|
||
accountId: 'fixture-account',
|
||
state: 'ready',
|
||
indexedMessageCount: 1,
|
||
indexedChunkCount: 1,
|
||
sourceMessageCount: 1,
|
||
processedMessages: 1,
|
||
totalMessages: 1,
|
||
estimatedRemainingMs: null,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
shmBytes: 0,
|
||
indexLatestAt: null,
|
||
sourceLatestAt: null
|
||
})
|
||
}
|
||
}))
|
||
|
||
vi.mock('../../src/main/services/chat-service', () => ({
|
||
isReady: () => chatState.ready,
|
||
getSelfAccountInfo: () => (chatState.accountId ? { wxid: chatState.accountId } : null),
|
||
getCurrentAccountRoot: () => chatState.accountId,
|
||
getGroupSnapshotAsync,
|
||
getGroupMemberNamesAsync,
|
||
listContactsAsync,
|
||
listMessagesAsync,
|
||
getSourceLatestActivityMs,
|
||
getConversationActivityMs
|
||
}))
|
||
|
||
vi.mock('../../src/main/knowledge/knowledge-service', () => ({
|
||
KnowledgeService: class {
|
||
dispose = knowledgeService.dispose
|
||
index = knowledgeService.index
|
||
search = knowledgeService.search
|
||
highWaterMarks = knowledgeService.highWaterMarks
|
||
cancelIndex = knowledgeService.cancelIndex
|
||
status = knowledgeService.status
|
||
}
|
||
}))
|
||
|
||
import { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
|
||
import type { VoiceTranscriptUpdate } from '../../src/shared/voice-recognition'
|
||
|
||
describe('KnowledgeSearchService legacy fallback', () => {
|
||
beforeEach(() => {
|
||
chatState.ready = false
|
||
chatState.accountId = ''
|
||
getGroupSnapshotAsync.mockReset()
|
||
getGroupMemberNamesAsync.mockReset()
|
||
getGroupMemberNamesAsync.mockResolvedValue([])
|
||
listContactsAsync.mockReset()
|
||
listMessagesAsync.mockReset()
|
||
knowledgeService.dispose.mockClear()
|
||
knowledgeService.index.mockClear()
|
||
knowledgeService.search.mockReset()
|
||
knowledgeService.status.mockClear()
|
||
listContactsAsync.mockResolvedValue([
|
||
{
|
||
m_nsUsrName: 'fixture-contact',
|
||
m_nsNickName: '脱敏会话',
|
||
md5: 'fixture-conversation',
|
||
type: 'user'
|
||
}
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'fixture-message',
|
||
localId: 42,
|
||
from: 'user',
|
||
type: '普通文本',
|
||
datetime: '2026/8/5 10:00:00',
|
||
content: '请把 Knowledge Worker 的 fallback 保留下来。',
|
||
isSender: false,
|
||
senderId: 'fixture-sender',
|
||
name: '脱敏成员',
|
||
createTime: 1785895200
|
||
}
|
||
])
|
||
getGroupSnapshotAsync.mockResolvedValue(null)
|
||
})
|
||
|
||
it('keeps the old main-process search path when Knowledge is unavailable', async () => {
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({
|
||
text: 'Knowledge Worker fallback',
|
||
terms: ['Knowledge Worker', 'fallback'],
|
||
conversationIds: ['fixture-conversation'],
|
||
startTime: 1785800000,
|
||
limit: 10
|
||
})
|
||
expect(listMessagesAsync).toHaveBeenCalledWith(
|
||
'fixture-conversation',
|
||
1785800000,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
'knowledge'
|
||
)
|
||
expect(result).toMatchObject({
|
||
source: 'fallback',
|
||
fallbackReason: 'unavailable',
|
||
state: 'unavailable',
|
||
totalMessages: 1
|
||
})
|
||
expect(result.evidence).toEqual([
|
||
expect.objectContaining({
|
||
messageId: 'local:42',
|
||
conversationId: 'fixture-conversation',
|
||
sender: '脱敏成员',
|
||
senderId: 'fixture-sender',
|
||
timestamp: 1785895200000
|
||
})
|
||
])
|
||
await service.dispose()
|
||
})
|
||
|
||
it('accepts username and Chat_<md5> aliases in the legacy fallback scope', async () => {
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({
|
||
text: 'Knowledge Worker fallback',
|
||
terms: ['Knowledge Worker', 'fallback'],
|
||
conversationIds: ['fixture-contact', 'Chat_fixture-conversation'],
|
||
limit: 10
|
||
})
|
||
|
||
expect(listMessagesAsync).toHaveBeenCalledWith(
|
||
'fixture-conversation',
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
'knowledge'
|
||
)
|
||
expect(result.evidence).toHaveLength(1)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('hydrates a cached voice transcript and incrementally indexes only its conversation', async () => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'C:/fixtures/account-a'
|
||
listContactsAsync.mockResolvedValue([
|
||
{
|
||
m_nsUsrName: 'voice-contact',
|
||
m_nsNickName: '语音测试会话',
|
||
md5: 'voice-conversation',
|
||
type: 'user'
|
||
}
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'voice-message',
|
||
localId: 18,
|
||
from: 'user',
|
||
type: '语音',
|
||
content: '[语音消息]',
|
||
isSender: false,
|
||
senderId: 'fixture-sender',
|
||
name: '脱敏成员',
|
||
sessionId: 'voice-contact',
|
||
createTime: 1_785_895_200
|
||
}
|
||
])
|
||
const { voiceAccountIdentity, voiceMessageIdentity } =
|
||
await import('../../src/main/voice-pipeline/voice-message-identity')
|
||
const reference = {
|
||
sessionId: 'voice-contact',
|
||
localId: 18,
|
||
createTime: 1_785_895_200
|
||
}
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
service.setVoiceTranscriptResolver(() => ({
|
||
state: 'transcribed',
|
||
transcript: '缓存中的语音文字'
|
||
}))
|
||
|
||
await service.indexVoiceTranscript({
|
||
accountIdentity: voiceAccountIdentity(chatState.accountId),
|
||
reference,
|
||
messageIdentity: voiceMessageIdentity(reference),
|
||
state: 'transcribed',
|
||
transcript: '缓存中的语音文字',
|
||
cached: true
|
||
})
|
||
|
||
expect(knowledgeService.index).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
accountId: chatState.accountId,
|
||
conversations: [
|
||
expect.objectContaining({
|
||
conversationId: 'voice-conversation',
|
||
completeSnapshot: true,
|
||
messages: [
|
||
expect.objectContaining({
|
||
kind: 'voice',
|
||
voiceTranscript: '缓存中的语音文字',
|
||
voiceTranscriptState: 'transcribed'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('coalesces consecutive voice updates for the same conversation', async () => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'C:/fixtures/account-a'
|
||
listContactsAsync.mockResolvedValue([
|
||
{
|
||
m_nsUsrName: 'voice-contact',
|
||
m_nsNickName: '语音测试会话',
|
||
md5: 'voice-conversation',
|
||
type: 'user'
|
||
}
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'voice-message-1',
|
||
localId: 18,
|
||
from: 'user',
|
||
type: '语音',
|
||
content: '[语音消息]',
|
||
isSender: false,
|
||
sessionId: 'voice-contact',
|
||
createTime: 1_785_895_200
|
||
},
|
||
{
|
||
id: 'voice-message-2',
|
||
localId: 19,
|
||
from: 'user',
|
||
type: '语音',
|
||
content: '[语音消息]',
|
||
isSender: false,
|
||
sessionId: 'voice-contact',
|
||
createTime: 1_785_895_201
|
||
}
|
||
])
|
||
let releaseFirstIndex: (() => void) | undefined
|
||
knowledgeService.index.mockImplementationOnce(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
releaseFirstIndex = resolve
|
||
})
|
||
)
|
||
const { voiceAccountIdentity, voiceMessageIdentity } =
|
||
await import('../../src/main/voice-pipeline/voice-message-identity')
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const update = (localId: number, createTime: number): VoiceTranscriptUpdate => {
|
||
const reference = { sessionId: 'voice-contact', localId, createTime }
|
||
return {
|
||
accountIdentity: voiceAccountIdentity(chatState.accountId),
|
||
reference,
|
||
messageIdentity: voiceMessageIdentity(reference),
|
||
state: 'transcribed' as const,
|
||
transcript: `转写 ${localId}`,
|
||
cached: false
|
||
}
|
||
}
|
||
|
||
const first = service.indexVoiceTranscript(update(18, 1_785_895_200))
|
||
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1))
|
||
const second = service.indexVoiceTranscript(update(19, 1_785_895_201))
|
||
const third = service.indexVoiceTranscript(update(18, 1_785_895_200))
|
||
releaseFirstIndex?.()
|
||
|
||
await Promise.all([first, second, third])
|
||
expect(knowledgeService.index).toHaveBeenCalledTimes(2)
|
||
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('uses existing Knowledge evidence while a new incremental pass is running', async () => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'fixture-account'
|
||
knowledgeService.search.mockResolvedValue({
|
||
state: 'indexing',
|
||
indexedMessageCount: 300,
|
||
indexedChunkCount: 60,
|
||
evidence: [
|
||
{
|
||
chunkId: 'chunk-1',
|
||
conversationId: 'fixture-conversation',
|
||
messageId: 'fixture-message',
|
||
senderId: 'fixture-sender',
|
||
sender: '脱敏成员',
|
||
timestamp: 1785895200000,
|
||
startTime: 1785895200000,
|
||
endTime: 1785895200000,
|
||
messageIds: ['fixture-message'],
|
||
text: 'Knowledge 已完成的部分可以立即检索。'
|
||
}
|
||
]
|
||
})
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({
|
||
text: 'fallback',
|
||
terms: ['fallback'],
|
||
limit: 10
|
||
})
|
||
|
||
expect(result).toMatchObject({
|
||
source: 'knowledge',
|
||
state: 'indexing',
|
||
totalMessages: 300
|
||
})
|
||
expect(result.evidence).toHaveLength(1)
|
||
expect(listMessagesAsync).not.toHaveBeenCalled()
|
||
await service.dispose()
|
||
})
|
||
|
||
it('splits a large scope filter before sending it to the Knowledge Worker', async () => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'fixture-account'
|
||
knowledgeService.search.mockResolvedValue({
|
||
state: 'ready',
|
||
indexedMessageCount: 1_500,
|
||
indexedChunkCount: 300,
|
||
evidence: []
|
||
})
|
||
const conversationIds = Array.from({ length: 1_401 }, (_, index) => `conversation-${index}`)
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
|
||
const result = await service.search({
|
||
text: '知识库',
|
||
terms: ['知识库'],
|
||
conversationIds,
|
||
limit: 10
|
||
})
|
||
|
||
expect(result).toMatchObject({ source: 'knowledge', totalMessages: 1_500 })
|
||
expect(knowledgeService.search).toHaveBeenCalledTimes(3)
|
||
for (const [request] of knowledgeService.search.mock.calls) {
|
||
expect(request.conversationIds.length).toBeLessThanOrEqual(700)
|
||
}
|
||
await service.dispose()
|
||
})
|
||
|
||
it('resolves a group member wxid to its group nickname in fallback evidence', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' }
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'group-message',
|
||
from: 'wxid_member',
|
||
type: '普通文本',
|
||
content: '今天继续健身。',
|
||
isSender: false,
|
||
senderId: 'wxid_member',
|
||
name: 'wxid_member',
|
||
createTime: 1785895200
|
||
}
|
||
])
|
||
getGroupMemberNamesAsync.mockResolvedValue([
|
||
{
|
||
wxid: 'wxid_member',
|
||
nickname: '微信昵称',
|
||
groupNickname: '健身同学',
|
||
wechatNickname: '微信昵称',
|
||
remark: '',
|
||
avatar: ''
|
||
}
|
||
])
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||
|
||
expect(result.evidence).toEqual([
|
||
expect.objectContaining({ senderId: 'wxid_member', sender: '健身同学' })
|
||
])
|
||
expect(getGroupMemberNamesAsync).toHaveBeenCalledWith('fixture-group', ['wxid_member'])
|
||
// sender enrichment 绝不能走完整群快照 —— 后者会 hydrate 整群成员名称与头像。
|
||
expect(getGroupSnapshotAsync).not.toHaveBeenCalled()
|
||
await service.dispose()
|
||
})
|
||
|
||
it('reuses contacts and group member names only within the same retrieval session', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' }
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'group-message',
|
||
from: 'wxid_member',
|
||
type: '普通文本',
|
||
content: '今天继续健身。',
|
||
isSender: false,
|
||
senderId: 'wxid_member',
|
||
name: 'wxid_member',
|
||
createTime: 1785895200
|
||
}
|
||
])
|
||
getGroupMemberNamesAsync.mockResolvedValue([
|
||
{
|
||
wxid: 'wxid_member',
|
||
nickname: '微信昵称',
|
||
groupNickname: '健身同学',
|
||
wechatNickname: '微信昵称',
|
||
remark: '',
|
||
avatar: ''
|
||
}
|
||
])
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const request = {
|
||
text: '健身',
|
||
terms: ['健身'],
|
||
retrievalSessionId: 'retrieval-a',
|
||
limit: 10
|
||
}
|
||
const first = await service.search(request)
|
||
const second = await service.search(request)
|
||
|
||
expect(first.evidence).toEqual(second.evidence)
|
||
expect(listContactsAsync).toHaveBeenCalledTimes(3)
|
||
// Each fallback search needs contacts for scope selection; enrichment is
|
||
// the only layer cached, so the second search avoids one extra lookup.
|
||
expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(1)
|
||
|
||
await service.search({ ...request, retrievalSessionId: 'retrieval-b' })
|
||
expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(2)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('resolves several senders of one group with a single name lookup (batched, deduped)', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' }
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'm1',
|
||
from: 'wxid_a',
|
||
type: '普通文本',
|
||
content: '健身打卡 A',
|
||
isSender: false,
|
||
senderId: 'wxid_a',
|
||
name: 'wxid_a',
|
||
createTime: 1785895200
|
||
},
|
||
{
|
||
id: 'm2',
|
||
from: 'wxid_b',
|
||
type: '普通文本',
|
||
content: '健身打卡 B',
|
||
isSender: false,
|
||
senderId: 'wxid_b',
|
||
name: 'wxid_b',
|
||
createTime: 1785895201
|
||
},
|
||
// 同一 sender 重复出现:不能因此多查一次(duplicate sender)。
|
||
{
|
||
id: 'm3',
|
||
from: 'wxid_a',
|
||
type: '普通文本',
|
||
content: '健身打卡 A2',
|
||
isSender: false,
|
||
senderId: 'wxid_a',
|
||
name: 'wxid_a',
|
||
createTime: 1785895202
|
||
}
|
||
])
|
||
getGroupMemberNamesAsync.mockImplementation(async (_md5: string, wxids: string[]) =>
|
||
wxids.map((wxid) => ({
|
||
wxid,
|
||
nickname: '',
|
||
groupNickname: `群昵称-${wxid}`,
|
||
wechatNickname: '',
|
||
remark: '',
|
||
avatar: ''
|
||
}))
|
||
)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||
|
||
expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(1)
|
||
expect(getGroupMemberNamesAsync.mock.calls[0][0]).toBe('fixture-group')
|
||
expect(new Set(getGroupMemberNamesAsync.mock.calls[0][1])).toEqual(new Set(['wxid_a', 'wxid_b']))
|
||
const bySender = new Map(result.evidence.map((item) => [item.senderId, item.sender]))
|
||
expect(bySender.get('wxid_a')).toBe('群昵称-wxid_a')
|
||
expect(bySender.get('wxid_b')).toBe('群昵称-wxid_b')
|
||
await service.dispose()
|
||
})
|
||
|
||
it('looks names up once per group when evidence spans several groups', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ md5: 'group-one', m_nsNickName: '群一', type: 'group' },
|
||
{ md5: 'group-two', m_nsNickName: '群二', type: 'group' }
|
||
])
|
||
listMessagesAsync.mockImplementation(async (md5: string) => {
|
||
if (md5 === 'group-one') {
|
||
return [
|
||
{
|
||
id: 'g1-m1',
|
||
from: 'wxid_1',
|
||
type: '普通文本',
|
||
content: '健身 group one',
|
||
isSender: false,
|
||
senderId: 'wxid_1',
|
||
name: 'wxid_1',
|
||
createTime: 1785895200
|
||
}
|
||
]
|
||
}
|
||
return [
|
||
{
|
||
id: 'g2-m1',
|
||
from: 'wxid_2',
|
||
type: '普通文本',
|
||
content: '健身 group two',
|
||
isSender: false,
|
||
senderId: 'wxid_2',
|
||
name: 'wxid_2',
|
||
createTime: 1785895201
|
||
}
|
||
]
|
||
})
|
||
getGroupMemberNamesAsync.mockImplementation(async (md5: string, wxids: string[]) =>
|
||
wxids.map((wxid) => ({
|
||
wxid,
|
||
nickname: '',
|
||
groupNickname: `${md5}:${wxid}`,
|
||
wechatNickname: '',
|
||
remark: '',
|
||
avatar: ''
|
||
}))
|
||
)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||
|
||
expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(2)
|
||
expect(getGroupMemberNamesAsync.mock.calls.map((call) => call[0]).sort()).toEqual([
|
||
'group-one',
|
||
'group-two'
|
||
])
|
||
const bySender = new Map(result.evidence.map((item) => [item.senderId, item.sender]))
|
||
expect(bySender.get('wxid_1')).toBe('group-one:wxid_1')
|
||
expect(bySender.get('wxid_2')).toBe('group-two:wxid_2')
|
||
await service.dispose()
|
||
})
|
||
|
||
it('keeps the display-name priority and never invents a name for an unknown wxid', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' }
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'm1',
|
||
from: 'wxid_remark',
|
||
type: '普通文本',
|
||
content: '健身 1',
|
||
isSender: false,
|
||
senderId: 'wxid_remark',
|
||
name: 'wxid_remark',
|
||
createTime: 1785895200
|
||
},
|
||
{
|
||
id: 'm2',
|
||
from: 'wxid_unknown',
|
||
type: '普通文本',
|
||
content: '健身 2',
|
||
isSender: false,
|
||
senderId: 'wxid_unknown',
|
||
name: 'wxid_unknown',
|
||
createTime: 1785895201
|
||
}
|
||
])
|
||
getGroupMemberNamesAsync.mockResolvedValue([
|
||
// 群昵称缺失 → 必须回退到 contact 微信昵称(不能退化成 wxid)。
|
||
{
|
||
wxid: 'wxid_remark',
|
||
nickname: '微信昵称A',
|
||
groupNickname: '',
|
||
wechatNickname: '微信昵称A',
|
||
remark: '备注A',
|
||
avatar: ''
|
||
},
|
||
// 什么名字都没有 → 保持原样,绝不编造。
|
||
{
|
||
wxid: 'wxid_unknown',
|
||
nickname: '',
|
||
groupNickname: '',
|
||
wechatNickname: '',
|
||
remark: '',
|
||
avatar: ''
|
||
}
|
||
])
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||
|
||
const bySender = new Map(result.evidence.map((item) => [item.senderId, item.sender]))
|
||
expect(bySender.get('wxid_remark')).toBe('微信昵称A')
|
||
expect(bySender.get('wxid_unknown')).toBe('wxid_unknown')
|
||
await service.dispose()
|
||
})
|
||
})
|
||
|
||
describe('KnowledgeSearchService freshness contract', () => {
|
||
beforeEach(() => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'fixture-account'
|
||
getGroupSnapshotAsync.mockReset()
|
||
getGroupMemberNamesAsync.mockReset()
|
||
getGroupMemberNamesAsync.mockResolvedValue([])
|
||
listContactsAsync.mockReset()
|
||
listMessagesAsync.mockReset()
|
||
getSourceLatestActivityMs.mockReset()
|
||
getSourceLatestActivityMs.mockReturnValue(null)
|
||
knowledgeService.index.mockClear()
|
||
knowledgeService.search.mockReset()
|
||
knowledgeService.status.mockClear()
|
||
listContactsAsync.mockResolvedValue([
|
||
{ m_nsUsrName: 'fixture-contact', m_nsNickName: '脱敏会话', md5: 'fixture-conversation', type: 'user' }
|
||
])
|
||
listMessagesAsync.mockResolvedValue([])
|
||
})
|
||
|
||
it('把索引覆盖时间与源数据最新时间一起返回(READY 不等于 FRESH)', async () => {
|
||
const indexLatestAt = 1787715444000
|
||
const sourceLatestAt = 1789099069000
|
||
getSourceLatestActivityMs.mockReturnValue(sourceLatestAt)
|
||
knowledgeService.search.mockResolvedValue({
|
||
state: 'ready',
|
||
evidence: [],
|
||
indexedMessageCount: 1,
|
||
indexedChunkCount: 1,
|
||
indexLatestAt,
|
||
voiceCoverage: undefined
|
||
})
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-freshness', '/missing-worker.js')
|
||
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||
|
||
expect(result.source).toBe('knowledge')
|
||
expect(result.indexLatestAt).toBe(indexLatestAt)
|
||
expect(result.sourceLatestAt).toBe(sourceLatestAt)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('派生索引不可用时也带上源数据最新时间,便于调用方判断落后程度', async () => {
|
||
const sourceLatestAt = 1789099069000
|
||
getSourceLatestActivityMs.mockReturnValue(sourceLatestAt)
|
||
knowledgeService.search.mockResolvedValue({
|
||
state: 'unavailable',
|
||
evidence: [],
|
||
indexedMessageCount: 0,
|
||
indexedChunkCount: 0,
|
||
indexLatestAt: null,
|
||
voiceCoverage: undefined
|
||
})
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-freshness-2', '/missing-worker.js')
|
||
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||
|
||
expect(result.indexLatestAt).toBeNull()
|
||
expect(result.sourceLatestAt).toBe(sourceLatestAt)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('requestCatchUp 复用正在跑的索引任务,不启动第二个', async () => {
|
||
// 让索引 pass 停在读取消息这一步,从而稳定地处于 in-flight 状态。
|
||
listMessagesAsync.mockImplementation(() => new Promise(() => {}))
|
||
const service = new KnowledgeSearchService('/tmp/wxe-freshness-3', '/missing-worker.js')
|
||
|
||
await expect(service.waitForIndexingComplete(5)).resolves.toBe(true)
|
||
|
||
const first = service.requestCatchUp(0)
|
||
expect(first).toMatchObject({ triggered: true, inProgress: true })
|
||
const second = service.requestCatchUp(0)
|
||
expect(second).toMatchObject({ triggered: false, inProgress: true })
|
||
|
||
// 有界等待:追不上就返回 false,绝不无限阻塞交互查询。
|
||
await expect(service.waitForIndexingComplete(20)).resolves.toBe(false)
|
||
await service.dispose()
|
||
})
|
||
})
|
||
|
||
describe('KnowledgeSearchService 完整 pass 记录源数据边界', () => {
|
||
beforeEach(() => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'fixture-account'
|
||
listContactsAsync.mockReset()
|
||
listMessagesAsync.mockReset()
|
||
knowledgeService.index.mockClear()
|
||
knowledgeService.search.mockReset()
|
||
knowledgeService.status.mockClear()
|
||
})
|
||
|
||
it('只在最后处理的会话上写入 sourceLatestAt,且取未过滤原始消息的最大 createTime', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ m_nsUsrName: 'fixture-a', m_nsNickName: 'A', md5: 'fixture-a', type: 'user' },
|
||
{ m_nsUsrName: 'fixture-b', m_nsNickName: 'B', md5: 'fixture-b', type: 'user' }
|
||
])
|
||
listMessagesAsync.mockImplementation(async (md5: string) =>
|
||
md5 === 'fixture-a'
|
||
? [{ id: 'a1', from: 'x', type: 'text', datetime: '', content: '你好', isSender: false, createTime: 1000 }]
|
||
: [
|
||
{ id: 'b1', from: 'x', type: 'text', datetime: '', content: 'hi', isSender: false, createTime: 2000 },
|
||
// 无正文/无附件的消息不会被索引建模,但 freshness 口径必须把它算进来。
|
||
{ id: 'b2', from: 'x', type: '图片', datetime: '', content: '', isSender: false, createTime: 9000 }
|
||
]
|
||
)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-pass-coverage', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
expect(knowledgeService.index).toHaveBeenCalledTimes(2)
|
||
const calls = knowledgeService.index.mock.calls as Array<[Record<string, unknown>]>
|
||
expect(calls[0][0].sourceLatestAt).toBeUndefined()
|
||
expect(calls[0][0].sourceMessageCount).toBeUndefined()
|
||
expect(calls[1][0].sourceLatestAt).toBe(9000 * 1000)
|
||
expect(calls[1][0].sourceMessageCount).toBe(2)
|
||
await service.dispose()
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 索引同步契约:必须**后台 / 可取消 / 可断点续传 / 不阻塞查询**,
|
||
* 而且取消之后不允许留下一个假的"正在同步"。
|
||
*/
|
||
describe('KnowledgeSearchService sync contract', () => {
|
||
beforeEach(() => {
|
||
chatState.ready = true
|
||
chatState.accountId = 'fixture-account'
|
||
listContactsAsync.mockReset()
|
||
listMessagesAsync.mockReset()
|
||
getConversationActivityMs.mockReset()
|
||
getConversationActivityMs.mockReturnValue(new Map<string, number>())
|
||
knowledgeService.index.mockReset()
|
||
knowledgeService.index.mockResolvedValue({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 0,
|
||
indexedChunks: 0,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
})
|
||
knowledgeService.highWaterMarks.mockReset()
|
||
knowledgeService.highWaterMarks.mockResolvedValue({})
|
||
knowledgeService.cancelIndex.mockReset()
|
||
knowledgeService.cancelIndex.mockResolvedValue(false)
|
||
knowledgeService.search.mockReset()
|
||
knowledgeService.status.mockClear()
|
||
listMessagesAsync.mockResolvedValue([])
|
||
})
|
||
|
||
const twoContacts = (): void => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ m_nsUsrName: 'fixture-a', m_nsNickName: 'A', md5: 'fixture-a', type: 'user' },
|
||
{ m_nsUsrName: 'fixture-b', m_nsNickName: 'B', md5: 'fixture-b', type: 'user' }
|
||
])
|
||
}
|
||
|
||
const captureStatuses = (
|
||
service: KnowledgeSearchService
|
||
): { all: Array<Record<string, unknown>>; last: () => Record<string, unknown> } => {
|
||
const all: Array<Record<string, unknown>> = []
|
||
service.onStatusChange((status) => all.push(status as unknown as Record<string, unknown>))
|
||
return { all, last: () => all[all.length - 1] }
|
||
}
|
||
|
||
it('skips conversations whose source activity is already covered by the checkpoint', async () => {
|
||
twoContacts()
|
||
// a 的源侧最后活跃时间早于它已经索引到的位置 → 没有任何新消息,必须整段跳过。
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([
|
||
['fixture-a', 4_000_000],
|
||
['fixture-b', 8_000_000]
|
||
])
|
||
)
|
||
knowledgeService.highWaterMarks.mockResolvedValue({ 'fixture-a': 5_000_000 })
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-skip', '/missing-worker.js')
|
||
const statuses = captureStatuses(service)
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
// 真正 delta 的核心:被跳过的会话**不读 WCDB**(这是把"每次全量重扫"变成分钟级以内的原因)。
|
||
expect(listMessagesAsync).toHaveBeenCalledTimes(1)
|
||
expect(listMessagesAsync.mock.calls[0][0]).toBe('fixture-b')
|
||
expect(knowledgeService.index).toHaveBeenCalledTimes(1)
|
||
|
||
const finished = statuses.all.find(
|
||
(status) => (status.pass as { phase?: string } | undefined)?.phase === 'idle'
|
||
)
|
||
const pass = finished?.pass as {
|
||
skippedConversations: number
|
||
processedConversations: number
|
||
totalConversations: number
|
||
cancellable: boolean
|
||
}
|
||
expect(pass.totalConversations).toBe(2)
|
||
expect(pass.skippedConversations).toBe(1)
|
||
expect(pass.processedConversations).toBe(2)
|
||
// 一遍跑完必须清掉"可取消",否则 UI 会在结束后继续显示取消按钮。
|
||
expect(pass.cancellable).toBe(false)
|
||
|
||
// 有跳过时不能把这一遍的部分计数冒充成"全量总量"。
|
||
const indexArgs = knowledgeService.index.mock.calls[0][0] as Record<string, unknown>
|
||
expect(indexArgs.sourceMessageCount).toBeUndefined()
|
||
expect(indexArgs.sourceLatestAt).toBeUndefined()
|
||
await service.dispose()
|
||
})
|
||
|
||
it('never skips a conversation when the checkpoint has no entry for it', async () => {
|
||
twoContacts()
|
||
// 源侧有活跃时间,但派生库没有对应的 checkpoint(例如换库/清库之后)。
|
||
// 这时候必须真读真同步,否则会静默丢消息。
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([
|
||
['fixture-a', 4_000_000],
|
||
['fixture-b', 8_000_000]
|
||
])
|
||
)
|
||
knowledgeService.highWaterMarks.mockResolvedValue({})
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-noskip', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
|
||
expect(knowledgeService.index).toHaveBeenCalledTimes(2)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('reports a cancelled pass as cancelled and never leaves a fake indexing state', async () => {
|
||
twoContacts()
|
||
let releaseSecond: (() => void) | undefined
|
||
knowledgeService.index
|
||
.mockResolvedValueOnce({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 1,
|
||
indexedChunks: 1,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
})
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
releaseSecond = () =>
|
||
resolve({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 0,
|
||
indexedChunks: 0,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: true
|
||
})
|
||
})
|
||
)
|
||
knowledgeService.cancelIndex.mockResolvedValue(true)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-cancel', '/missing-worker.js')
|
||
const statuses = captureStatuses(service)
|
||
service.startCurrentAccountIndex()
|
||
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(2))
|
||
|
||
const ack = await service.cancelCurrentAccountIndex()
|
||
expect(ack).toEqual({ cancellable: true, cancelled: true })
|
||
// 取消请求一发出,UI 立刻就该看到"不可再取消"(按钮变「正在取消…」)。
|
||
const cancelling = statuses.all.find(
|
||
(status) => (status.pass as { cancellable?: boolean } | undefined)?.cancellable === false
|
||
)
|
||
expect(cancelling).toBeDefined()
|
||
|
||
releaseSecond?.()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
const finalStatus = await service.getStatus()
|
||
expect(finalStatus.state).toBe('cancelled')
|
||
expect(finalStatus.pass?.phase).toBe('cancelled')
|
||
expect(finalStatus.pass?.cancellable).toBe(false)
|
||
// 全程不允许出现"同步中"残留(run_state 不得留下假的 indexing)。
|
||
expect(statuses.all.some((status) => status.state === 'cancelled')).toBe(true)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('keeps committed conversations when a pass is cancelled and resumes from the checkpoint', async () => {
|
||
listContactsAsync.mockResolvedValue([
|
||
{ m_nsUsrName: 'fixture-a', m_nsNickName: 'A', md5: 'fixture-a', type: 'user' },
|
||
{ m_nsUsrName: 'fixture-b', m_nsNickName: 'B', md5: 'fixture-b', type: 'user' },
|
||
{ m_nsUsrName: 'fixture-c', m_nsNickName: 'C', md5: 'fixture-c', type: 'user' }
|
||
])
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'fixture-message',
|
||
localId: 42,
|
||
from: 'user',
|
||
type: '普通文本',
|
||
datetime: '2026/8/5 10:00:00',
|
||
content: '断点续传',
|
||
isSender: false,
|
||
senderId: 'fixture-sender',
|
||
name: '脱敏成员',
|
||
createTime: 1785895200
|
||
}
|
||
])
|
||
let releaseThird: (() => void) | undefined
|
||
knowledgeService.index
|
||
.mockResolvedValueOnce({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 1,
|
||
indexedChunks: 1,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
})
|
||
.mockResolvedValueOnce({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 1,
|
||
indexedChunks: 1,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
})
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
releaseThird = () =>
|
||
resolve({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 0,
|
||
indexedChunks: 0,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: true
|
||
})
|
||
})
|
||
)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-resume', '/missing-worker.js')
|
||
const statuses = captureStatuses(service)
|
||
service.startCurrentAccountIndex()
|
||
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(3))
|
||
await service.cancelCurrentAccountIndex()
|
||
releaseThird?.()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
// 取消 ≠ 回滚:前两个会话已经提交的索引必须留下来。
|
||
const cancelled = statuses.all[statuses.all.length - 1]
|
||
const pass = cancelled.pass as { indexedMessages: number; phase: string }
|
||
expect(pass.phase).toBe('cancelled')
|
||
expect(pass.indexedMessages).toBeGreaterThan(0)
|
||
|
||
// 下一遍:两个已覆盖的会话必须被 checkpoint 跳过,只补没跑完的那个。
|
||
knowledgeService.index.mockClear()
|
||
listMessagesAsync.mockClear()
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([
|
||
['fixture-a', 1_785_895_200_000],
|
||
['fixture-b', 1_785_895_200_000]
|
||
])
|
||
)
|
||
// a 与 b 的 checkpoint 已追平它们的源侧活跃时间;c 仍然没有覆盖。
|
||
knowledgeService.highWaterMarks.mockResolvedValue({
|
||
'fixture-a': 1_785_895_200_000,
|
||
'fixture-b': 1_785_895_200_000
|
||
})
|
||
knowledgeService.index.mockResolvedValue({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 1,
|
||
indexedChunks: 1,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
})
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
expect(listMessagesAsync).toHaveBeenCalledTimes(1)
|
||
expect(listMessagesAsync.mock.calls[0][0]).toBe('fixture-c')
|
||
await service.dispose()
|
||
})
|
||
|
||
it('is honest when there is nothing to cancel', async () => {
|
||
twoContacts()
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-nocancel', '/missing-worker.js')
|
||
// 没有在跑的 pass:不能谎报"已取消"。
|
||
await expect(service.cancelCurrentAccountIndex()).resolves.toEqual({
|
||
cancellable: false,
|
||
cancelled: false
|
||
})
|
||
expect(knowledgeService.cancelIndex).not.toHaveBeenCalled()
|
||
await service.dispose()
|
||
})
|
||
|
||
it('does not let a cancelled pass shorten the catch-up threshold of the next pass', async () => {
|
||
twoContacts()
|
||
let releaseFirst: (() => void) | undefined
|
||
knowledgeService.index.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
releaseFirst = () =>
|
||
resolve({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 0,
|
||
indexedChunks: 0,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: true
|
||
})
|
||
})
|
||
)
|
||
knowledgeService.cancelIndex.mockResolvedValue(true)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-threshold', '/missing-worker.js')
|
||
const statuses = captureStatuses(service)
|
||
service.startCurrentAccountIndex()
|
||
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1))
|
||
await service.cancelCurrentAccountIndex()
|
||
releaseFirst?.()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
// 取消的一遍不计入 lastIndexPassMs;否则"是否值得再追一遍"的门槛会被一次提前结束的 pass 带偏
|
||
// (表现为:明明落后很多却因为"上一遍很快"而拒绝追赶)。
|
||
const last = statuses.all[statuses.all.length - 1]
|
||
expect(last.state).toBe('cancelled')
|
||
expect(await service.getStatus()).toMatchObject({ state: 'cancelled' })
|
||
await service.dispose()
|
||
})
|
||
|
||
it('keeps search available while a pass is running (indexing must not block queries)', async () => {
|
||
twoContacts()
|
||
// 搜索走派生库,索引在跑 → 必须复用**已有**证据,而不是把用户挡回去等同步。
|
||
knowledgeService.search.mockResolvedValue({
|
||
state: 'ready',
|
||
evidence: [
|
||
{
|
||
messageId: 'local:42',
|
||
conversationId: 'fixture-a',
|
||
sender: '脱敏成员',
|
||
senderId: 'fixture-sender',
|
||
timestamp: 1785895200000,
|
||
text: '命中',
|
||
kind: 'text',
|
||
score: 1
|
||
}
|
||
],
|
||
indexedMessageCount: 2,
|
||
indexedChunkCount: 1,
|
||
indexLatestAt: 1785895200000,
|
||
timings: {}
|
||
})
|
||
let releaseIndex: (() => void) | undefined
|
||
knowledgeService.index.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
releaseIndex = () =>
|
||
resolve({
|
||
accountId: 'fixture-account',
|
||
processedMessages: 1,
|
||
indexedChunks: 1,
|
||
updatedChunks: 0,
|
||
unchangedConversations: 0,
|
||
databaseBytes: 0,
|
||
walBytes: 0,
|
||
elapsedMs: 0,
|
||
cancelled: false
|
||
})
|
||
})
|
||
)
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-query', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1))
|
||
|
||
const result = await service.search({
|
||
text: '命中',
|
||
terms: ['命中'],
|
||
limit: 5
|
||
})
|
||
// 索引在跑也必须能查到(走派生库的既有数据),且不降级成一次 WCDB 全扫。
|
||
expect(result.source).toBe('knowledge')
|
||
expect(result.evidence.length).toBeGreaterThan(0)
|
||
|
||
releaseIndex?.()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('reads nothing at all when every checkpoint already covers the source (empty incremental pass)', async () => {
|
||
twoContacts()
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([
|
||
['fixture-a', 4_000_000],
|
||
['fixture-b', 5_000_000]
|
||
])
|
||
)
|
||
knowledgeService.highWaterMarks.mockResolvedValue({
|
||
'fixture-a': 4_000_000,
|
||
'fixture-b': 5_000_000
|
||
})
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-empty', '/missing-worker.js')
|
||
const statuses = captureStatuses(service)
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
// 空增量 pass 的核心断言:一条源消息都不读、一个会话都不写。
|
||
// 这必须接近 O(会话数),而不是 O(全部历史消息)。
|
||
expect(listMessagesAsync).not.toHaveBeenCalled()
|
||
expect(knowledgeService.index).not.toHaveBeenCalled()
|
||
|
||
const finished = statuses.all.find(
|
||
(status) => (status.pass as { phase?: string } | undefined)?.phase === 'idle'
|
||
)
|
||
const pass = finished?.pass as {
|
||
skippedConversations: number
|
||
scannedMessages: number
|
||
indexedMessages: number
|
||
catchupConversations: number
|
||
backfillConversations: number
|
||
}
|
||
expect(pass.skippedConversations).toBe(2)
|
||
expect(pass.scannedMessages).toBe(0)
|
||
expect(pass.indexedMessages).toBe(0)
|
||
expect(pass.catchupConversations).toBe(0)
|
||
expect(pass.backfillConversations).toBe(0)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('reads only the bounded delta window and never claims a complete snapshot', async () => {
|
||
twoContacts()
|
||
const mark = 1_700_000_000_000
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([['fixture-a', mark + 60_000]])
|
||
)
|
||
knowledgeService.highWaterMarks.mockResolvedValue({
|
||
'fixture-a': mark,
|
||
'fixture-b': 1_000
|
||
})
|
||
// b 的 checkpoint 已经覆盖它的源侧活动 → 整段跳过,让断言只看到 a 的一次 delta 读。
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([
|
||
['fixture-a', mark + 60_000],
|
||
['fixture-b', 1_000]
|
||
])
|
||
)
|
||
listMessagesAsync.mockResolvedValue([
|
||
{
|
||
id: 'delta-message',
|
||
localId: 7,
|
||
from: 'fixture-sender',
|
||
type: '普通文本',
|
||
content: '今天继续健身。',
|
||
isSender: false,
|
||
senderId: 'fixture-sender',
|
||
name: '脱敏成员',
|
||
createTime: 1_700_000_030
|
||
}
|
||
])
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-delta', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
// 1) 只读 delta 窗口:下界 = checkpoint - 24h overlap,而不是历史开头。
|
||
// ⚠️ 单位契约:checkpoint 是 epoch ms,但传给 WCDB 读取层的 startTime 必须是
|
||
// epoch **秒** —— `finalizeMessages` 拿它和秒级 `create_time` 直接比较。
|
||
// 传 ms 会让下界恒大于任何真实消息时间戳,delta 读被静默过滤成空。
|
||
expect(listMessagesAsync).toHaveBeenCalledTimes(1)
|
||
expect(listMessagesAsync.mock.calls[0][0]).toBe('fixture-a')
|
||
const deltaLowerBound = listMessagesAsync.mock.calls[0][1] as number
|
||
expect(deltaLowerBound).toBe(Math.floor((mark - 24 * 60 * 60 * 1000) / 1000))
|
||
// 秒级下界(< 1e11),且真的落在真实消息时间戳之前;否则整段 delta 会被过滤为空。
|
||
expect(deltaLowerBound).toBeLessThan(1e11)
|
||
expect(deltaLowerBound).toBeLessThan(1_700_000_030)
|
||
// 2) 增量绝不能声明"完整快照",否则 store 会把没在 delta 里的历史消息
|
||
// 误判成已删除,从而整段重建这个会话,增量就白做了。
|
||
const conversation = (
|
||
knowledgeService.index.mock.calls[0][0] as {
|
||
conversations: Array<Record<string, unknown>>
|
||
}
|
||
).conversations[0]
|
||
expect(conversation.completeSnapshot).toBe(false)
|
||
expect(conversation.sourceHighWaterTime).toBe(mark + 60_000)
|
||
await service.dispose()
|
||
})
|
||
|
||
it('processes catch-up conversations before historical backfill', async () => {
|
||
twoContacts()
|
||
const mark = 1_700_000_000_000
|
||
// a 从来没有 checkpoint(历史缺口,2020 年的时间戳);b 已有 checkpoint 且出现新消息。
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([
|
||
['fixture-a', 1_600_000_000_000],
|
||
['fixture-b', mark + 60_000]
|
||
])
|
||
)
|
||
knowledgeService.highWaterMarks.mockResolvedValue({ 'fixture-b': mark })
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-order', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
// 「今天的新消息」必须排在 2020 年的历史缺口前面,否则用户永远等不到新数据。
|
||
expect(listMessagesAsync.mock.calls.map((call) => call[0])).toEqual(['fixture-b', 'fixture-a'])
|
||
await service.dispose()
|
||
})
|
||
|
||
it('does not advance a checkpoint on a suspicious empty delta read', async () => {
|
||
twoContacts()
|
||
const mark = 1_700_000_000_000
|
||
getConversationActivityMs.mockReturnValue(
|
||
new Map<string, number>([['fixture-a', mark + 60_000]])
|
||
)
|
||
knowledgeService.highWaterMarks.mockResolvedValue({ 'fixture-a': mark })
|
||
// 可疑:源侧明确说有新消息,但 delta 范围读返回了空。
|
||
listMessagesAsync.mockResolvedValue([])
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-suspicious', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
const conversation = (
|
||
knowledgeService.index.mock.calls[0][0] as {
|
||
conversations: Array<Record<string, unknown>>
|
||
}
|
||
).conversations[0]
|
||
// 宁可下一遍重试,也绝不能把"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。
|
||
expect(conversation.sourceHighWaterTime).toBeUndefined()
|
||
await service.dispose()
|
||
})
|
||
|
||
it('still reads every conversation when the source activity map is empty', async () => {
|
||
twoContacts()
|
||
// 锁死一条安全性质:**「源侧活动里没有它」不是「它没有消息」的证据**。
|
||
// 用 activity 缺失来跳过没有 Session 行的联系人看起来很有吸引力,
|
||
// 但只要 Session 表读取不完整,那个优化就会把真有消息的会话变成永久静默不索引。
|
||
// 因此:没有 checkpoint 就必须老实读一遍。谁加了 activity-based 跳过,这里必须红。
|
||
getConversationActivityMs.mockReturnValue(new Map<string, number>())
|
||
knowledgeService.highWaterMarks.mockResolvedValue({})
|
||
|
||
const service = new KnowledgeSearchService('/tmp/wxe-sync-empty-activity', '/missing-worker.js')
|
||
service.startCurrentAccountIndex()
|
||
await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true)
|
||
|
||
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
|
||
await service.dispose()
|
||
})
|
||
})
|