2026-07-14 16:03:05 +08:00
|
|
|
|
import assert from 'node:assert/strict'
|
2026-07-14 17:16:23 +08:00
|
|
|
|
import { readFileSync } from 'node:fs'
|
2026-07-14 16:03:05 +08:00
|
|
|
|
import test from 'node:test'
|
2026-07-14 17:16:23 +08:00
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-07-14 16:03:05 +08:00
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
|
2026-07-14 17:16:23 +08:00
|
|
|
|
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'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-14 17:16:23 +08:00
|
|
|
|
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')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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',
|
2026-07-14 17:16:23 +08:00
|
|
|
|
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: '已记录本次修正,继续确认后可形成稳定偏好'
|
|
|
|
|
|
}]
|
2026-07-14 16:03:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message),
|
|
|
|
|
|
/动作服务暂不可用/
|
|
|
|
|
|
)
|
2026-07-14 17:16:23 +08:00
|
|
|
|
assert.equal(message.applicationLearningReceipts, undefined)
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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')
|
2026-07-14 17:16:23 +08:00
|
|
|
|
assert.equal(message.applicationLearningReceipts.length, 1)
|
|
|
|
|
|
assert.equal(message.applicationLearningReceipts[0].status, 'candidate')
|
2026-07-14 16:03:05 +08:00
|
|
|
|
assert.equal(emitted[0].status, 'draft')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-14 17:16:23 +08:00
|
|
|
|
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('申请预览 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, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
|
|
|
|
|
|
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]), '偏好已激活')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|