Files
ChatLab/tests/tools/analysis-time-range.test.ts

191 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})