2026-05-22 16:00:19 +08:00
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import test from 'node:test'
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
|
2026-06-20 21:44:16 +08:00
|
|
|
import {
|
|
|
|
|
markAiWorkbenchConversationDraftDeleted,
|
2026-06-24 22:59:05 +08:00
|
|
|
markAiWorkbenchConversationDocumentDeleted,
|
2026-06-20 21:44:16 +08:00
|
|
|
loadAiWorkbenchConversationHistory,
|
|
|
|
|
saveAiWorkbenchConversation
|
|
|
|
|
} from '../src/utils/aiWorkbenchConversationStore.js'
|
2026-05-22 16:00:19 +08:00
|
|
|
import {
|
|
|
|
|
clearAssistantSessionSnapshotForDraftClaim,
|
|
|
|
|
readAssistantSessionSnapshot,
|
|
|
|
|
writeAssistantSessionSnapshot
|
|
|
|
|
} from '../src/utils/assistantSessionSnapshot.js'
|
|
|
|
|
|
|
|
|
|
function installWindowStub() {
|
|
|
|
|
const store = new Map()
|
|
|
|
|
const events = []
|
|
|
|
|
|
|
|
|
|
globalThis.CustomEvent = class CustomEvent {
|
|
|
|
|
constructor(type, options = {}) {
|
|
|
|
|
this.type = type
|
|
|
|
|
this.detail = options.detail || {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
globalThis.window = {
|
|
|
|
|
localStorage: {
|
|
|
|
|
getItem(key) {
|
|
|
|
|
return store.has(key) ? store.get(key) : null
|
|
|
|
|
},
|
|
|
|
|
setItem(key, value) {
|
|
|
|
|
store.set(key, String(value))
|
|
|
|
|
},
|
|
|
|
|
removeItem(key) {
|
|
|
|
|
store.delete(key)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
dispatchEvent(event) {
|
|
|
|
|
events.push(event)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { events }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('assistant snapshot is cleared only when it belongs to the deleted draft claim', () => {
|
|
|
|
|
const { events } = installWindowStub()
|
|
|
|
|
|
|
|
|
|
writeAssistantSessionSnapshot('emp-1', 'expense', {
|
|
|
|
|
draftClaimId: 'claim-1',
|
|
|
|
|
messages: [{ role: 'assistant', text: '已保存草稿 EXP-001' }]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert.equal(clearAssistantSessionSnapshotForDraftClaim('emp-1', 'claim-2', 'expense'), false)
|
|
|
|
|
assert.equal(readAssistantSessionSnapshot('emp-1', 'expense')?.state?.draftClaimId, 'claim-1')
|
|
|
|
|
|
|
|
|
|
assert.equal(clearAssistantSessionSnapshotForDraftClaim('emp-1', 'claim-1', 'expense'), true)
|
|
|
|
|
assert.equal(readAssistantSessionSnapshot('emp-1', 'expense'), null)
|
|
|
|
|
assert.equal(events.at(-1)?.detail?.action, 'clear')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('claim delete flow invalidates the matching financial assistant session', () => {
|
|
|
|
|
const appShellScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
const appShellRouteView = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
const createViewScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert.match(appShellScript, /clearAssistantSessionSnapshotForDraftClaim/)
|
|
|
|
|
assert.match(appShellScript, /async function handleRequestDeleted\(payload = \{\}\)/)
|
|
|
|
|
assert.match(appShellScript, /smartEntryInvalidatedDraftClaimId\.value = deletedClaimId/)
|
|
|
|
|
assert.match(appShellRouteView, /:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"/)
|
|
|
|
|
assert.match(createViewScript, /invalidatedDraftClaimId/)
|
|
|
|
|
assert.match(createViewScript, /function clearExpenseSessionForDeletedClaim\(claimId\)/)
|
|
|
|
|
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-20 21:44:16 +08:00
|
|
|
test('deleting an application draft marks AI workbench detail links as unavailable', () => {
|
|
|
|
|
installWindowStub()
|
|
|
|
|
const user = { username: 'zhangsan@example.com' }
|
|
|
|
|
|
|
|
|
|
saveAiWorkbenchConversation(user, {
|
|
|
|
|
id: 'conversation-application-draft',
|
|
|
|
|
title: '申请草稿',
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
id: 'assistant-draft-saved',
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
content: [
|
|
|
|
|
'### 申请草稿已保存',
|
|
|
|
|
'',
|
2026-06-21 22:49:58 +08:00
|
|
|
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |',
|
|
|
|
|
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
|
|
|
|
|
'| 出差申请 | AP-20260620-DRAFT | 草稿 | 待提交 | 2026-02-20 至 2026-02-23 | 上海 | 辅助国网仿生产服务器部署 | ¥2,600.00 | [查看](#ai-open-application-detail:claim_id%3Dclaim-draft-1%26claim_no%3DAP-20260620-DRAFT) |'
|
2026-06-20 21:44:16 +08:00
|
|
|
].join('\n')
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const nextHistory = markAiWorkbenchConversationDraftDeleted(user, {
|
|
|
|
|
claimId: 'claim-draft-1',
|
|
|
|
|
claimNo: 'AP-20260620-DRAFT'
|
|
|
|
|
})
|
|
|
|
|
const conversation = nextHistory.find((item) => item.id === 'conversation-application-draft')
|
|
|
|
|
|
|
|
|
|
assert.ok(conversation)
|
|
|
|
|
assert.match(conversation.messages[0].content, /#ai-deleted-application-detail:/)
|
|
|
|
|
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-application-detail:/)
|
|
|
|
|
assert.match(conversation.messages.at(-1).content, /用户已经删除了草稿单据 AP-20260620-DRAFT/)
|
|
|
|
|
assert.match(conversation.messages.at(-1).content, /草稿单据已经删除,请重新再次申请。/)
|
|
|
|
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-24 22:59:05 +08:00
|
|
|
test('deleted document detail links are disabled for reopened AI conversations', () => {
|
|
|
|
|
installWindowStub()
|
|
|
|
|
const user = { username: 'zhangsan@example.com' }
|
|
|
|
|
|
|
|
|
|
saveAiWorkbenchConversation(user, {
|
|
|
|
|
id: 'conversation-document-delete-candidate',
|
|
|
|
|
title: '删除申请单草稿',
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
id: 'assistant-document-candidates',
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
content: [
|
|
|
|
|
'### 已查询到相关单据',
|
|
|
|
|
'',
|
|
|
|
|
'[进入详情确认删除](#ai-open-document-detail:claim_id%3Dclaim-draft-2%26claim_no%3DAP-DRAFT-002)'
|
|
|
|
|
].join('\n')
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const nextHistory = markAiWorkbenchConversationDocumentDeleted(user, {
|
|
|
|
|
claimId: 'claim-draft-2',
|
|
|
|
|
claimNo: 'AP-DRAFT-002'
|
|
|
|
|
})
|
|
|
|
|
const conversation = nextHistory.find((item) => item.id === 'conversation-document-delete-candidate')
|
|
|
|
|
|
|
|
|
|
assert.ok(conversation)
|
|
|
|
|
assert.match(conversation.messages[0].content, /#ai-deleted-document-detail:/)
|
|
|
|
|
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-document-detail:/)
|
|
|
|
|
assert.match(conversation.messages[0].content, /\[单据已删除\]/)
|
|
|
|
|
assert.match(conversation.messages.at(-1).content, /单据 AP-DRAFT-002 已经删除或不可访问/)
|
|
|
|
|
assert.match(conversation.messages.at(-1).content, /该历史入口已失效,请返回单据列表重新查询/)
|
|
|
|
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('AI workbench validates document detail links before opening stale history entries', () => {
|
|
|
|
|
const aiMode = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert.match(aiMode, /fetchExpenseClaimDetail/)
|
|
|
|
|
assert.match(aiMode, /markAiWorkbenchConversationDocumentDeleted/)
|
|
|
|
|
assert.match(aiMode, /async function handleAiAnswerMarkdownClick/)
|
|
|
|
|
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable/)
|
|
|
|
|
assert.match(aiMode, /已将这条历史入口标记为不可查看/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
test('saving a draft keeps the financial assistant open for continued work', () => {
|
|
|
|
|
const appShellScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
const handleDraftSavedBlock = appShellScript.match(
|
|
|
|
|
/async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*?\r?\n \}\r?\n\r?\n function openRequestDetail/
|
|
|
|
|
)?.[0]
|
|
|
|
|
|
|
|
|
|
assert.ok(handleDraftSavedBlock)
|
2026-05-30 15:46:51 +08:00
|
|
|
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*if \(isApplicationDocument\) \{[\s\S]*return/)
|
|
|
|
|
assert.match(handleDraftSavedBlock, /smartEntryOpen\.value = false[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
|
2026-05-22 16:00:19 +08:00
|
|
|
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
const applicationSubmittedIndex = handleDraftSavedBlock.indexOf('if (isApplicationDocument)')
|
|
|
|
|
const applicationSubmittedReturnIndex = handleDraftSavedBlock.indexOf('return', applicationSubmittedIndex)
|
|
|
|
|
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
|
|
|
|
|
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
|
|
|
|
|
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
|
2026-05-26 09:15:14 +08:00
|
|
|
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)
|
2026-05-22 16:00:19 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
|
|
|
|
const detailViewScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
const appShellScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
const sessionStateScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
const submitComposerScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert.match(detailViewScript, /restoreLatestConversation:\s*false/)
|
|
|
|
|
assert.match(detailViewScript, /scope:\s*claimId[\s\S]*type:\s*'claim'[\s\S]*claimId/)
|
|
|
|
|
assert.match(appShellScript, /function isDetailClaimScopedPayload\(payload = \{\}\)/)
|
|
|
|
|
assert.match(appShellScript, /if \(isDetailClaimScopedPayload\(payload\)\) \{[\s\S]*return null[\s\S]*\}/)
|
|
|
|
|
assert.match(sessionStateScript, /const shouldPersistLocalSnapshot = props\.entrySource !== 'detail'/)
|
|
|
|
|
assert.match(sessionStateScript, /if \(!shouldPersistLocalSnapshot\) \{[\s\S]*return[\s\S]*\}/)
|
|
|
|
|
assert.match(submitComposerScript, /function resolveDetailScopedClaimId\(\)/)
|
|
|
|
|
assert.match(submitComposerScript, /const detailScopedUpload = Boolean\(detailScopedClaimId && files\.length\)/)
|
|
|
|
|
assert.match(submitComposerScript, /draft_claim_id: detailScopedClaimId/)
|
|
|
|
|
assert.match(submitComposerScript, /detail_scope_claim_id: detailScopedClaimId/)
|
|
|
|
|
assert.match(submitComposerScript, /detailScopedUpload/)
|
|
|
|
|
})
|