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('申请预览 UI 解释已应用偏好并提供可访问的忘记入口', () => { assert.match(messageItemTemplate, //) 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]), '偏好已激活') }) 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) })