Files
zkxw/web-admin/scripts/test-oa-finance-remark.mjs
T
zlwu a8ee0fcebd fix(mobile): polish finance remark dialog from approved Figma design
Use compact layout, independent buttons and keyboard-aware viewport; retain drafts on failure and block closing during save. Require Figma approval for future screens and dialogs unless explicitly waived.
2026-10-30 22:17:49 +08:00

388 lines
13 KiB
JavaScript

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, {})
})