test(ai): cover analysis time range precedence

This commit is contained in:
liuyy2 authored and digua committed 2026-09-21 18:45:55 +08:00
1 parent aea7e2540c
commit 600decf148
1 file changed
+190
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict'
import { describe, it, type TestContext } from 'node:test'
import { openTestSqliteDatabase } from '../helpers/sqlite.mts'
import { SqliteTestAdapter } from '../../packages/core/src/query/__tests__/sqlite-test-adapter'
import { CoreDataProvider } from '../../packages/tools/src/providers/core-data-provider'
import { keywordFrequencyTool } from '../../packages/tools/src/definitions/keyword-frequency'
import { responseTimeAnalysisTool } from '../../packages/tools/src/definitions/response-time-analysis'
import { executeToolForAgent, toAgentToolParameters } from '../../packages/tools/src/agent-adapter'
import type { ToolExecutionContext, ToolResult } from '../../packages/tools/src/types'
const historical = { startTs: 1704067200, endTs: 1704067620 }
const explicit = { start_time: '2024-01-01T00:00:00Z', end_time: '2024-01-01T00:07:00Z' }
function createFixture(t: TestContext) {
const db = openTestSqliteDatabase()
t.after(() => db.close())
db.exec(`
CREATE TABLE member (id INTEGER PRIMARY KEY, account_name TEXT, group_nickname TEXT);
CREATE TABLE message (sender_id INTEGER, content TEXT, type INTEGER, ts INTEGER);
INSERT INTO member VALUES (1, 'Alice', NULL), (2, 'Bob', NULL);
`)
const recentStart = (db.prepare("SELECT unixepoch('now', '-14 days') AS ts").get() as { ts: number }).ts
const recent = { startTs: recentStart, endTs: recentStart + 70 }
const insert = db.prepare('INSERT INTO message VALUES (?, ?, 0, ?)')
for (let i = 0; i < 8; i++) {
insert.run((i % 2) + 1, 'historical', historical.startTs + i * 60)
insert.run((i % 2) + 1, 'recent', recent.startTs + i * 10)
}
const context: ToolExecutionContext = {
sessionId: 'test-session',
locale: 'en-US',
dataProvider: new CoreDataProvider(new SqliteTestAdapter(db)),
segmentText(texts, _locale, options) {
const words = new Map<string, number>()
for (const text of texts) words.set(text, (words.get(text) ?? 0) + 1)
return {
words: new Map(
[...words].filter(([, count]) => count >= Number(options.minCount)).slice(0, Number(options.topN))
),
uniqueWords: words.size,
totalWords: texts.length,
}
},
}
return { db, insert, context, recent }
}
function assertAnalysis(result: ToolResult, keyword: string, interval: string, period: string, topN = 2) {
assert.ok(result.data, result.content)
assert.deepEqual(JSON.parse(result.content), result.data)
const data = result.data as Record<string, unknown>
if ('keywords' in data) {
assert.equal(data.totalMessages, 8)
assert.equal(data.totalKeywords, 1)
assert.deepEqual(data.keywords, [`1. ${keyword} (8)`])
} else {
assert.equal(data.totalResponders, topN)
assert.deepEqual(
data.ranking,
[
`1. Bob — median ${interval}, avg ${interval} (4 responses)`,
`2. Alice — median ${interval}, avg ${interval} (3 responses)`,
].slice(0, topN)
)
}
assert.equal(data.period, period)
}
function rangePeriod(range: typeof historical, locale = 'en-US') {
return `${new Date(range.startTs * 1000).toLocaleString(locale)} – ${new Date(range.endTs * 1000).toLocaleString(locale)}`
}
for (const tool of [keywordFrequencyTool, responseTimeAnalysisTool]) {
describe(tool.name, () => {
for (const params of [{}, { days: 7 }, { start_time: 'invalid', end_time: 'invalid' }]) {
it(`inherits the selected historical range with ${JSON.stringify(params)}`, async (t) => {
const { db, context } = createFixture(t)
context.timeFilter = historical
assertAnalysis(await tool.handler(params, context), 'historical', '1m0s', rangePeriod(historical))
db.prepare('DELETE FROM message WHERE ts > ?').run(historical.endTs)
assertAnalysis(await tool.handler(params, context), 'historical', '1m0s', rangePeriod(historical))
})
}
it('explicit dates override the selected range and days, including both endpoints', async (t) => {
const { insert, context, recent } = createFixture(t)
context.timeFilter = recent
insert.run(2, 'outside', historical.startTs - 60)
insert.run(2, 'outside', historical.startTs - 1)
insert.run(1, 'outside', historical.endTs + 1)
insert.run(2, 'outside', historical.endTs + 60)
const result = await tool.handler({ ...explicit, days: 7, top_n: 1 }, context)
assertAnalysis(result, 'historical', '1m0s', rangePeriod(historical), 1)
})
it('retains default days, custom days and invalid-date fallback without a selected range', async (t) => {
const { context } = createFixture(t)
for (const params of [{}, { days: 30 }, { start_time: 'invalid' }, { start_time: '', end_time: '' }]) {
assertAnalysis(await tool.handler(params, context), 'recent', '10s', 'Last 30 days')
}
assert.equal((await tool.handler({ days: 7 }, context)).data, null)
assertAnalysis(await tool.handler({ days: 20 }, context), 'recent', '10s', 'Last 20 days')
})
it('uses the shared parser defaults for one-sided dates', async (t) => {
const { context, recent } = createFixture(t)
context.timeFilter = recent
const result = await tool.handler({ end_time: explicit.end_time }, context)
assertAnalysis(result, 'historical', '1m0s', rangePeriod({ startTs: 0, endTs: historical.endTs }))
context.timeFilter = historical
const before = Math.floor(Date.now() / 1000)
const startOnly = await tool.handler({ start_time: new Date(recent.startTs * 1000).toISOString() }, context)
const after = Math.floor(Date.now() / 1000)
const period = (startOnly.data as { period: string }).period
assert.ok(
Array.from({ length: after - before + 1 }, (_, i) => rangePeriod({ ...recent, endTs: before + i })).includes(
period
)
)
assertAnalysis(startOnly, 'recent', '10s', period)
})
it('returns no data for empty or reversed ranges', async (t) => {
const { context } = createFixture(t)
for (const timeFilter of [
{ startTs: historical.startTs - 10, endTs: historical.startTs - 1 },
{ startTs: historical.endTs, endTs: historical.startTs },
]) {
assert.equal((await tool.handler({}, { ...context, timeFilter })).data, null)
}
})
it('keeps Chinese period labels and content/data consistent', async (t) => {
const { context } = createFixture(t)
context.locale = 'zh-CN'
for (const timeFilter of [historical, undefined]) {
const result = await tool.handler({}, { ...context, timeFilter })
assert.deepEqual(JSON.parse(result.content), result.data)
assert.equal(
(result.data as { period: string }).period,
timeFilter ? rangePeriod(historical, 'zh-CN') : '近30天'
)
}
})
it('exposes optional dates and returns historical results through the shared Agent adapter', async (t) => {
const { context } = createFixture(t)
const schema = toAgentToolParameters(tool.inputSchema)
assert.deepEqual(schema.required, [])
for (const key of ['start_time', 'end_time']) {
assert.equal((schema.properties[key] as { type: string }).type, 'string')
}
const result = await executeToolForAgent(tool, { days: 7 }, { ...context, timeFilter: historical })
assertAnalysis(
{ content: result.content[0].text, data: result.details },
'historical',
'1m0s',
rangePeriod(historical)
)
assert.notEqual(result.isError, true)
})
it('preserves database failures and the existing adapter error result', async (t) => {
const { db, context } = createFixture(t)
db.exec('DROP TABLE message')
await assert.rejects(async () => tool.handler({}, context), /no such table: message/)
const result = await executeToolForAgent(tool, {}, context)
assert.equal(result.isError, true)
assert.equal(result.details, null)
assert.match(result.content[0].text, /no such table: message/)
})
})
}
it('keyword_frequency preserves its missing-segmentation result', async (t) => {
const { context } = createFixture(t)
assert.equal((await keywordFrequencyTool.handler({}, { ...context, segmentText: undefined })).data, null)
})
it('response_time_analysis does not borrow preceding messages from outside the selected range', async (t) => {
const { context } = createFixture(t)
const result = await responseTimeAnalysisTool.handler(
{},
{
...context,
timeFilter: { startTs: historical.startTs + 180, endTs: historical.endTs },
}
)
assert.equal(result.data, null)
})