import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { markAiWorkbenchConversationDraftDeleted, loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../src/utils/aiWorkbenchConversationStore.js' 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\('该草稿单据已删除,相关财务助手会话已清空。'\)/) }) 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: [ '### 申请草稿已保存', '', '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |', '| --- | --- | --- | --- | --- | --- | --- | --- | --- |', '| 出差申请 | 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) |' ].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) }) 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) 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' \}\)/) assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/) 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) const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。') assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1) assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1) }) 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/) })