This commit is contained in:
233
scripts/tests/employeeCollectionActing.test.mjs
Normal file
233
scripts/tests/employeeCollectionActing.test.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { compileScript, parse } from '@vue/compiler-sfc'
|
||||
|
||||
function evaluate(source, imports = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) => {
|
||||
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
||||
return imports[name]
|
||||
},
|
||||
module,
|
||||
module.exports
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const acting = evaluate(readFileSync('src/utils/business/employeeCollectionActing.ts', 'utf8'))
|
||||
const { descriptor } = parse(
|
||||
readFileSync(
|
||||
'src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue',
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
const componentSource = compileScript(descriptor, { id: 'collection-acting-test' }).content
|
||||
|
||||
const employee = { id: 10, name: '测试员工', user_type: 1 }
|
||||
const bill = (id, name = employee.name, accountId = 99) => ({
|
||||
id,
|
||||
remaining_amount: 10000,
|
||||
status: 0,
|
||||
debtor_account_id: accountId,
|
||||
debtor_snapshot: { account_id: accountId, account_name: name }
|
||||
})
|
||||
|
||||
function formHarness(props = {}, candidates = [], details = {}) {
|
||||
const calls = []
|
||||
const detailCalls = []
|
||||
const cleared = []
|
||||
const user = vue.reactive({ info: { ...employee }, isSuperAdmin: true })
|
||||
const service = {
|
||||
getPaymentMethods: async () => ({
|
||||
code: 0,
|
||||
data: [{ id: 1, name: '银行转账', enabled: true }]
|
||||
}),
|
||||
getBills: async () => ({ code: 0, data: { items: candidates, total: candidates.length } }),
|
||||
getApplicationById: async () => ({
|
||||
code: 0,
|
||||
data: {
|
||||
allocations: Object.keys(details).map((id) => ({ bill_id: Number(id), amount: 5000 }))
|
||||
}
|
||||
}),
|
||||
getBillById: async (id) => {
|
||||
detailCalls.push(id)
|
||||
return { code: 0, data: { bill: details[id] } }
|
||||
},
|
||||
createApplication: async (data) => {
|
||||
calls.push({ method: 'create', data })
|
||||
return { code: 0 }
|
||||
},
|
||||
updateApplication: async (id, data) => {
|
||||
calls.push({ method: 'update', id, data })
|
||||
return { code: 0 }
|
||||
}
|
||||
}
|
||||
const display = {
|
||||
BILL_SOURCE_OPTIONS: [],
|
||||
BILL_STATUS_OPTIONS: [],
|
||||
canCreateApplication: () => true,
|
||||
formatCollectionCurrency: String,
|
||||
normalizeCollectionList: (data) => (Array.isArray(data) ? data : data.items || []),
|
||||
normalizeCollectionPage: (data) => ({ list: data.items || [], total: data.total || 0 })
|
||||
}
|
||||
const component = evaluate(componentSource, {
|
||||
vue,
|
||||
'element-plus': { ElMessage: { success() {}, error() {}, warning() {} } },
|
||||
'@/api/modules': {
|
||||
EmployeeCollectionService: service,
|
||||
AgentRechargeService: {},
|
||||
ShopService: {}
|
||||
},
|
||||
'@/utils/business/format': {
|
||||
fenToYuan: (value) => Number(value || 0) / 100,
|
||||
yuanToFen: (value) => Math.round(Number(value || 0) * 100)
|
||||
},
|
||||
'@/utils/business': {
|
||||
getErrorMessage: String,
|
||||
toVoucherKeyList: (value) => (Array.isArray(value) ? value : value ? [value] : [])
|
||||
},
|
||||
'@/store/modules/user': { useUserStore: () => user },
|
||||
'@/utils/business/employeeCollectionActing': acting,
|
||||
'@/components/business/VoucherUpload.vue': { default: {} },
|
||||
'../../employeeCollectionDisplay': display
|
||||
}).default
|
||||
const reactiveProps = vue.reactive({
|
||||
modelValue: true,
|
||||
application: null,
|
||||
presetBill: null,
|
||||
...props
|
||||
})
|
||||
const state = component.setup(reactiveProps, { expose() {}, emit() {} })
|
||||
state.formRef.value = {
|
||||
clearValidate: (field) => cleared.push(field),
|
||||
validate: async () => {
|
||||
if (state.rules.value.acting_reason?.[0]?.required && !state.form.acting_reason.trim()) {
|
||||
throw new Error('请填写代办原因')
|
||||
}
|
||||
}
|
||||
}
|
||||
const fillPayment = () =>
|
||||
Object.assign(state.form, {
|
||||
payment_method_id: 1,
|
||||
paid_amount: 100,
|
||||
paid_at: '2026-10-14T15:00:00+08:00',
|
||||
payer_name: '付款方',
|
||||
external_transaction_no: 'LOCAL_TEST',
|
||||
payment_voucher_keys: ['test-voucher']
|
||||
})
|
||||
return { state, user, calls, cleared, detailCalls, fillPayment }
|
||||
}
|
||||
|
||||
test('matching nonempty me.name makes a bill self-handled even when account IDs differ', () => {
|
||||
assert.equal(acting.isEmployeeCollectionBillSelf(bill(1), employee), true)
|
||||
assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, ' 测试员工 '), employee), true)
|
||||
assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, '其他员工'), employee), false)
|
||||
assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, '', 10), employee), true)
|
||||
assert.equal(
|
||||
acting.isEmployeeCollectionBillSelf(bill(1, '', null), { name: '', user_type: 1 }),
|
||||
false
|
||||
)
|
||||
assert.equal(acting.requiresEmployeeCollectionActingReason([bill(1), bill(2)], employee), false)
|
||||
assert.equal(
|
||||
acting.requiresEmployeeCollectionActingReason([bill(1), bill(2, '其他员工')], employee),
|
||||
true
|
||||
)
|
||||
assert.equal(acting.requiresEmployeeCollectionActingReason([undefined], employee), true)
|
||||
assert.equal(acting.requiresEmployeeCollectionActingReason([], employee), true)
|
||||
assert.equal(
|
||||
acting.requiresEmployeeCollectionActingReason([bill(2, '其他员工')], {
|
||||
...employee,
|
||||
user_type: 2
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('creating a self-handled application has no acting reason rule and omits the field entirely', async () => {
|
||||
const { state, calls, fillPayment } = formHarness({ presetBill: bill(1) })
|
||||
await state.initialize()
|
||||
await vue.nextTick()
|
||||
fillPayment()
|
||||
assert.equal(state.isActing.value, false)
|
||||
assert.equal(state.rules.value.acting_reason, undefined)
|
||||
state.form.acting_reason = '不得发送的残留值'
|
||||
await state.handleSubmit()
|
||||
assert.equal(calls[0].method, 'create')
|
||||
assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false)
|
||||
assert.deepEqual(calls[0].data.allocations, [{ bill_id: 1, amount: 10000 }])
|
||||
})
|
||||
|
||||
test('acting for another employee remains required; switching back to self clears the old value and validation', async () => {
|
||||
const { state, calls, cleared, fillPayment } = formHarness()
|
||||
fillPayment()
|
||||
state.toggleBill(bill(2, '其他员工'))
|
||||
await vue.nextTick()
|
||||
assert.equal(state.rules.value.acting_reason[0].required, true)
|
||||
await assert.rejects(state.handleSubmit(), /代办原因/)
|
||||
assert.equal(calls.length, 0)
|
||||
state.form.acting_reason = '代办核销'
|
||||
state.toggleBill(bill(2, '其他员工'))
|
||||
state.toggleBill(bill(1))
|
||||
await vue.nextTick()
|
||||
assert.equal(state.isActing.value, false)
|
||||
assert.equal(state.form.acting_reason, '')
|
||||
assert.ok(cleared.includes('acting_reason'))
|
||||
await state.handleSubmit()
|
||||
assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false)
|
||||
})
|
||||
|
||||
test('resubmission loads responsibility for bills outside the current candidate page and omits a historical acting reason', async () => {
|
||||
const application = {
|
||||
id: 8,
|
||||
acting_reason: '历史代办原因',
|
||||
paid_amount: 5000,
|
||||
payment_method_id: 1
|
||||
}
|
||||
const { state, calls, detailCalls, fillPayment } = formHarness({ application }, [], {
|
||||
21: bill(21)
|
||||
})
|
||||
await state.initialize()
|
||||
await vue.nextTick()
|
||||
fillPayment()
|
||||
assert.deepEqual(detailCalls, [21])
|
||||
assert.equal(state.displayBills.value[0].debtor_snapshot.account_name, employee.name)
|
||||
assert.equal(state.isActing.value, false)
|
||||
assert.equal(state.rules.value.acting_reason, undefined)
|
||||
assert.equal(state.form.acting_reason, '')
|
||||
await state.handleSubmit()
|
||||
assert.equal(calls[0].method, 'update')
|
||||
assert.equal(calls[0].id, 8)
|
||||
assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false)
|
||||
assert.deepEqual(calls[0].data.allocations, [{ bill_id: 21, amount: 5000 }])
|
||||
})
|
||||
|
||||
test('resubmission reuses responsibility from the candidate page and cannot be shadowed by an incomplete allocation cache', async () => {
|
||||
const { state, detailCalls } = formHarness({ application: { id: 9 } }, [bill(1)], { 1: bill(1) })
|
||||
await state.initialize()
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(detailCalls, [])
|
||||
state.selectedBillOptions.value[1] = { id: 1, remaining_amount: 10000 }
|
||||
assert.equal(state.isActing.value, false)
|
||||
})
|
||||
|
||||
test('mixed-responsibility resubmission still sends the required acting reason', async () => {
|
||||
const { state, calls, fillPayment } = formHarness({ application: { id: 10 } }, [], {
|
||||
1: bill(1),
|
||||
2: bill(2, '其他员工')
|
||||
})
|
||||
await state.initialize()
|
||||
await vue.nextTick()
|
||||
fillPayment()
|
||||
assert.equal(state.isActing.value, true)
|
||||
assert.equal(state.rules.value.acting_reason[0].required, true)
|
||||
state.form.acting_reason = ' 帮员工提交核销 '
|
||||
await state.handleSubmit()
|
||||
assert.equal(calls[0].data.acting_reason, '帮员工提交核销')
|
||||
})
|
||||
@@ -3,6 +3,9 @@ import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { compileScript, parse } from '@vue/compiler-sfc'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const esbuild = createRequire(require.resolve('vite'))('esbuild')
|
||||
@@ -47,11 +50,21 @@ async function loadModule(entry, stubService = false) {
|
||||
return module.exports
|
||||
}
|
||||
|
||||
test('new login password accepts only 8-12 ASCII letters/digits with all three classes', async () => {
|
||||
test('new login password allows special characters and requires at least three of four classes', async () => {
|
||||
const { getNewLoginPasswordError, createNewLoginPasswordRule } = await loadModule(
|
||||
'src/utils/validation/loginPassword.ts'
|
||||
)
|
||||
for (const password of ['Abc12345', 'Abc123456789'])
|
||||
for (const password of [
|
||||
'Abc12345',
|
||||
'Abc123456789',
|
||||
'Abc1234!',
|
||||
'Abc1234_',
|
||||
'ABC1234!',
|
||||
'abc1234!',
|
||||
'Abcdefg!',
|
||||
'Ab1~`-=[]{}',
|
||||
'Ab1;:\\|,<>/?'
|
||||
])
|
||||
assert.equal(getNewLoginPasswordError(password), undefined)
|
||||
for (const password of [
|
||||
'Abc1234',
|
||||
@@ -59,8 +72,9 @@ test('new login password accepts only 8-12 ASCII letters/digits with all three c
|
||||
'abcd1234',
|
||||
'ABCD1234',
|
||||
'AbcDefGh',
|
||||
'Abc1234!',
|
||||
'Abc1234_',
|
||||
'ABCDEF!!',
|
||||
'abcdef!!',
|
||||
'123456!!',
|
||||
'Abc1234 ',
|
||||
' Abc12345',
|
||||
'Abc1234\n',
|
||||
@@ -73,7 +87,218 @@ test('new login password accepts only 8-12 ASCII letters/digits with all three c
|
||||
const rule = createNewLoginPasswordRule()
|
||||
for (const value of ['', undefined, null])
|
||||
rule.validator({}, value, (error) => assert.equal(error, undefined))
|
||||
rule.validator({}, 'Abc1234!', (error) => assert.ok(error instanceof Error))
|
||||
rule.validator({}, 'Abc1234!', (error) => assert.equal(error, undefined))
|
||||
rule.validator({}, 'abcdef!!', (error) => assert.ok(error instanceof Error))
|
||||
for (const symbol of Array.from('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~')) {
|
||||
assert.equal(getNewLoginPasswordError(`Ab12345${symbol}`), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('admin and scan-registration new-password forms share validation, hints and the 12-character input limit', async () => {
|
||||
const forms = [
|
||||
{
|
||||
path: 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue',
|
||||
models: ['formData.new_password', 'formData.confirm_password'],
|
||||
fields: ['new_password', 'confirm_password'],
|
||||
hint: "t('passwordChange.passwordRule')"
|
||||
},
|
||||
{
|
||||
path: 'src/views/account-management/account/index.vue',
|
||||
models: ['formData.password'],
|
||||
fields: ['password']
|
||||
},
|
||||
{
|
||||
path: 'src/views/common/account-list.vue',
|
||||
models: ['accountForm.password'],
|
||||
fields: ['password']
|
||||
},
|
||||
{
|
||||
path: 'src/views/shop-management/list/index.vue',
|
||||
models: ['formData.init_password'],
|
||||
fields: ['init_password']
|
||||
},
|
||||
{
|
||||
path: 'src/views/account-management/enterprise-customer/index.vue',
|
||||
models: ['form.password', 'passwordForm.password'],
|
||||
fields: ['password', 'password']
|
||||
},
|
||||
{
|
||||
path: 'src/views/agent-registration/index.vue',
|
||||
models: ['form.password'],
|
||||
fields: ['password'],
|
||||
hint: null
|
||||
}
|
||||
]
|
||||
for (const { path, models, fields, hint = 'NEW_LOGIN_PASSWORD_HINT' } of forms) {
|
||||
const source = await readFile(path, 'utf8')
|
||||
assert.match(source, /from '@\/utils\/validation\/loginPassword'/, path)
|
||||
const inputs = [...source.matchAll(/<ElInput\b[\s\S]*?\/>/g)].map(([input]) => input)
|
||||
for (const model of models) {
|
||||
// Exact v-model also rejects a silent .trim modifier on password inputs.
|
||||
const matches = inputs.filter((input) => input.includes(`v-model="${model}"`))
|
||||
assert.equal(matches.length, 1, `${path}: ${model}`)
|
||||
assert.match(matches[0], /type="password"/, `${path}: ${model}`)
|
||||
assert.match(matches[0], /maxlength="12"/, `${path}: ${model}`)
|
||||
if (hint) {
|
||||
assert.ok(matches[0].includes(`:placeholder="${hint}"`), `${path}: ${model} hint`)
|
||||
} else {
|
||||
assert.match(matches[0], /aria-describedby="registration-password-rule"/, path)
|
||||
assert.match(source, /id="registration-password-rule"[\s\S]*?NEW_LOGIN_PASSWORD_HINT/, path)
|
||||
}
|
||||
}
|
||||
const rules = [
|
||||
...source.matchAll(
|
||||
/\b(password|new_password|confirm_password|init_password):\s*\[([\s\S]*?)\n\s*\]/g
|
||||
)
|
||||
]
|
||||
assert.deepEqual(rules.map(([, field]) => field).sort(), [...fields].sort(), path)
|
||||
for (const [, field, rule] of rules) {
|
||||
assert.match(rule, /required: true/, `${path}: ${field}`)
|
||||
assert.match(rule, /createNewLoginPasswordRule\(/, `${path}: ${field}`)
|
||||
assert.doesNotMatch(rule, /\b(?:min|max|pattern):/, `${path}: ${field} stale rule`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('scan registration validates password and locally handles errors without changing public endpoint, payload or SMS behavior', async () => {
|
||||
const { AgentDistributionService } = await loadModule(
|
||||
'src/api/modules/agentDistribution.ts',
|
||||
true
|
||||
)
|
||||
AgentDistributionService.calls = []
|
||||
const params = {
|
||||
distribution_code: 'local-test',
|
||||
phone: '13800000000',
|
||||
code: '123456',
|
||||
shop_name: '本地测试店铺',
|
||||
shop_code: 'LOCAL_TEST',
|
||||
username: 'local_test'
|
||||
}
|
||||
for (const password of ['Abc12345', 'Abc123456789', 'ABC1234!', 'abc1234!', 'Abcdefg!']) {
|
||||
await AgentDistributionService.registerAgent({ ...params, password })
|
||||
assert.deepEqual(AgentDistributionService.calls.at(-1), {
|
||||
url: '/api/c/v1/agent-distribution-registrations',
|
||||
data: { ...params, password },
|
||||
config: { requestOptions: { withToken: false, errorMessageMode: 'none' } }
|
||||
})
|
||||
}
|
||||
for (const password of ['', 'Abc1234', 'Abc123456789X', 'abcd1234', 'Abc1234 ', 'Abc1234中']) {
|
||||
await assert.rejects(AgentDistributionService.registerAgent({ ...params, password }))
|
||||
}
|
||||
assert.equal(AgentDistributionService.calls.length, 5)
|
||||
await AgentDistributionService.sendCode({ phone: params.phone, scene: 'bind_phone' })
|
||||
assert.deepEqual(AgentDistributionService.calls.at(-1), {
|
||||
url: '/api/c/v1/auth/send-code',
|
||||
data: { phone: params.phone, scene: 'bind_phone' },
|
||||
config: { requestOptions: { withToken: false } }
|
||||
})
|
||||
})
|
||||
|
||||
test('scan registration masks template errors in both business and HTTP responses and uses only local feedback', async () => {
|
||||
const { getAgentRegistrationFailureMessage } = await loadModule(
|
||||
'src/utils/business/agentRegistrationError.ts'
|
||||
)
|
||||
for (const code of [1050, 1001, '1050', '1001']) {
|
||||
const failure = { code, data: null, msg: '企业微信审批模板无效或不可访问' }
|
||||
for (const value of [
|
||||
failure,
|
||||
{ ...failure, data: { detail: 'internal template settings' } },
|
||||
{ isAxiosError: true, response: { status: 400, data: failure } },
|
||||
{ isAxiosError: true, response: { status: 500, data: failure } },
|
||||
{ data: failure }
|
||||
]) {
|
||||
assert.equal(getAgentRegistrationFailureMessage(value), '注册失败, 请联系管理员')
|
||||
}
|
||||
}
|
||||
assert.equal(getAgentRegistrationFailureMessage({ code: 1203, msg: '验证码错误' }), '验证码错误')
|
||||
assert.equal(
|
||||
getAgentRegistrationFailureMessage({ code: 1204, message: '分销码不可用' }),
|
||||
'分销码不可用'
|
||||
)
|
||||
assert.equal(
|
||||
getAgentRegistrationFailureMessage({ isAxiosError: true, code: 'ECONNABORTED' }),
|
||||
'请求超时,结果可能未知,请刷新确认'
|
||||
)
|
||||
for (const value of [null, undefined, new Error('network')]) {
|
||||
assert.equal(typeof getAgentRegistrationFailureMessage(value), 'string')
|
||||
}
|
||||
const page = await readFile('src/views/agent-registration/index.vue', 'utf8')
|
||||
assert.match(page, /ElMessage.error\(getAgentRegistrationFailureMessage\(res\)\)/)
|
||||
assert.match(
|
||||
page,
|
||||
/catch \(error\)\s*\{\s*ElMessage.error\(getAgentRegistrationFailureMessage\(error\)\)/
|
||||
)
|
||||
assert.doesNotMatch(page, /ElMessage.error\('分销码不可用'\)/)
|
||||
assert.match(page, /if \(!valid \|\| submitting.value\) return/)
|
||||
})
|
||||
|
||||
test('scan registration emits exactly one masked message per template failure and retains the form for retry', async () => {
|
||||
const { getAgentRegistrationFailureMessage } = await loadModule(
|
||||
'src/utils/business/agentRegistrationError.ts'
|
||||
)
|
||||
const passwordValidation = await loadModule('src/utils/validation/loginPassword.ts')
|
||||
const { descriptor } = parse(await readFile('src/views/agent-registration/index.vue', 'utf8'))
|
||||
const script = compileScript(descriptor, { id: 'scan-registration-error-test' }).content
|
||||
const compiled = ts.transpileModule(script, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
for (const code of [1050, 1001]) {
|
||||
for (const rejected of [false, true]) {
|
||||
const messages = []
|
||||
const failure = { code, data: null, msg: '企业微信审批模板无效或不可访问' }
|
||||
const imports = {
|
||||
vue: { ...vue, onBeforeUnmount() {} },
|
||||
'vue-router': { useRoute: () => ({ query: {} }) },
|
||||
'element-plus': { ElMessage: { error: (message) => messages.push(message) } },
|
||||
'@/api/modules/agentDistribution': {
|
||||
AgentDistributionService: {
|
||||
registerAgent: async () => {
|
||||
if (rejected) throw { isAxiosError: true, response: { status: 400, data: failure } }
|
||||
return failure
|
||||
}
|
||||
}
|
||||
},
|
||||
'@/utils/constants/regionData': { regionData: [] },
|
||||
'@/utils/validation/loginPassword': passwordValidation,
|
||||
'@/utils/business/agentRegistrationError': { getAgentRegistrationFailureMessage }
|
||||
}
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) => {
|
||||
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
||||
return imports[name]
|
||||
},
|
||||
module,
|
||||
module.exports
|
||||
)
|
||||
const state = module.exports.default.setup({}, { expose() {} })
|
||||
Object.assign(state.form, { distribution_code: 'local-test', password: 'Abc1234!' })
|
||||
let submission
|
||||
state.formRef.value = {
|
||||
validate: (callback) => {
|
||||
submission = callback(true)
|
||||
}
|
||||
}
|
||||
state.handleSubmit()
|
||||
state.handleSubmit()
|
||||
await submission
|
||||
assert.deepEqual(messages, ['注册失败, 请联系管理员'])
|
||||
assert.equal(state.submitted.value, false)
|
||||
assert.equal(state.submitting.value, false)
|
||||
assert.equal(state.form.distribution_code, 'local-test')
|
||||
assert.equal(state.form.password, 'Abc1234!')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('scan registration password feedback uses the shared rule rather than treating spaces or Chinese as symbols', async () => {
|
||||
const page = await readFile('src/views/agent-registration/index.vue', 'utf8')
|
||||
assert.match(page, /return getNewLoginPasswordError\(pwd\) \? 1 : 3/)
|
||||
assert.match(page, /密码符合规则/)
|
||||
assert.match(page, /aria-live="polite"/)
|
||||
assert.match(page, /password-form-item :deep\(\.el-form-item__error\)[\s\S]*?position: static/)
|
||||
assert.doesNotMatch(page, /8-32|maxlength="32"/)
|
||||
assert.match(page, /password: form.password,/)
|
||||
})
|
||||
|
||||
test('account password requests use the right field and omit unchanged optional password', async () => {
|
||||
@@ -83,9 +308,26 @@ test('account password requests use the right field and omit unchanged optional
|
||||
assert.deepEqual(AccountService.calls[0].data, { new_password: 'Abc12345' })
|
||||
await AccountService.updateAccount(12, { username: 'operator', password: '' })
|
||||
assert.deepEqual(AccountService.calls[1].data, { username: 'operator' })
|
||||
await assert.rejects(AccountService.updateAccount(12, { password: 'Abc1234!' }))
|
||||
await AccountService.updateAccount(12, { password: 'Abc1234!' })
|
||||
assert.deepEqual(AccountService.calls[2].data, { password: 'Abc1234!' })
|
||||
await AccountService.updateAccountPassword(12, 'ABC1234!')
|
||||
assert.deepEqual(AccountService.calls[3].data, { new_password: 'ABC1234!' })
|
||||
await assert.rejects(AccountService.updateAccount(12, { password: 'abcdef!!' }))
|
||||
await assert.rejects(AccountService.updateAccountPassword(12, 'abcd1234'))
|
||||
assert.equal(AccountService.calls.length, 2)
|
||||
assert.equal(AccountService.calls.length, 4)
|
||||
})
|
||||
|
||||
test('change-password dialog is appended to body so its overlay covers the sidebar stacking context', async () => {
|
||||
const dialog = await readFile(
|
||||
'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue',
|
||||
'utf8'
|
||||
)
|
||||
assert.match(dialog, /append-to-body/)
|
||||
assert.match(dialog, /createNewLoginPasswordRule\(t\('passwordChange.passwordRule'\)\)/)
|
||||
for (const language of ['zh', 'en']) {
|
||||
const locale = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8'))
|
||||
assert.doesNotMatch(locale.passwordChange.passwordRule, /不允许特殊符号|no special characters/)
|
||||
}
|
||||
})
|
||||
|
||||
test('writeoff terminal failure blocks recovery but allows resubmission; unknown result stays recoverable', async () => {
|
||||
|
||||
258
scripts/tests/tableHeaderDragLifecycle.test.mjs
Normal file
258
scripts/tests/tableHeaderDragLifecycle.test.mjs
Normal file
@@ -0,0 +1,258 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { compileScript, parse } from '@vue/compiler-sfc'
|
||||
|
||||
const { descriptor } = parse(readFileSync('src/components/core/tables/ArtTableHeader.vue', 'utf8'))
|
||||
const componentSource = compileScript(descriptor, {
|
||||
id: 'table-header-drag-test',
|
||||
inlineTemplate: true
|
||||
}).content
|
||||
|
||||
class HostElement {
|
||||
constructor(tag, text = '') {
|
||||
this.tag = tag
|
||||
this.text = text
|
||||
this.props = {}
|
||||
this.children = []
|
||||
this.parentNode = null
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.tag === 'root' || Boolean(this.parentNode?.isConnected)
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child)
|
||||
assert.notEqual(index, -1)
|
||||
this.children.splice(index, 1)
|
||||
child.parentNode = null
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(source, imports) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) => {
|
||||
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
||||
return imports[name]
|
||||
},
|
||||
module,
|
||||
module.exports
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
function mountHeader(t) {
|
||||
const previousGlobals = { HTMLElement: globalThis.HTMLElement, document: globalThis.document }
|
||||
globalThis.HTMLElement = HostElement
|
||||
globalThis.document = { addEventListener() {}, removeEventListener() {} }
|
||||
const controls = []
|
||||
const store = vue.reactive({
|
||||
tableSize: 'default',
|
||||
isZebra: false,
|
||||
isBorder: false,
|
||||
isHeaderBackground: false
|
||||
})
|
||||
const slotComponent = (name) =>
|
||||
vue.defineComponent({
|
||||
name,
|
||||
setup:
|
||||
(_, { slots, attrs }) =>
|
||||
() =>
|
||||
vue.h(name, attrs, [slots.reference?.(), slots.default?.(), slots.dropdown?.()])
|
||||
})
|
||||
const elements = Object.fromEntries(
|
||||
['ElPopover', 'ElCheckbox', 'ElDropdown', 'ElDropdownMenu', 'ElDropdownItem'].map((name) => [
|
||||
name,
|
||||
slotComponent(name)
|
||||
])
|
||||
)
|
||||
const component = evaluate(componentSource, {
|
||||
vue,
|
||||
pinia: { storeToRefs: (value) => vue.toRefs(value) },
|
||||
'element-plus': elements,
|
||||
'vue-i18n': { useI18n: () => ({ t: (key) => key }) },
|
||||
'@/enums/formEnum': { TableSizeEnum: { SMALL: 'small', DEFAULT: 'default', LARGE: 'large' } },
|
||||
'@/store/modules/table': { useTableStore: () => store },
|
||||
'vue-draggable-plus': {
|
||||
useDraggable: (target, model, options) => {
|
||||
const control = {
|
||||
target,
|
||||
model,
|
||||
options,
|
||||
starts: [],
|
||||
active: false,
|
||||
destroys: 0,
|
||||
start(element = target.value) {
|
||||
assert.ok(
|
||||
element instanceof HostElement && element.isConnected,
|
||||
'Sortable needs a connected native element'
|
||||
)
|
||||
this.starts.push(element)
|
||||
this.active = true
|
||||
},
|
||||
destroy() {
|
||||
this.destroys += 1
|
||||
this.active = false
|
||||
}
|
||||
}
|
||||
controls.push(control)
|
||||
vue.onMounted(() => {
|
||||
if (options.immediate !== false) control.start()
|
||||
})
|
||||
return control
|
||||
}
|
||||
}
|
||||
}).default
|
||||
const renderer = vue.createRenderer({
|
||||
createElement: (tag) => new HostElement(tag),
|
||||
createText: (text) => new HostElement('#text', text),
|
||||
createComment: (text) => new HostElement('#comment', text),
|
||||
setText: (node, text) => {
|
||||
node.text = text
|
||||
},
|
||||
setElementText: (node, text) => {
|
||||
node.text = text
|
||||
node.children = []
|
||||
},
|
||||
parentNode: (node) => node.parentNode,
|
||||
nextSibling: (node) => {
|
||||
const siblings = node.parentNode?.children || []
|
||||
return siblings[siblings.indexOf(node) + 1] || null
|
||||
},
|
||||
patchProp: (node, key, _, value) => {
|
||||
node.props[key] = value
|
||||
},
|
||||
insert: (node, parent, anchor) => {
|
||||
node.parentNode?.removeChild(node)
|
||||
const index = anchor ? parent.children.indexOf(anchor) : parent.children.length
|
||||
assert.notEqual(index, -1)
|
||||
parent.children.splice(index, 0, node)
|
||||
node.parentNode = parent
|
||||
},
|
||||
remove: (node) => node.parentNode?.removeChild(node)
|
||||
})
|
||||
const columns = vue.ref([
|
||||
{ prop: 'phone', label: '手机号', checked: true },
|
||||
{ prop: 'shop_name', label: '店铺', checked: false }
|
||||
])
|
||||
const active = vue.ref(true)
|
||||
const root = new HostElement('root')
|
||||
const app = renderer.createApp({
|
||||
render: () =>
|
||||
vue.h(vue.KeepAlive, null, {
|
||||
default: () =>
|
||||
active.value
|
||||
? vue.h(component, {
|
||||
columns: columns.value,
|
||||
'onUpdate:columns': (value) => {
|
||||
columns.value = value
|
||||
}
|
||||
})
|
||||
: vue.h('placeholder')
|
||||
})
|
||||
})
|
||||
for (const [name, element] of Object.entries(elements)) app.component(name, element)
|
||||
app.mount(root)
|
||||
let mounted = true
|
||||
const unmount = () => {
|
||||
if (mounted) {
|
||||
app.unmount()
|
||||
mounted = false
|
||||
}
|
||||
}
|
||||
t.after(() => {
|
||||
unmount()
|
||||
for (const [key, value] of Object.entries(previousGlobals)) {
|
||||
if (value === undefined) delete globalThis[key]
|
||||
else globalThis[key] = value
|
||||
}
|
||||
})
|
||||
const find = (node, tag) =>
|
||||
node.tag === tag ? node : node.children.map((child) => find(child, tag)).find(Boolean)
|
||||
return { control: controls[0], columns, active, unmount, popover: find(root, 'ElPopover') }
|
||||
}
|
||||
|
||||
const openPopover = (popover) => {
|
||||
popover.props['onUpdate:visible'](true)
|
||||
popover.props.onAfterEnter()
|
||||
}
|
||||
|
||||
test('table header does not auto-start Sortable until the column popover has entered', async (t) => {
|
||||
const { control, popover } = mountHeader(t)
|
||||
await vue.nextTick()
|
||||
assert.equal(control.options.immediate, false)
|
||||
assert.equal(control.starts.length, 0)
|
||||
popover.props.onAfterEnter()
|
||||
assert.equal(control.starts.length, 0, 'a stale enter without opening is ignored')
|
||||
openPopover(popover)
|
||||
assert.equal(control.starts.length, 1)
|
||||
assert.equal(control.starts[0], control.target.value)
|
||||
})
|
||||
|
||||
test('closing and reopening column settings destroys the drag instance and rejects a late enter', async (t) => {
|
||||
const { control, popover } = mountHeader(t)
|
||||
openPopover(popover)
|
||||
popover.props.onBeforeLeave()
|
||||
assert.equal(control.active, false)
|
||||
assert.equal(control.destroys, 1)
|
||||
popover.props.onAfterEnter()
|
||||
assert.equal(control.starts.length, 1)
|
||||
openPopover(popover)
|
||||
assert.equal(control.starts.length, 2)
|
||||
})
|
||||
|
||||
test('missing, non-element and detached column targets never start Sortable', async (t) => {
|
||||
const { control, popover } = mountHeader(t)
|
||||
const original = control.target.value
|
||||
for (const target of [null, undefined, {}, new HostElement('detached')]) {
|
||||
control.target.value = target
|
||||
openPopover(popover)
|
||||
assert.equal(control.starts.length, 0)
|
||||
}
|
||||
control.target.value = original
|
||||
openPopover(popover)
|
||||
assert.equal(control.starts.length, 1)
|
||||
})
|
||||
|
||||
test('unmounting before or after opening safely cleans up and rejects queued popover callbacks', async (t) => {
|
||||
for (const opened of [false, true]) {
|
||||
const { control, popover, unmount } = mountHeader(t)
|
||||
if (opened) openPopover(popover)
|
||||
const count = control.starts.length
|
||||
unmount()
|
||||
await vue.nextTick()
|
||||
assert.equal(control.active, false)
|
||||
assert.ok(control.destroys > 0)
|
||||
popover.props.onAfterEnter()
|
||||
assert.equal(control.starts.length, count)
|
||||
}
|
||||
})
|
||||
|
||||
test('cached page deactivation cleans up; returning preserves column order and checked state', async (t) => {
|
||||
const { control, popover, active, columns } = mountHeader(t)
|
||||
openPopover(popover)
|
||||
control.model.value = [...control.model.value].reverse()
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(
|
||||
columns.value.map((item) => item.prop),
|
||||
['shop_name', 'phone']
|
||||
)
|
||||
active.value = false
|
||||
await vue.nextTick()
|
||||
assert.equal(control.active, false)
|
||||
popover.props.onAfterEnter()
|
||||
assert.equal(control.starts.length, 1)
|
||||
active.value = true
|
||||
await vue.nextTick()
|
||||
openPopover(popover)
|
||||
assert.equal(control.starts.length, 2)
|
||||
assert.equal(columns.value[0].checked, false)
|
||||
assert.equal(columns.value[1].checked, true)
|
||||
})
|
||||
Reference in New Issue
Block a user