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.
388 lines
13 KiB
JavaScript
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, {})
|
|
})
|