182 lines
7.9 KiB
JavaScript
182 lines
7.9 KiB
JavaScript
|
|
import assert from 'node:assert/strict'
|
|||
|
|
import test from 'node:test'
|
|||
|
|
|
|||
|
|
import {
|
|||
|
|
buildAiDocumentQueryConditionSummary,
|
|||
|
|
buildAiDocumentQueryMessage,
|
|||
|
|
filterAiDocumentQueryRecords,
|
|||
|
|
resolveAiDocumentQueryIntent
|
|||
|
|
} from '../src/utils/aiDocumentQueryModel.js'
|
|||
|
|
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
|
|||
|
|
|
|||
|
|
const today = '2026-06-20'
|
|||
|
|
|
|||
|
|
const claims = [
|
|||
|
|
{
|
|||
|
|
id: 'claim-1',
|
|||
|
|
claim_no: 'CL-20260221001',
|
|||
|
|
document_type_code: 'reimbursement',
|
|||
|
|
expense_type: 'travel',
|
|||
|
|
status: 'submitted',
|
|||
|
|
reason: '上海出差报销',
|
|||
|
|
employee_name: '曹小筑',
|
|||
|
|
department_name: '交付部',
|
|||
|
|
location: '上海',
|
|||
|
|
occurred_at: '2026-02-21T09:00:00Z',
|
|||
|
|
updated_at: '2026-02-22T10:00:00Z',
|
|||
|
|
amount: 1200
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'app-1',
|
|||
|
|
claim_no: 'AP-20260220001',
|
|||
|
|
document_type_code: 'application',
|
|||
|
|
expense_type: 'travel_application',
|
|||
|
|
status: 'approved',
|
|||
|
|
reason: '辅助国网仿生产服务器部署',
|
|||
|
|
employee_name: '曹小筑',
|
|||
|
|
department_name: '交付部',
|
|||
|
|
location: '上海',
|
|||
|
|
occurred_at: '2026-02-20T09:00:00Z',
|
|||
|
|
updated_at: '2026-02-20T10:00:00Z',
|
|||
|
|
amount: 3000
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'claim-2',
|
|||
|
|
claim_no: 'CL-20260305001',
|
|||
|
|
document_type_code: 'reimbursement',
|
|||
|
|
expense_type: 'office',
|
|||
|
|
status: 'draft',
|
|||
|
|
reason: '办公用品采购',
|
|||
|
|
occurred_at: '2026-03-05T09:00:00Z',
|
|||
|
|
amount: 500
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
test('AI document query intent detects my document list questions', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today })
|
|||
|
|
|
|||
|
|
assert.equal(intent?.source, 'mine')
|
|||
|
|
assert.equal(intent?.documentType, 'all')
|
|||
|
|
assert.equal(intent?.sourceLabel, '我的单据')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query intent detects approval document questions', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('我有哪些审核单', { today })
|
|||
|
|
|
|||
|
|
assert.equal(intent?.source, 'approval')
|
|||
|
|
assert.equal(intent?.sourceLabel, '待我审核的单据')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query filters by month and document type', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('我2月有哪些申请单?', { today })
|
|||
|
|
const records = filterAiDocumentQueryRecords(claims, intent)
|
|||
|
|
|
|||
|
|
assert.equal(intent?.documentType, 'application')
|
|||
|
|
assert.equal(intent?.timeRange?.start, '2026-02-01')
|
|||
|
|
assert.equal(intent?.timeRange?.end, '2026-02-28')
|
|||
|
|
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query filters by single day and document type', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('2月20日发生的申请单有哪些?', { today })
|
|||
|
|
const records = filterAiDocumentQueryRecords(claims, intent)
|
|||
|
|
|
|||
|
|
assert.equal(intent?.timeRange?.start, '2026-02-20')
|
|||
|
|
assert.equal(intent?.timeRange?.end, '2026-02-20')
|
|||
|
|
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query combines natural-language filters', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('查一下2月审批中的差旅报销单,金额超过1000,上海相关的单据有哪些?', { today })
|
|||
|
|
const records = filterAiDocumentQueryRecords(claims, intent)
|
|||
|
|
|
|||
|
|
assert.equal(intent?.documentType, 'reimbursement')
|
|||
|
|
assert.equal(intent?.timeRange?.label, '2026年2月')
|
|||
|
|
assert.equal(intent?.statusFilter?.label, '审批中')
|
|||
|
|
assert.equal(intent?.expenseTypeFilter?.label, '差旅费')
|
|||
|
|
assert.equal(intent?.keywordFilter?.label, '上海')
|
|||
|
|
assert.equal(intent?.amountFilter?.min, 1000)
|
|||
|
|
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001'])
|
|||
|
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /状态:审批中/)
|
|||
|
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /费用类型:差旅费/)
|
|||
|
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /关键词:上海/)
|
|||
|
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额:不少于1000元/)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query excludes undated rows when a time condition is present', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
|||
|
|
const records = filterAiDocumentQueryRecords([
|
|||
|
|
...claims,
|
|||
|
|
{
|
|||
|
|
id: 'no-date',
|
|||
|
|
claim_no: 'CL-NO-DATE',
|
|||
|
|
document_type_code: 'reimbursement',
|
|||
|
|
expense_type: 'travel',
|
|||
|
|
status: 'submitted',
|
|||
|
|
reason: '缺少业务日期的单据',
|
|||
|
|
amount: 800
|
|||
|
|
}
|
|||
|
|
], intent)
|
|||
|
|
|
|||
|
|
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001', 'AP-20260220001'])
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query message renders html document cards with detail actions', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
|||
|
|
const message = buildAiDocumentQueryMessage(intent, claims)
|
|||
|
|
|
|||
|
|
assert.match(message, /### 已查询到相关单据/)
|
|||
|
|
assert.match(message, /<!-- ai-trusted-html:start -->/)
|
|||
|
|
assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
|
|||
|
|
// 申请单 app-1 状态为 approved → is-success 语义类
|
|||
|
|
assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
|
|||
|
|
assert.match(message, /<header class="ai-document-card__head">/)
|
|||
|
|
assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/)
|
|||
|
|
assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/)
|
|||
|
|
assert.match(message, /<span class="ai-document-card__owner">曹小筑<\/span>/)
|
|||
|
|
assert.match(message, /<span class="ai-document-card__dept">交付部<\/span>/)
|
|||
|
|
assert.match(message, /<span class="ai-document-card__number">AP-20260220001<\/span>/)
|
|||
|
|
assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/)
|
|||
|
|
assert.match(message, /<div class="ai-document-card__meta">/)
|
|||
|
|
assert.match(message, /<span class="ai-document-card__meta-item">上海<\/span>/)
|
|||
|
|
assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/)
|
|||
|
|
// 报销单 claim-1 状态为 submitted → is-pending 语义类
|
|||
|
|
assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/)
|
|||
|
|
assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/)
|
|||
|
|
assert.doesNotMatch(message, /\| 单据编号 \|/)
|
|||
|
|
assert.doesNotMatch(message, /^> /m)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query html cards render as trusted card markup', () => {
|
|||
|
|
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
|||
|
|
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims))
|
|||
|
|
|
|||
|
|
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
|
|||
|
|
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
|
|||
|
|
assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
|
|||
|
|
assert.match(rendered, /class="ai-document-card__head"/)
|
|||
|
|
assert.match(rendered, /class="ai-document-card__meta"/)
|
|||
|
|
assert.match(rendered, /class="ai-document-card__meta-item"/)
|
|||
|
|
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/)
|
|||
|
|
assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/)
|
|||
|
|
assert.doesNotMatch(rendered, /<section class="ai-document-card-list/)
|
|||
|
|
assert.doesNotMatch(rendered, /<blockquote>/)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('AI document query trusted html rejects unsafe card markup', () => {
|
|||
|
|
const rendered = renderAiConversationHtml([
|
|||
|
|
'### 查询结果',
|
|||
|
|
'',
|
|||
|
|
'<!-- ai-trusted-html:start -->',
|
|||
|
|
'<section class="ai-document-card-list" aria-label="单据查询结果" onclick="alert(1)">',
|
|||
|
|
'<article class="ai-document-card"><script>alert(1)</script></article>',
|
|||
|
|
'</section>',
|
|||
|
|
'<!-- ai-trusted-html:end -->'
|
|||
|
|
].join('\n'))
|
|||
|
|
|
|||
|
|
assert.match(rendered, /<h3 class="ai-html-title">查询结果<\/h3>/)
|
|||
|
|
assert.doesNotMatch(rendered, /ai-document-card-list/)
|
|||
|
|
assert.doesNotMatch(rendered, /<script>/)
|
|||
|
|
assert.doesNotMatch(rendered, /onclick=/)
|
|||
|
|
})
|