From 187268d8a5f7d53c0965715c684a986225ca8a8d Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 7 Jun 2026 20:49:00 +0800 Subject: [PATCH] test(core,import): rebalance database coverage --- .../desktop/main/worker/import/tempDb.test.ts | 10 - .../src/query/__tests__/session-index.test.ts | 312 +++++++----------- 2 files changed, 112 insertions(+), 210 deletions(-) diff --git a/apps/desktop/main/worker/import/tempDb.test.ts b/apps/desktop/main/worker/import/tempDb.test.ts index f18d36e7..769cfaa2 100644 --- a/apps/desktop/main/worker/import/tempDb.test.ts +++ b/apps/desktop/main/worker/import/tempDb.test.ts @@ -13,13 +13,3 @@ test('空字符串内容在写库归一化前后应生成同一个去重 key', ( assert.equal(keyAfterPersist, keyBeforePersist) }) - -test('同秒同发送者但等长不同内容应生成不同去重 key', () => { - const timestamp = 1710000000 - const senderPlatformId = 'user-1' - - const firstKey = generateMessageKey(timestamp, senderPlatformId, '你好啊') - const secondKey = generateMessageKey(timestamp, senderPlatformId, '再见呀') - - assert.notEqual(firstKey, secondKey) -}) diff --git a/packages/core/src/query/__tests__/session-index.test.ts b/packages/core/src/query/__tests__/session-index.test.ts index 2a65d277..b376cf48 100644 --- a/packages/core/src/query/__tests__/session-index.test.ts +++ b/packages/core/src/query/__tests__/session-index.test.ts @@ -9,6 +9,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' +import Database from 'better-sqlite3' import { DEFAULT_SESSION_GAP_THRESHOLD, hasSessionIndex, @@ -23,187 +24,98 @@ import { } from '../session-queries' import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' -// ==================== In-memory mock DB ==================== +// ==================== SQLite test DB ==================== -interface MockRow { - [key: string]: unknown +class SqlitePreparedStatement implements PreparedStatement { + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + + get(...params: unknown[]): Record | undefined { + return this.stmt.get(...params) as Record | undefined + } + + all(...params: unknown[]): Record[] { + return this.stmt.all(...params) as Record[] + } + + run(...params: unknown[]): RunResult { + const result = this.stmt.run(...params) + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } + } } -/** - * Lightweight in-memory DB for testing session index write operations. - * Stores tables as arrays of rows and supports basic SQL pattern matching. - */ -function createInMemoryDb(): DatabaseAdapter & { tables: Record } { - const tables: Record = { - message: [], - segment: [], - message_context: [], - meta: [{ session_gap_threshold: null }], - sqlite_master: [], - } - let autoIncrement = 0 +class TestSqliteDb implements DatabaseAdapter { + constructor(private db: Database.Database) {} - const db: DatabaseAdapter & { tables: Record } = { - tables, - prepare(sql: string): PreparedStatement { - return { - get(...params: unknown[]) { - return handleGet(sql, params) - }, - all(...params: unknown[]) { - return handleAll(sql, params) - }, - run(...params: unknown[]): RunResult { - return handleRun(sql, params) - }, - } - }, - exec(sql: string) { - if (sql.includes('DELETE FROM message_context')) { - tables.message_context = [] - } - if (sql.includes('DELETE FROM segment')) { - tables.segment = [] - autoIncrement = 0 - } - }, - transaction(fn: () => T): T { - return fn() - }, - pragma() { - return undefined - }, - // eslint-disable-next-line @typescript-eslint/no-empty-function - close() {}, + exec(sql: string): void { + this.db.exec(sql) } - function handleGet(sql: string, params: unknown[]): MockRow | undefined { - if (sql.includes('sqlite_master')) { - const tableName = (params[0] as string) ?? '' - return tables[tableName] ? { cnt: 1 } : { cnt: 0 } - } - if (sql.includes('COUNT(*)') && sql.includes('FROM segment')) { - return { count: tables.segment.length } - } - if (sql.includes('COUNT(*)') && sql.includes('FROM message')) { - return { count: tables.message.length } - } - if (sql.includes('session_gap_threshold') && sql.includes('meta')) { - return tables.meta[0] ?? { session_gap_threshold: null } - } - if (sql.includes('summary') && sql.includes('segment') && sql.includes('WHERE id')) { - const id = params[0] as number - const row = tables.segment.find((r) => r.id === id) - return row ? { summary: row.summary ?? null } : undefined - } - if (sql.includes('end_ts') && sql.includes('segment') && sql.includes('ORDER BY end_ts DESC')) { - const sorted = [...tables.segment].sort((a, b) => (b.end_ts as number) - (a.end_ts as number)) - return sorted[0] ? { id: sorted[0].id, end_ts: sorted[0].end_ts } : undefined - } - return undefined + prepare(sql: string): PreparedStatement { + return new SqlitePreparedStatement(this.db.prepare(sql)) } - function handleAll(sql: string, params: unknown[]): MockRow[] { - if (sql.includes('sqlite_master')) { - const tableName = (params[0] as string) ?? '' - return tables[tableName] ? [{ cnt: 1 }] : [{ cnt: 0 }] - } - if (sql.includes('PRAGMA table_info')) { - return [{ name: 'id' }] - } - if (sql.includes('session_num')) { - const gt = params[0] as number - const msgs = [...tables.message].sort( - (a, b) => (a.ts as number) - (b.ts as number) || (a.id as number) - (b.id as number) - ) - let sessionNum = 0 - let prevTs: number | null = null - return msgs.map((m) => { - if (prevTs === null || (m.ts as number) - prevTs > gt) { - sessionNum++ - } - prevTs = m.ts as number - return { id: m.id, ts: m.ts, session_num: sessionNum } - }) - } - // segment list query (contains subquery on message_context) — must match before message_context - if (sql.includes('FROM segment') && sql.includes('ORDER BY')) { - return [...tables.segment] - .sort((a, b) => (a.start_ts as number) - (b.start_ts as number)) - .map((s) => ({ - id: s.id, - startTs: s.start_ts, - endTs: s.end_ts, - messageCount: s.message_count, - summary: s.summary ?? null, - firstMessageId: - tables.message_context - .filter((mc) => mc.segment_id === s.id) - .sort((a, b) => (a.message_id as number) - (b.message_id as number))[0]?.message_id ?? 0, - })) - } - if (sql.includes('message_context') && sql.includes('SELECT message_id')) { - return tables.message_context.map((r) => ({ message_id: r.message_id })) - } - if (sql.includes('FROM message') && sql.includes('ORDER BY ts')) { - return [...tables.message].sort( - (a, b) => (a.ts as number) - (b.ts as number) || (a.id as number) - (b.id as number) - ) - } - return [] + transaction(fn: () => T): T { + return this.db.transaction(fn)() } - function handleRun(sql: string, params: unknown[]): RunResult { - if (sql.includes('INSERT INTO segment')) { - autoIncrement++ - tables.segment.push({ - id: autoIncrement, - start_ts: params[0], - end_ts: params[1], - message_count: params[2], - is_manual: 0, - summary: null, - }) - return { changes: 1, lastInsertRowid: autoIncrement } - } - if (sql.includes('INSERT') && sql.includes('message_context')) { - tables.message_context.push({ message_id: params[0], segment_id: params[1], topic_id: null }) - return { changes: 1, lastInsertRowid: 0 } - } - if (sql.includes('UPDATE segment') && sql.includes('summary')) { - const row = tables.segment.find((r) => r.id === params[1]) - if (row) row.summary = params[0] - return { changes: row ? 1 : 0, lastInsertRowid: 0 } - } - if (sql.includes('UPDATE segment') && sql.includes('end_ts')) { - const row = tables.segment.find((r) => r.id === params[2]) - if (row) { - row.end_ts = params[0] - row.message_count = (row.message_count as number) + (params[1] as number) - } - return { changes: row ? 1 : 0, lastInsertRowid: 0 } - } - if (sql.includes('UPDATE meta') && sql.includes('session_gap_threshold')) { - if (tables.meta[0]) tables.meta[0].session_gap_threshold = params[0] - return { changes: 1, lastInsertRowid: 0 } - } - if (sql.includes('DELETE FROM segment')) { - tables.segment = [] - autoIncrement = 0 - return { changes: 0, lastInsertRowid: 0 } - } - if (sql.includes('DELETE FROM message_context')) { - tables.message_context = [] - return { changes: 0, lastInsertRowid: 0 } - } - return { changes: 0, lastInsertRowid: 0 } + pragma(pragma: string): unknown { + return this.db.pragma(pragma) } + close(): void { + this.db.close() + } +} + +function createSqliteDb(): TestSqliteDb { + const db = new TestSqliteDb(new Database(':memory:')) + db.exec(` + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL + ); + CREATE TABLE segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER NOT NULL, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + CREATE TABLE message_context ( + message_id INTEGER NOT NULL, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + CREATE TABLE meta ( + session_gap_threshold INTEGER + ); + INSERT INTO meta (session_gap_threshold) VALUES (NULL); + `) return db } -function seedMessages(db: ReturnType, msgs: Array<{ id: number; ts: number }>) { - db.tables.message = msgs.map((m) => ({ id: m.id, ts: m.ts })) +function seedMessages(db: DatabaseAdapter, msgs: Array<{ id: number; ts: number }>) { + const insert = db.prepare('INSERT INTO message (id, ts) VALUES (?, ?)') + for (const msg of msgs) { + insert.run(msg.id, msg.ts) + } +} + +function countRows(db: DatabaseAdapter, table: 'segment' | 'message_context'): number { + const row = db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get() as { count: number } + return row.count +} + +function getMetaGapThreshold(db: DatabaseAdapter): number | null { + const row = db.prepare('SELECT session_gap_threshold FROM meta LIMIT 1').get() as { + session_gap_threshold: number | null + } + return row.session_gap_threshold } // ==================== Tests ==================== @@ -216,12 +128,12 @@ describe('DEFAULT_SESSION_GAP_THRESHOLD', () => { describe('hasSessionIndex', () => { it('returns false when no sessions exist', () => { - const db = createInMemoryDb() + const db = createSqliteDb() assert.equal(hasSessionIndex(db), false) }) it('returns true after generating sessions', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 1100 }, @@ -233,7 +145,7 @@ describe('hasSessionIndex', () => { describe('getSessionIndexStats', () => { it('returns defaults when no index exists', () => { - const db = createInMemoryDb() + const db = createSqliteDb() const stats = getSessionIndexStats(db) assert.equal(stats.sessionCount, 0) assert.equal(stats.hasIndex, false) @@ -241,8 +153,8 @@ describe('getSessionIndexStats', () => { }) it('returns custom gap threshold from meta', () => { - const db = createInMemoryDb() - db.tables.meta[0].session_gap_threshold = 900 + const db = createSqliteDb() + updateSessionGapThreshold(db, 900) const stats = getSessionIndexStats(db) assert.equal(stats.gapThreshold, 900) }) @@ -250,12 +162,12 @@ describe('getSessionIndexStats', () => { describe('generateSessionIndex', () => { it('returns 0 when no messages exist', () => { - const db = createInMemoryDb() + const db = createSqliteDb() assert.equal(generateSessionIndex(db), 0) }) it('creates sessions based on gap threshold', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 1100 }, @@ -265,12 +177,12 @@ describe('generateSessionIndex', () => { const count = generateSessionIndex(db, 2000) assert.equal(count, 2) - assert.equal(db.tables.segment.length, 2) - assert.equal(db.tables.message_context.length, 4) + assert.equal(countRows(db, 'segment'), 2) + assert.equal(countRows(db, 'message_context'), 4) }) it('puts all messages in one session when gap is large enough', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 1100 }, @@ -282,21 +194,21 @@ describe('generateSessionIndex', () => { }) it('clears previous sessions before regenerating', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 5000 }, ]) generateSessionIndex(db, 2000) - assert.equal(db.tables.segment.length, 2) + assert.equal(countRows(db, 'segment'), 2) generateSessionIndex(db, 99999) - assert.equal(db.tables.segment.length, 1) + assert.equal(countRows(db, 'segment'), 1) }) it('calls onProgress callback', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 5000 }, @@ -315,12 +227,12 @@ describe('generateSessionIndex', () => { describe('getChatSessionList', () => { it('returns empty array when no sessions', () => { - const db = createInMemoryDb() + const db = createSqliteDb() assert.deepEqual(getChatSessionList(db), []) }) it('returns sessions with firstMessageId', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 10, ts: 1000 }, { id: 20, ts: 1100 }, @@ -338,7 +250,7 @@ describe('getChatSessionList', () => { describe('getSegmentSummary / saveSegmentSummary', () => { it('returns null when no summary set', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [{ id: 1, ts: 1000 }]) generateSessionIndex(db) @@ -346,7 +258,7 @@ describe('getSegmentSummary / saveSegmentSummary', () => { }) it('saves and retrieves summary', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [{ id: 1, ts: 1000 }]) generateSessionIndex(db) @@ -357,38 +269,38 @@ describe('getSegmentSummary / saveSegmentSummary', () => { describe('updateSessionGapThreshold', () => { it('updates gap threshold in meta', () => { - const db = createInMemoryDb() + const db = createSqliteDb() updateSessionGapThreshold(db, 900) - assert.equal(db.tables.meta[0].session_gap_threshold, 900) + assert.equal(getMetaGapThreshold(db), 900) }) it('accepts null to reset', () => { - const db = createInMemoryDb() + const db = createSqliteDb() updateSessionGapThreshold(db, 900) updateSessionGapThreshold(db, null) - assert.equal(db.tables.meta[0].session_gap_threshold, null) + assert.equal(getMetaGapThreshold(db), null) }) }) describe('clearSessionIndex', () => { it('removes all sessions and contexts', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 5000 }, ]) generateSessionIndex(db, 2000) - assert.ok(db.tables.segment.length > 0) + assert.ok(countRows(db, 'segment') > 0) clearSessionIndex(db) - assert.equal(db.tables.segment.length, 0) - assert.equal(db.tables.message_context.length, 0) + assert.equal(countRows(db, 'segment'), 0) + assert.equal(countRows(db, 'message_context'), 0) }) }) describe('generateIncrementalSessionIndex', () => { it('returns 0 when no new messages', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [{ id: 1, ts: 1000 }]) generateSessionIndex(db) @@ -397,30 +309,30 @@ describe('generateIncrementalSessionIndex', () => { }) it('creates new sessions for unindexed messages', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [ { id: 1, ts: 1000 }, { id: 2, ts: 1100 }, ]) generateSessionIndex(db, 2000) - assert.equal(db.tables.segment.length, 1) + assert.equal(countRows(db, 'segment'), 1) - db.tables.message.push({ id: 3, ts: 50000 }) + seedMessages(db, [{ id: 3, ts: 50000 }]) const newCount = generateIncrementalSessionIndex(db, 2000) assert.equal(newCount, 1) - assert.equal(db.tables.segment.length, 2) + assert.equal(countRows(db, 'segment'), 2) }) it('appends to existing session when within threshold', () => { - const db = createInMemoryDb() + const db = createSqliteDb() seedMessages(db, [{ id: 1, ts: 1000 }]) generateSessionIndex(db, 2000) - db.tables.message.push({ id: 2, ts: 1500 }) + seedMessages(db, [{ id: 2, ts: 1500 }]) const newCount = generateIncrementalSessionIndex(db, 2000) assert.equal(newCount, 0, 'should not create new session') - assert.equal(db.tables.message_context.length, 2) + assert.equal(countRows(db, 'message_context'), 2) }) })