Files
X-Financial/web/tests/financial-assistant-application-preview-actions.test.mjs

617 lines
21 KiB
JavaScript
Raw 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 { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
useTravelReimbursementApplicationPreviewActions
} from '../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js'
import {
normalizeSnapshotMessage,
serializeSessionMessages
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
)
const memoryPanelTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMemoryPanel.vue', import.meta.url)),
'utf8'
)
const createViewUiScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewUi.js', import.meta.url)),
'utf8'
)
function createPreview(overrides = {}) {
return {
fields: {
applicationType: '差旅费用申请',
time: '2026-07-20 至 2026-07-22',
location: '上海',
reason: '客户现场实施',
days: '3天',
transportMode: '火车',
amount: '1800元'
},
...overrides
}
}
function createActions(overrides = {}) {
const persisted = []
const emitted = []
const toasts = []
const state = {
conversationId: { value: 'conversation-financial-assistant' },
currentUser: { value: { username: 'zhangsan', name: '张三' } },
draftClaimId: { value: '' },
linkedRequest: { value: null },
reviewActionBusy: { value: false },
sessionSwitchBusy: { value: false },
submitting: { value: false },
emitDraftSaved: (payload) => emitted.push(payload),
entrySource: 'application',
persistSessionState: () => persisted.push(Date.now()),
toast: (message) => toasts.push(message),
...overrides
}
return {
actions: useTravelReimbursementApplicationPreviewActions(state),
emitted,
persisted,
state,
toasts
}
}
test('完整 preview 展示前写入稳定签发/动作 request id并使用 canonical decision', async () => {
const targetMessage = { id: 'preview-1', applicationPreview: null }
const { actions, persisted } = createActions()
let pendingIssueRequestId = ''
let pendingActionRequestId = ''
global.fetch = async (_url, options) => {
const body = JSON.parse(options.body)
pendingIssueRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionIssueRequestId
pendingActionRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionActionRequestId
assert.equal(body.request_id, pendingIssueRequestId)
assert.ok(pendingActionRequestId.startsWith('action:'))
assert.equal(targetMessage.applicationPreview, null)
return {
ok: true,
async json() {
return {
decision_id: 'decision-assistant-1',
decision_source: 'rule',
expires_at: '2026-07-14T10:30:00Z',
application_preview: {
fields: {
reason: '服务端规范事由',
location: '上海'
}
}
}
}
}
}
const preview = await actions.registerApplicationPreviewDecision({
applicationPreview: createPreview(),
sourceText: '7月20日去上海做客户现场实施坐火车',
targetMessage
})
assert.equal(preview.decisionId, 'decision-assistant-1')
assert.equal(preview.decisionTrackingStatus, 'registered')
assert.equal(preview.fields.reason, '服务端规范事由')
assert.equal(preview.decisionIssueRequestId, pendingIssueRequestId)
assert.equal(preview.decisionActionRequestId, pendingActionRequestId)
assert.equal(targetMessage.pendingApplicationPreviewDecision, undefined)
assert.ok(persisted.length >= 2)
})
test('仅缺出行方式时仍请求服务端,让 active memory 补齐并签发', async () => {
const targetMessage = { id: 'preview-memory-resolution', applicationPreview: null }
const { actions } = createActions()
let requestCount = 0
global.fetch = async (_url, options) => {
requestCount += 1
const body = JSON.parse(options.body)
assert.match(body.message, /客户现场实施/)
return {
ok: true,
async json() {
return {
decision_id: 'decision-memory-resolution',
decision_source: 'hybrid',
expires_at: '2026-07-14T10:30:00Z',
application_preview: {
fields: {
...createPreview().fields,
transportMode: '火车',
amount: '2880元'
},
memoryApplications: [{
memory_id: 'memory-transport-active',
field_key: 'transport_mode',
value: '火车',
status: 'applied',
evidence_count: 3,
approved_evidence_count: 2
}]
}
}
}
}
}
const preview = await actions.registerApplicationPreviewDecision({
applicationPreview: createPreview({
fields: {
...createPreview().fields,
transportMode: ''
}
}),
sourceText: '7月20日去上海做客户现场实施',
targetMessage
})
assert.equal(requestCount, 1)
assert.equal(preview.readyToSubmit, true)
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.decisionId, 'decision-memory-resolution')
assert.equal(preview.memoryApplications[0].memoryId, 'memory-transport-active')
})
test('签发失败后 fail-closed可编辑并以相同 request id 重试签发', async () => {
const targetMessage = { id: 'preview-2', applicationPreview: null }
const { actions } = createActions()
const requestIds = []
let attempt = 0
global.fetch = async (_url, options) => {
attempt += 1
requestIds.push(JSON.parse(options.body).request_id)
if (attempt === 1) {
return {
ok: false,
status: 503,
async json() {
return { detail: '签发服务暂不可用' }
}
}
}
return {
ok: true,
async json() {
return {
decision_id: 'decision-assistant-retry',
application_preview: { fields: { reason: '服务端规范事由' } }
}
}
}
}
const failed = await actions.registerApplicationPreviewDecision({
applicationPreview: createPreview(),
sourceText: '去上海做客户现场实施',
targetMessage
})
assert.equal(failed.decisionTrackingStatus, 'registration_failed')
assert.equal(failed.decisionId, '')
assert.equal(actions.canSaveApplicationPreviewDraft(targetMessage), false)
await assert.rejects(
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, targetMessage),
/尚未通过服务端签发/
)
targetMessage.applicationPreview.fields.reason = '用户修正后的事由'
const retried = await actions.retryApplicationPreviewDecision(targetMessage)
assert.equal(retried, true)
assert.equal(requestIds[1], requestIds[0])
assert.equal(targetMessage.applicationPreview.fields.reason, '用户修正后的事由')
assert.equal(targetMessage.applicationPreview.decisionId, 'decision-assistant-retry')
})
test('动作失败重试复用 action request id保存成功更新草稿和 next decision', async () => {
const message = {
id: 'preview-3',
applicationPreview: createPreview({
decisionId: 'decision-assistant-action',
decisionTrackingStatus: 'registered',
decisionActionRequestId: 'action:stable-retry'
}),
draftPayload: null
}
const { actions, emitted, state } = createActions()
const actionBodies = []
let attempt = 0
global.fetch = async (_url, options) => {
attempt += 1
actionBodies.push(JSON.parse(options.body))
if (attempt === 1) {
return {
ok: false,
status: 503,
async json() {
return { detail: '动作服务暂不可用' }
}
}
}
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-assistant-draft',
claim_no: 'AP-20260714-001',
approval_stage: '待提交'
},
decision_id: 'decision-assistant-next',
decision_expires_at: '2026-07-14T11:00:00Z',
learning_receipts: [{
memoryId: 'memory-reason-candidate',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
message: '已记录本次修正,继续确认后可形成稳定偏好'
}]
}
}
}
}
}
await assert.rejects(
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message),
/动作服务暂不可用/
)
assert.equal(message.applicationLearningReceipts, undefined)
assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
await actions.saveApplicationPreviewDraft(message)
assert.equal(actionBodies[0].request_id, 'action:stable-retry')
assert.equal(actionBodies[1].request_id, 'action:stable-retry')
assert.equal(actionBodies[1].context_json.entry_source, 'application')
assert.equal(actionBodies[1].context_json.source, 'financial_assistant')
assert.equal(message.draftPayload.claim_id, 'claim-assistant-draft')
assert.equal(state.draftClaimId.value, 'claim-assistant-draft')
assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next')
assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
assert.equal(message.applicationLearningReceipts.length, 1)
assert.equal(message.applicationLearningReceipts[0].status, 'candidate')
assert.equal(emitted[0].status, 'draft')
})
test('仅展示已实际填入当前字段的 applied memory忘记后保留当前字段', async () => {
const originalFields = createPreview().fields
const message = {
id: 'preview-memory',
applicationPreview: createPreview({
memoryApplications: [
{
memoryId: 'memory-transport-applied',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
evidenceCount: 4,
approvedEvidenceCount: 3,
message: '根据已审批的历史差旅申请填入'
},
{
memoryId: 'memory-reason-candidate',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
message: '候选偏好'
},
{
memoryId: 'memory-location-stale',
fieldKey: 'location',
fieldLabel: '出差地点',
value: '北京',
status: 'applied',
evidenceCount: 3,
approvedEvidenceCount: 2,
message: '旧偏好值与当前字段不一致'
}
]
})
}
let confirmCalls = 0
let capturedUrl = ''
const { actions, persisted, toasts } = createActions({
confirmForgetMemory: async () => {
confirmCalls += 1
return true
}
})
assert.deepEqual(
actions.resolveAppliedApplicationPreviewMemories(message).map((item) => item.memoryId),
['memory-transport-applied']
)
global.fetch = async (url, options) => {
capturedUrl = String(url)
assert.equal(options.method, 'DELETE')
return {
ok: true,
async json() {
return { status: 'forgotten' }
}
}
}
const forgotten = await actions.forgetApplicationPreviewMemory(
message,
message.applicationPreview.memoryApplications[0]
)
assert.equal(forgotten, true)
assert.equal(confirmCalls, 1)
assert.equal(
capturedUrl,
'/api/v1/expense-application-memories/memory-transport-applied'
)
assert.deepEqual(message.applicationPreview.fields, originalFields)
assert.deepEqual(
message.applicationPreview.memoryApplications.map((item) => item.memoryId),
['memory-reason-candidate', 'memory-location-stale']
)
assert.ok(persisted.length >= 1)
assert.match(toasts.at(-1), /当前申请内容保持不变/)
})
test('忘记偏好失败时不移除 memory也不改当前字段', async () => {
const message = {
id: 'preview-memory-failure',
applicationPreview: createPreview({
memoryApplications: [{
memoryId: 'memory-failure',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'applied'
}]
})
}
const previousPreview = structuredClone(message.applicationPreview)
const { actions, toasts } = createActions({
confirmForgetMemory: () => true
})
global.fetch = async () => ({
ok: false,
status: 503,
async json() {
return { detail: '偏好服务暂不可用' }
}
})
const forgotten = await actions.forgetApplicationPreviewMemory(
message,
message.applicationPreview.memoryApplications[0]
)
assert.equal(forgotten, false)
assert.deepEqual(message.applicationPreview, previousPreview)
assert.match(toasts.at(-1), /偏好服务暂不可用/)
})
test('组织记忆不允许普通用户调用忘记接口', async () => {
const organizationMemory = {
memoryId: 'memory-enterprise-policy',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
scopeType: 'enterprise',
canRevoke: false
}
const message = {
id: 'preview-enterprise-memory',
applicationPreview: createPreview({ memoryApplications: [organizationMemory] })
}
const { actions, toasts } = createActions()
let requestCount = 0
global.fetch = async () => {
requestCount += 1
throw new Error('不应发起组织记忆删除请求')
}
const forgotten = await actions.forgetApplicationPreviewMemory(message, organizationMemory)
assert.equal(forgotten, false)
assert.equal(requestCount, 0)
assert.equal(message.applicationPreview.memoryApplications.length, 1)
assert.match(toasts.at(-1), /管理员统一维护/)
})
test('申请预览 UI 解释分层记忆,仅个人记忆提供忘记入口', () => {
assert.match(messageItemTemplate, /<TravelReimbursementMemoryPanel :message="message" :ui="ui" \/>/)
assert.match(memoryPanelTemplate, /resolveAppliedApplicationPreviewMemories\(props\.message\)/)
assert.match(memoryPanelTemplate, /企业制度填充/)
assert.match(memoryPanelTemplate, /部门基线填充/)
assert.match(memoryPanelTemplate, /个性化填充/)
assert.match(memoryPanelTemplate, /您仍可直接修改上方字段/)
assert.match(memoryPanelTemplate, /忘记此偏好/)
assert.match(memoryPanelTemplate, /v-if="canForgetMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /memory\.scopeType === 'user' && memory\.canRevoke === true/)
assert.match(memoryPanelTemplate, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /检测到同层级设置冲突/)
assert.doesNotMatch(memoryPanelTemplate, /JSON\.stringify\(memory\.conflicts/)
assert.match(memoryPanelTemplate, /resolveVisibleApplicationLearningReceipts\(props\.message\)/)
assert.match(memoryPanelTemplate, /ui\.resolveApplicationLearningReceiptTitle\(receipt\)/)
assert.doesNotMatch(messageItemTemplate, /已按历史偏好填入/)
assert.match(createViewUiScript, /forgetApplicationPreviewMemory: ctx\.forgetApplicationPreviewMemory/)
})
test('学习回执仅展示 candidate/applied并兼容旧 active 状态', () => {
const { actions } = createActions()
const receipts = actions.resolveVisibleApplicationLearningReceipts({
applicationLearningReceipts: [
{
memoryId: 'memory-candidate',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate'
},
{
memoryId: 'memory-active-legacy',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'active'
},
{
memoryId: 'memory-revoked',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '飞机',
status: 'revoked'
}
]
})
assert.deepEqual(receipts.map((item) => item.status), ['candidate', 'applied'])
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[0]), '已记录为候选偏好')
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[1]), '偏好已激活')
})
test('decision 过期后清空旧 ID 并开放重新签发', async () => {
const message = {
id: 'preview-expired',
applicationPreview: createPreview({
decisionId: 'decision-expired',
decisionTrackingStatus: 'registered',
decisionIssueRequestId: 'preview:old',
decisionActionRequestId: 'action:expired'
})
}
const { actions } = createActions()
global.fetch = async () => ({
ok: false,
status: 409,
async json() {
return { detail: '该预览决策已过期,请重新生成预览。' }
}
})
await assert.rejects(
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, message),
/已过期/
)
assert.equal(message.applicationPreview.decisionId, '')
assert.equal(message.applicationPreview.decisionTrackingStatus, 'registration_failed')
assert.notEqual(message.applicationPreview.decisionIssueRequestId, 'preview:old')
assert.equal(message.applicationPreview.decisionActionRequestId, '')
assert.equal(actions.canRetryApplicationPreviewDecision(message), true)
})
test('本地快照保留签发与动作 request id并把中断中的签发恢复为可重试状态', () => {
const pending = createPreview({
decisionIssueRequestId: 'preview:recover',
decisionActionRequestId: 'action:recover',
decisionTrackingStatus: 'registering'
})
const serialized = serializeSessionMessages([{
id: 'preview-4',
role: 'assistant',
text: '正在签发',
attachments: [],
pendingApplicationPreviewDecision: pending
}])
assert.equal(
serialized[0].pendingApplicationPreviewDecision.decisionIssueRequestId,
'preview:recover'
)
const restored = normalizeSnapshotMessage(serialized[0])
assert.equal(restored.applicationPreview.decisionIssueRequestId, 'preview:recover')
assert.equal(restored.applicationPreview.decisionActionRequestId, 'action:recover')
assert.equal(restored.applicationPreview.decisionTrackingStatus, 'registration_failed')
})
function createSubmitConfirm({ message, runApplicationPreviewAction, submitComposer }) {
const applicationSubmitConfirmDialog = { value: { open: true, message } }
const reviewActionBusy = { value: false }
const toasts = []
const api = useTravelReimbursementApplicationSubmitConfirm({
activeSessionType: { value: 'application' },
applicationSubmitConfirmDialog,
buildStewardFieldItems: () => [],
createMessage: (role, text, _attachments, extras = {}) => ({ role, text, ...extras }),
emitDraftSaved: () => {},
formatStewardMissingFieldList: () => '',
formatStewardOntologyFields: () => '',
linkedRequest: { value: null },
messages: { value: [] },
nextTick: (callback) => callback?.(),
persistSessionState: () => {},
reviewActionBusy,
runApplicationPreviewAction,
scrollToBottom: () => {},
submitComposer,
submitting: { value: false },
toast: (text) => toasts.push(text)
})
return { api, toasts }
}
test('structured preview 只走认证 action未签发失败时也不降级到 Orchestrator', async () => {
let actionCalls = 0
let orchestratorCalls = 0
const unsignedMessage = {
applicationPreview: createPreview({
decisionTrackingStatus: 'registration_failed',
decisionId: ''
})
}
const { api, toasts } = createSubmitConfirm({
message: unsignedMessage,
runApplicationPreviewAction: async () => {
actionCalls += 1
throw new Error('申请预览尚未通过服务端签发')
},
submitComposer: async () => {
orchestratorCalls += 1
return null
}
})
await api.confirmApplicationSubmit()
assert.equal(actionCalls, 1)
assert.equal(orchestratorCalls, 0)
assert.match(toasts[0], /尚未通过服务端签发/)
})
test('旧消息没有 structured preview 时保留 Orchestrator fallback', async () => {
let actionCalls = 0
let orchestratorCalls = 0
const { api } = createSubmitConfirm({
message: { text: '旧版确认消息' },
runApplicationPreviewAction: async () => {
actionCalls += 1
return null
},
submitComposer: async () => {
orchestratorCalls += 1
return { status: 'succeeded', result: {} }
}
})
await api.confirmApplicationSubmit()
assert.equal(actionCalls, 0)
assert.equal(orchestratorCalls, 1)
})