import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' import { parse, compileTemplate } from '@vue/compiler-sfc' import ts from 'typescript' import { ref, reactive, computed, proxyRefs } from 'vue' const root = new URL('../../', import.meta.url) const fields = [ 'canManageFinanceRemark', 'financeRemarkVisible', 'financeRemarkLoading', 'financeRemarkSaving', 'financeRemarkText', 'openFinanceRemark', 'saveFinanceRemark', 'cancelFinanceRemark', 'clearFinanceAccess', 'load', 'rows' ] function evaluate(source, deps, tail = '') { const withoutImports = source.replace(/import[\s\S]*?from\s*['"][^'"]+['"]/g, '') const js = ts.transpileModule(withoutImports + tail, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const exports = {} return ( new Function(...Object.keys(deps), 'exports', js)(...Object.values(deps), exports) || exports.default || exports ) } function harness(platform, overrides = {}) { const calls = [] const hooks = {} const api = { list: { list: [{ id: 'task-1', instanceId: 'instance-1', financeRemark: '列表旧值' }], total: 1, canManageFinanceRemark: true }, get: async () => ({ financeRemark: '最新备注' }), put: async () => ({ financeRemark: '' }) } const deps = { ref, reactive, computed, defineOptions() {}, watchDebounced() {}, onMounted() {}, onActivated() {}, onDeactivated(fn) { hooks.hide = fn }, useRouter: () => ({}), useMenuPerm: () => ({ hasMenuPerm: () => false }), useUserStore: () => ({}), ElMessage: { success() {}, error() {}, warning() {} }, uni: { showToast() {}, navigateTo() {}, getSystemInfoSync: () => ({ windowHeight: 800 }) }, VanEmpty: {}, getToken: () => 'token', forceRelogin() {}, fetchOaTasks: async () => { calls.push(['list']) return api.list }, fetchOaFinanceRemark: async (id) => { calls.push(['get', id]) return api.get(id) }, fetchUpdateOaFinanceRemark: async (id, text) => { calls.push(['put', id, text]) return api.put(id, text) }, ...overrides } const path = platform === 'web' ? 'web-admin/src/views/oa-approval/done/index.vue' : 'mobile-h5/pages/oa/done/index.vue' const { descriptor } = parse(readFileSync(new URL(path, root), 'utf8')) let vm if (platform === 'web') { vm = proxyRefs( evaluate( descriptor.scriptSetup.content, deps, '\nreturn {' + fields.map((k) => `${k}: typeof ${k} === 'undefined' ? undefined : ${k}`).join(',') + '}' ) ) } else { const options = evaluate(descriptor.script.content, deps) vm = options.data() for (const [key, fn] of Object.entries(options.methods)) vm[key] = fn.bind(vm) hooks.hide = () => options.onHide?.call(vm) } const load = () => (platform === 'web' ? vm.load() : vm.loadList(true)) const rows = () => (platform === 'web' ? vm.rows : vm.list) return { vm, calls, api, load, rows, hooks, descriptor } } for (const platform of ['web', 'mobile']) { test(`${platform}: capability gates access and opening gets latest instance value`, async () => { const h = harness(platform) await h.load() assert.equal(h.vm.canManageFinanceRemark, true) await h.vm.openFinanceRemark(h.rows()[0]) assert.deepEqual(h.calls.at(-1), ['get', 'instance-1']) assert.equal(h.vm.financeRemarkText, '最新备注') assert.equal(h.vm.financeRemarkVisible, true) }) test(`${platform}: cancellation makes no write and discards late GET`, async () => { const h = harness(platform) await h.load() let resolve h.api.get = () => new Promise((r) => { resolve = r }) const opening = h.vm.openFinanceRemark(h.rows()[0]) h.vm.cancelFinanceRemark() resolve({ financeRemark: '不得重新显示' }) await opening assert.equal(h.vm.financeRemarkVisible, false) assert.equal(h.vm.financeRemarkText, '') assert.equal( h.calls.some((c) => c[0] === 'put'), false ) }) test(`${platform}: save failure preserves input; clear succeeds and refreshes`, async () => { const h = harness(platform) await h.load() await h.vm.openFinanceRemark(h.rows()[0]) h.vm.financeRemarkText = '我的未保存输入' h.api.put = async () => { throw new Error('failure') } await h.vm.saveFinanceRemark() assert.equal(h.vm.financeRemarkText, '我的未保存输入') assert.equal(h.vm.financeRemarkVisible, true) h.api.put = async () => ({ financeRemark: '' }) h.vm.financeRemarkText = '' await h.vm.saveFinanceRemark() assert.deepEqual(h.calls.slice(-2), [['put', 'instance-1', ''], ['list']]) assert.equal(h.vm.financeRemarkVisible, false) }) test(`${platform}: missing capability strips cached notes and prevents GET`, async () => { const h = harness(platform) await h.load() await h.vm.openFinanceRemark(h.rows()[0]) h.api.list = { list: [{ instanceId: 'instance-1', financeRemark: '敏感备注' }], total: 1 } await h.load() assert.equal(h.vm.canManageFinanceRemark, false) assert.equal(h.vm.financeRemarkVisible, false) assert.equal(h.vm.financeRemarkText, '') assert.equal('financeRemark' in h.rows()[0], false) const count = h.calls.length await h.vm.openFinanceRemark(h.rows()[0]) assert.equal(h.calls.length, count) }) test(`${platform}: hiding page clears notes and invalidates pending reads`, async () => { const h = harness(platform) await h.load() let resolve h.api.get = () => new Promise((r) => { resolve = r }) const opening = h.vm.openFinanceRemark(h.rows()[0]) h.hooks.hide() resolve({ financeRemark: '不得恢复的旧备注' }) await opening assert.equal(h.vm.canManageFinanceRemark, false) assert.equal(h.vm.financeRemarkText, '') assert.equal('financeRemark' in h.rows()[0], false) }) test(`${platform}: overlong input cannot send PUT`, async () => { const h = harness(platform) await h.load() await h.vm.openFinanceRemark(h.rows()[0]) h.vm.financeRemarkText = '字'.repeat(513) await h.vm.saveFinanceRemark() assert.equal( h.calls.some((c) => c[0] === 'put'), false ) assert.equal(h.vm.financeRemarkVisible, true) }) test(`${platform}: saving while reading or already saving sends no duplicate PUT`, async () => { const h = harness(platform) await h.load() let resolveGet h.api.get = () => new Promise((r) => { resolveGet = r }) const opening = h.vm.openFinanceRemark(h.rows()[0]) await h.vm.saveFinanceRemark() assert.equal( h.calls.some((c) => c[0] === 'put'), false ) resolveGet({ financeRemark: '备注' }) await opening let resolvePut h.api.put = () => new Promise((r) => { resolvePut = r }) const saving = h.vm.saveFinanceRemark() await h.vm.saveFinanceRemark() assert.equal(h.calls.filter((c) => c[0] === 'put').length, 1) resolvePut({ financeRemark: '备注' }) await saving }) test(`${platform}: late list response cannot restore permission after hiding`, async () => { const h = harness(platform) await h.load() let resolve const previous = h.api.list h.api.list = new Promise((r) => { resolve = r }) const loading = h.load() h.hooks.hide() resolve(previous) await loading assert.equal(h.vm.canManageFinanceRemark, false) assert.equal('financeRemark' in h.rows()[0], false) }) test(`${platform}: failed latest-value request cannot save stale list text`, async () => { const h = harness(platform) await h.load() h.api.get = async () => { throw new Error('forbidden') } await h.vm.openFinanceRemark(h.rows()[0]) assert.equal(h.vm.financeRemarkVisible, false) assert.equal(h.vm.financeRemarkText, '') await h.vm.saveFinanceRemark() assert.equal( h.calls.some((c) => c[0] === 'put'), false ) }) test(`${platform}: template compiles with plain text and gated edit controls`, () => { const { descriptor } = harness(platform) const result = compileTemplate({ source: descriptor.template.content, filename: 'done.vue', id: platform }) assert.deepEqual(result.errors, []) assert.match(result.code, /canManageFinanceRemark/) assert.match(result.code, /toDisplayString\([^)]*financeRemark/) assert.doesNotMatch(result.code, /innerHTML/) if (platform === 'mobile') assert.match(result.code, /withModifiers[^\n]*openFinanceRemark[^\n]*[\s\S]*?\["stop"\]/) }) } for (const platform of ['web', 'mobile']) { test(`${platform}: finance API uses dedicated authenticated instance endpoint`, async () => { const calls = [] const request = (config) => { calls.push(config) return Promise.resolve({}) } request.get = (config) => request({ ...config, method: 'GET' }) request.put = (config) => request({ ...config, method: 'PUT' }) const path = platform === 'web' ? 'web-admin/src/api/oaApproval.ts' : 'mobile-h5/utils/oa-api.js' const api = evaluate(readFileSync(new URL(path, root), 'utf8'), { request, get: (url, header) => request({ url, header, method: 'GET' }), getToken: () => 'token' }) assert.equal(typeof api.fetchOaFinanceRemark, 'function') await api.fetchOaFinanceRemark('instance-1') await api.fetchUpdateOaFinanceRemark('instance-1', '') assert.equal(calls[0].method, 'GET') assert.equal(calls[1].method, 'PUT') assert.equal(calls[0].url, '/web/admin/oa-approval/instances/instance-1/finance-remark') assert.equal(calls[1].url, calls[0].url) assert.deepEqual(calls[1].data, { financeRemark: '' }) if (platform === 'mobile') assert.equal(calls[1].header.Authorization, 'Bearer token') }) } test('web: delayed refresh on an inactive cached page cannot restore finance data', async () => { const h = harness('web') await h.load() h.hooks.hide() const count = h.calls.length await h.load() assert.equal(h.vm.canManageFinanceRemark, false) assert.equal(h.calls.length, count) }) test('mobile: save errors remain inline until retry or cancellation', async () => { const h = harness('mobile') await h.load() await h.vm.openFinanceRemark(h.rows()[0]) h.vm.financeRemarkText = '保留输入' h.api.put = async () => { throw new Error('failure') } await h.vm.saveFinanceRemark() assert.equal(h.vm.financeRemarkError, '保存失败,请稍后重试,输入已保留。') assert.equal(h.vm.financeRemarkText, '保留输入') let resolve h.api.put = () => new Promise((r) => { resolve = r }) const retry = h.vm.saveFinanceRemark() assert.equal(h.vm.financeRemarkError, '') resolve({}) await retry assert.equal(h.vm.financeRemarkVisible, false) }) test('mobile: saving blocks user cancellation but page cleanup invalidates the response', async () => { const h = harness('mobile') await h.load() await h.vm.openFinanceRemark(h.rows()[0]) let resolve h.api.put = () => new Promise((r) => { resolve = r }) const saving = h.vm.saveFinanceRemark() h.vm.cancelFinanceRemark() assert.equal(h.vm.financeRemarkVisible, true) assert.equal(h.vm.financeRemarkSaving, true) h.hooks.hide() assert.equal(h.vm.financeRemarkVisible, false) assert.equal(h.vm.financeRemarkText, '') const callCount = h.calls.length resolve({}) await saving assert.equal(h.calls.length, callCount) }) test('mobile: keyboard height keeps the dialog inside available viewport and resets on close', async () => { const h = harness('mobile') await h.load() await h.vm.openFinanceRemark(h.rows()[0]) assert.equal(h.vm.financeRemarkViewportStyle?.height, '800px') h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 320 } }) assert.equal(h.vm.financeRemarkViewportStyle.height, '480px') h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 0 } }) assert.equal(h.vm.financeRemarkViewportStyle.height, '800px') h.vm.cancelFinanceRemark() assert.deepEqual(h.vm.financeRemarkViewportStyle, {}) h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 320 } }) assert.deepEqual(h.vm.financeRemarkViewportStyle, {}) }) test('mobile: H5 viewport resize and scroll track keyboard geometry and listeners are released', async () => { const listeners = new Map() const viewport = { height: 800, offsetTop: 0, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name, fn) => { assert.equal(listeners.get(name), fn) listeners.delete(name) } } const h = harness('mobile', { window: { visualViewport: viewport } }) await h.load() await h.vm.openFinanceRemark(h.rows()[0]) assert.equal(listeners.size, 2) viewport.height = 420 viewport.offsetTop = 20 listeners.get('resize')() assert.deepEqual(h.vm.financeRemarkViewportStyle, { height: '420px', top: '20px' }) // A native keyboard notification must not subtract the same keyboard twice on H5. h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 380 } }) assert.equal(h.vm.financeRemarkViewportStyle.height, '420px') h.hooks.hide() assert.equal(listeners.size, 0) assert.deepEqual(h.vm.financeRemarkViewportStyle, {}) })