执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。 Co-authored-by: Cursor <cursoragent@cursor.com>
963 lines
35 KiB
TypeScript
963 lines
35 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync, existsSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import { computed, ref, reactive } from 'vue'
|
|
const file = new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url)
|
|
const source = readFileSync(file, 'utf8')
|
|
const descriptor = parse(source).descriptor
|
|
const ast = ts.createSourceFile(
|
|
'form.ts',
|
|
descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
function evaluate(names: string[], bindings: Record<string, any>) {
|
|
const nodes = names.map((name) => {
|
|
const node = ast.statements.find((n) =>
|
|
ts.isFunctionDeclaration(n)
|
|
? n.name?.text === name
|
|
: ts.isVariableStatement(n) &&
|
|
n.declarationList.declarations.some((d) => d.name.getText(ast) === name)
|
|
)
|
|
assert.ok(node, `production declaration ${name}`)
|
|
return node.getText(ast)
|
|
})
|
|
return new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(nodes.join('\n'), { target: ts.ScriptTarget.ESNext }) +
|
|
`;return {${names.join(',')}}`
|
|
)(...Object.values(bindings))
|
|
}
|
|
test('mobile has a separate first root; desktop root retained', () => {
|
|
const roots = descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[]
|
|
assert.equal(roots[0].tag, 'MobilePlPlanForm')
|
|
assert.match(roots[1].loc.source, /v-else/)
|
|
})
|
|
test('actual controller preserves ref identity and handler references', async () => {
|
|
const declaration = ast.statements.find(
|
|
(n) =>
|
|
ts.isVariableStatement(n) &&
|
|
n.declarationList.declarations.some((d) => d.name.getText(ast) === 'mobileController')
|
|
)
|
|
assert.ok(declaration)
|
|
const text = declaration.getText(ast)
|
|
const properties = text.match(/reactive\(\{([\s\S]*)\}\)/)![1]
|
|
const names = properties
|
|
.split(',')
|
|
.map((x) => x.trim())
|
|
.filter((x) => /^\w+$/.test(x))
|
|
const bindings: Record<string, any> = {
|
|
reactive,
|
|
basicFormRef: ref(null),
|
|
loadSectionRef: ref(null),
|
|
deliverySectionRef: ref(null)
|
|
}
|
|
names.forEach((n) => {
|
|
bindings[n] = ref(n)
|
|
})
|
|
const c = evaluate(['mobileController'], bindings).mobileController
|
|
const fields: string[] = []
|
|
const validator = {
|
|
validate: () => true,
|
|
validateField: async (field: string) => {
|
|
fields.push(field)
|
|
}
|
|
}
|
|
c.setBasicFormRef(validator)
|
|
assert.equal(bindings.basicFormRef.value.validate, validator.validate)
|
|
await c.validateBasicField('belongsCompany')
|
|
await c.validateBasicField('createDate')
|
|
assert.deepEqual(fields, ['belongsCompany', 'createDate'])
|
|
c.setLoadSectionRef(validator)
|
|
assert.equal(bindings.loadSectionRef.value.validate, validator.validate)
|
|
})
|
|
test('original required rules and button matrix execute', () => {
|
|
const props = reactive({ planId: '', planEditOnly: false, openForArrange: false })
|
|
const status = ref('pending')
|
|
const bindings = {
|
|
computed,
|
|
props,
|
|
isEdit: computed(() => !!props.planId),
|
|
loadedTransportStatus: status,
|
|
loadedFollowStatus: ref('pending_arrange')
|
|
}
|
|
const c = evaluate(
|
|
[
|
|
'basicRules',
|
|
'showSaveButton',
|
|
'showDraftButton',
|
|
'showSaveAndArrangeButton',
|
|
'isRearrangeMode',
|
|
'showRearrangeButton'
|
|
],
|
|
bindings
|
|
)
|
|
assert.deepEqual(Object.keys(c.basicRules), ['createDate', 'belongsCompany'])
|
|
assert.equal(c.showSaveButton.value, true)
|
|
props.planId = 'p1'
|
|
status.value = 'arranged'
|
|
assert.equal(c.showSaveButton.value, false)
|
|
props.openForArrange = true
|
|
assert.equal(c.showRearrangeButton.value, true)
|
|
props.planEditOnly = true
|
|
assert.equal(c.showSaveButton.value, true)
|
|
assert.equal(c.showDraftButton.value, false)
|
|
assert.equal(c.showSaveAndArrangeButton.value, false)
|
|
})
|
|
test('mobile validator registers basic fields and all five sections; SFC compiles', () => {
|
|
const path = new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url)
|
|
assert.ok(existsSync(path))
|
|
const d = parse(readFileSync(path, 'utf8')).descriptor
|
|
for (const id of ['basic', 'transport', 'load', 'delivery', 'remark'])
|
|
assert.match(d.template!.content, new RegExp(`id="mp-plan-${id}"`))
|
|
for (const prop of ['createDate', 'belongsCompany'])
|
|
assert.match(d.template!.content, new RegExp(`prop="${prop}"`))
|
|
compileScript(d, { id: 'r2' })
|
|
assert.deepEqual(
|
|
compileTemplate({ source: d.template!.content, filename: path.pathname, id: 'r2' }).errors,
|
|
[]
|
|
)
|
|
compileScript(descriptor, { id: 'parent' })
|
|
assert.deepEqual(
|
|
compileTemplate({ source: descriptor.template!.content, filename: file.pathname, id: 'parent' })
|
|
.errors,
|
|
[]
|
|
)
|
|
})
|
|
|
|
test('plan date applies to each stop and vehicle default/clear handlers remain functional', async () => {
|
|
const loadForm = ref({ loadTime: '' })
|
|
const deliveryForms = ref([
|
|
{ deliveryTime: 'old', id: 1 },
|
|
{ deliveryTime: 'old2', id: 2 }
|
|
])
|
|
const driver = ref(''),
|
|
escort = ref(''),
|
|
carrier = reactive({ carrierId: 'old' })
|
|
const bundle = ref<any>({ detail: { licensePlate: 'A' } })
|
|
const calls: string[] = []
|
|
const b = {
|
|
loadForm,
|
|
deliveryForms,
|
|
selectedDriverId: driver,
|
|
selectedEscortId: escort,
|
|
formData: carrier,
|
|
vehicleBundle: bundle,
|
|
pageLoadGeneration: 1,
|
|
assertPageLoadCurrent: () => {},
|
|
reapplyStopDibangByVehiclePlate: (plate: string) => calls.push(plate),
|
|
vehicleList: ref([{ id: 'v', carrierId: 'c' }]),
|
|
vehicleLoading: ref(false),
|
|
isPageLoadCurrent: () => true,
|
|
ensurePageSelectedOption: async () => {},
|
|
loadVehicleViewBundle: async () => ({
|
|
detail: { licensePlate: 'V', carrierId: 'carrier' },
|
|
personnelList: [
|
|
{ role: 'driver', personnelId: 'd', isDefault: 1 },
|
|
{ role: 'escort', personnelId: 'e', isDefault: 1 }
|
|
]
|
|
})
|
|
}
|
|
const f = evaluate(
|
|
['handlePlanDateChange', 'resolveDefaultDriver', 'resolveDefaultEscort', 'handleVehicleChange'],
|
|
b
|
|
)
|
|
f.handlePlanDateChange('2026-10-24')
|
|
assert.equal(loadForm.value.loadTime, '2026-10-24')
|
|
assert.deepEqual(
|
|
deliveryForms.value.map((x) => x.deliveryTime),
|
|
['2026-10-24', '2026-10-24']
|
|
)
|
|
await f.handleVehicleChange('v')
|
|
assert.equal(driver.value, 'd')
|
|
assert.equal(escort.value, 'e')
|
|
assert.equal(carrier.carrierId, 'carrier')
|
|
await f.handleVehicleChange('')
|
|
assert.equal(driver.value, '')
|
|
assert.equal(escort.value, '')
|
|
assert.equal(carrier.carrierId, '')
|
|
assert.equal(bundle.value, null)
|
|
assert.deepEqual(calls, ['V', ''])
|
|
})
|
|
|
|
test('desktop root and all pre-existing setup declarations are byte-identical to before R2', () => {
|
|
const baselinePath = '/tmp/mobile-r2-before/index.vue'
|
|
if (!existsSync(baselinePath)) return
|
|
const before = parse(readFileSync(baselinePath, 'utf8')).descriptor
|
|
const root = descriptor.template!.ast.children.filter((n: any) => n.type === 1)[1] as any
|
|
assert.equal(
|
|
root.loc.source.replace(' v-else\n', ''),
|
|
(before.template!.ast.children.find((n: any) => n.type === 1) as any).loc.source
|
|
)
|
|
const oldAst = ts.createSourceFile(
|
|
'before.ts',
|
|
before.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const old = oldAst.statements
|
|
.filter((n) => !ts.isImportDeclaration(n))
|
|
.map((n) => n.getText(oldAst))
|
|
const next = ast.statements.filter((n) => !ts.isImportDeclaration(n)).map((n) => n.getText(ast))
|
|
for (const declaration of old) {
|
|
// R3 adds opt-in metadata submit guard and stable delivery request identity.
|
|
// Their desktop behavior is exercised by mobile-pl-plan-load-state.test.ts.
|
|
if (
|
|
[
|
|
'async function handleSubmit(',
|
|
'async function performArrangeAfterSave(',
|
|
'async function handlePasteRecognizeDone(',
|
|
'async function handleProductConfirm(',
|
|
'async function openProductSelector(',
|
|
'async function handleLinePickerConfirm(',
|
|
'const productSelectorTarget =',
|
|
'function mirrorPlanProductsFromLoad(',
|
|
'function applyStopsFromPlan(',
|
|
'function enrichStopFormsDisplayFromPlanProducts(',
|
|
'function buildPlanCommonPayload(',
|
|
'async function loadPlanForEdit(',
|
|
'async function loadPlanForCopy(',
|
|
'async function handleOpened('
|
|
].some((prefix) => declaration.startsWith(prefix))
|
|
)
|
|
continue
|
|
if (
|
|
declaration.startsWith('watch(') &&
|
|
(declaration.includes('deliveryForms.value.map((row) => ({') ||
|
|
declaration.includes('() => loadForm.value.contracts'))
|
|
)
|
|
continue
|
|
assert.ok(
|
|
next.includes(declaration),
|
|
`unchanged pre-R2 declaration: ${declaration.slice(0, 100)}`
|
|
)
|
|
}
|
|
})
|
|
|
|
import * as vue from 'vue'
|
|
import * as vehicleCert from '../src/utils/pl-vehicle-cert'
|
|
import { getPersonnelCertMissingSuffix } from '../src/utils/pl-personnel-cert'
|
|
import { useMobilePickerList } from '../src/components/mp/useMobilePickerList'
|
|
import { getProductUnitLabel, TON_PRODUCT_UNIT_VALUES } from '../src/utils/crm-product-display'
|
|
function mobileHarness() {
|
|
const content = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const tree = ts.createSourceFile('mobile.ts', content, ts.ScriptTarget.Latest, true)
|
|
const code = tree.statements
|
|
.filter((n) => !ts.isImportDeclaration(n))
|
|
.map((n) => n.getText(tree))
|
|
.join('\n')
|
|
const calls: any[] = []
|
|
const validations: string[] = []
|
|
const mounts: (() => void)[] = [],
|
|
cleanups: (() => void)[] = []
|
|
const c = reactive({
|
|
validateBasicField: async (field: string) => {
|
|
validations.push(field)
|
|
},
|
|
pageReady: true,
|
|
formData: { vehicleId: 'v', products: [] },
|
|
loadForm: { products: [] },
|
|
deliveryForms: [],
|
|
dictStore: { getLabel: (_type: string, value: string) => value },
|
|
vehicleLoading: false,
|
|
driverList: [],
|
|
escortList: [],
|
|
selectedDriverId: '',
|
|
selectedEscortId: '',
|
|
userStore: { accessToken: 'a', getUserInfo: { id: 'a' } },
|
|
mpFooterSplit: {
|
|
primary: [{ key: 'draft' }, { key: 'save' }, { key: 'saveAndArrange' }],
|
|
more: [{ key: 'cancel' }]
|
|
}
|
|
})
|
|
const bindings = {
|
|
ref: vue.ref,
|
|
computed: vue.computed,
|
|
watch: vue.watch,
|
|
onScopeDispose: vue.onScopeDispose,
|
|
defineProps: () => ({ controller: c }),
|
|
nextTick: vue.nextTick,
|
|
onMounted: (fn: () => void) => mounts.push(fn),
|
|
onBeforeUnmount: (fn: () => void) => cleanups.push(fn),
|
|
useMobilePickerList,
|
|
getProductUnitLabel,
|
|
TON_PRODUCT_UNIT_VALUES,
|
|
...vehicleCert,
|
|
getPersonnelCertMissingSuffix,
|
|
personnelApi: {
|
|
list: (params: any) =>
|
|
new Promise((resolve, reject) => calls.push({ params, resolve, reject }))
|
|
},
|
|
pickBxPageResult: (x: any) => x
|
|
}
|
|
const scope = vue.effectScope()
|
|
const h = scope.run(() =>
|
|
new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
|
|
';return {openPersonnel,selectPersonnel,personnelKind,keyword,picker,rows,error,loading,footer,jump,active,syncScroll,headerHeight,navRef,choice,choiceActions,selectChoice,typeLabel,isBalanced,formatQty,quantitySummary:typeof quantitySummary === "undefined" ? undefined : quantitySummary,vehicleCertGroups,vehicleMissingHint,personnelMissingHint,keyboardOpen}'
|
|
)(...Object.values(bindings))
|
|
)
|
|
return {
|
|
...h,
|
|
validations,
|
|
c,
|
|
calls,
|
|
mount: () => mounts.forEach((fn) => fn()),
|
|
unmount: () => cleanups.forEach((fn) => fn()),
|
|
stop: () => scope.stop()
|
|
}
|
|
}
|
|
const tick = async () => {
|
|
await vue.nextTick()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
}
|
|
test('actual mobile setup keeps draft in footer and personnel query filters, pagination retry and stale writes', async () => {
|
|
const h = mobileHarness()
|
|
assert.deepEqual(
|
|
h.footer.value.primary.map((x: any) => x.key),
|
|
['draft', 'save', 'saveAndArrange']
|
|
)
|
|
assert.deepEqual(
|
|
h.footer.value.more.map((x: any) => x.key),
|
|
['cancel']
|
|
)
|
|
h.c.mpFooterSplit.primary = [{ key: 'save' }, { key: 'saveAndArrange' }]
|
|
assert.deepEqual(
|
|
h.footer.value.primary.map((x: any) => x.key),
|
|
['save', 'saveAndArrange']
|
|
)
|
|
assert.deepEqual(
|
|
h.footer.value.more.map((x: any) => x.key),
|
|
['cancel']
|
|
)
|
|
h.openPersonnel('driver')
|
|
assert.equal(h.calls[0].params.personnelType, 'driver')
|
|
assert.equal(h.calls[0].params.status, '1')
|
|
h.calls[0].resolve({ list: [{ id: 'd' }], total: 3 })
|
|
await tick()
|
|
h.picker.loadMore()
|
|
h.calls[1].reject(new Error('offline'))
|
|
await tick()
|
|
h.picker.retry()
|
|
assert.equal(h.calls[2].params.page, 2)
|
|
h.openPersonnel('escort')
|
|
assert.equal(h.calls[3].params.personnelTypeIn, 'escort,both')
|
|
h.calls[2].resolve({ list: [{ id: 'stale' }], total: 3 })
|
|
await tick()
|
|
assert.deepEqual(h.rows.value, [])
|
|
h.calls[3].resolve({ list: [{ id: 'e' }], total: 1 })
|
|
await tick()
|
|
h.selectPersonnel(h.rows.value[0])
|
|
assert.equal(h.c.selectedEscortId, 'e')
|
|
h.openPersonnel('driver')
|
|
const pending = h.calls.at(-1)
|
|
h.personnelKind.value = ''
|
|
pending.resolve({ list: [{ id: 'closed' }], total: 1 })
|
|
await tick()
|
|
assert.deepEqual(h.rows.value, [])
|
|
h.c.formData.vehicleId = ''
|
|
h.openPersonnel('driver')
|
|
assert.equal(h.personnelKind.value, '')
|
|
h.stop()
|
|
})
|
|
test('actual mobile search uses server q and rejects account/unmount responses', async () => {
|
|
const h = mobileHarness()
|
|
h.openPersonnel('driver')
|
|
h.keyword.value = '李'
|
|
const request = h.picker.fetchList(true)
|
|
assert.equal(h.calls.at(-1).params.q, '李')
|
|
const old = h.calls.at(-1)
|
|
h.c.userStore.accessToken = 'b'
|
|
old.resolve({ list: [{ id: 'old' }], total: 1 })
|
|
await request
|
|
assert.deepEqual(h.rows.value, [])
|
|
const latest = h.calls.at(-1)
|
|
h.stop()
|
|
latest.resolve({ list: [{ id: 'unmounted' }], total: 1 })
|
|
await tick()
|
|
assert.deepEqual(h.rows.value, [])
|
|
})
|
|
|
|
test('changing basic Vant models invokes original form field validation', async () => {
|
|
const h = mobileHarness()
|
|
h.c.formData.createDate = '2026-10-23'
|
|
h.c.formData.belongsCompany = 'shanxia'
|
|
await tick()
|
|
assert.deepEqual(h.validations, ['createDate', 'belongsCompany'])
|
|
h.stop()
|
|
})
|
|
test('manual scrolling resets first anchor and highlights final section at page bottom', () => {
|
|
const h = mobileHarness()
|
|
const oldWindow = globalThis.window,
|
|
oldDocument = globalThis.document
|
|
const positions: Record<string, number> = {
|
|
basic: 120,
|
|
transport: 400,
|
|
load: 700,
|
|
delivery: 900,
|
|
remark: 1100
|
|
}
|
|
try {
|
|
globalThis.window = { innerHeight: 700, scrollY: 0 } as any
|
|
globalThis.document = {
|
|
documentElement: { scrollHeight: 1500 },
|
|
getElementById: (id: string) => ({
|
|
getBoundingClientRect: () => ({ top: positions[id.replace('mp-plan-', '')] })
|
|
})
|
|
} as any
|
|
h.navRef.value = { getBoundingClientRect: () => ({ bottom: 100 }) }
|
|
h.active.value = 'delivery'
|
|
h.syncScroll()
|
|
assert.equal(h.active.value, 'basic')
|
|
positions.basic = -800
|
|
positions.transport = -500
|
|
positions.load = -100
|
|
positions.delivery = 50
|
|
positions.remark = 500
|
|
globalThis.window.scrollY = 700
|
|
h.syncScroll()
|
|
assert.equal(h.active.value, 'delivery')
|
|
globalThis.window.scrollY = 800
|
|
h.syncScroll()
|
|
assert.equal(h.active.value, 'remark')
|
|
} finally {
|
|
globalThis.window = oldWindow
|
|
globalThis.document = oldDocument
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('document type shows an empty value without a no-selection option', () => {
|
|
const h = mobileHarness()
|
|
try {
|
|
h.choice.value = 'type'
|
|
assert.deepEqual(h.choiceActions.value, [
|
|
{ name: '直销单', value: 'direct' },
|
|
{ name: '入库单', value: 'inbound' },
|
|
{ name: '出库单', value: 'outbound' }
|
|
])
|
|
for (const value of ['', null, undefined]) {
|
|
h.c.formData.planType = value
|
|
assert.equal(h.typeLabel.value, '')
|
|
}
|
|
h.selectChoice({ value: 'inbound' })
|
|
assert.equal(h.c.formData.planType, 'inbound')
|
|
assert.equal(h.typeLabel.value, '入库单')
|
|
assert.equal(h.choice.value, '')
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
import { ActionSheet } from 'vant'
|
|
test('real Vant ActionSheet click changes company without writing document type', async () => {
|
|
const h = mobileHarness()
|
|
h.c.formData.belongsCompany = 'shanxia'
|
|
h.c.formData.planType = 'direct'
|
|
h.choice.value = 'company'
|
|
const source = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
|
|
).descriptor.template!.content
|
|
const sheet = source.match(/<van-action-sheet[\s\S]*?\/>/)![0]
|
|
const vnode = vue.compile(sheet)(vue.proxyRefs(h), [])
|
|
const events: string[] = []
|
|
const render = (ActionSheet as any).setup(
|
|
{
|
|
actions: h.choiceActions.value,
|
|
closeOnClickAction:
|
|
('close-on-click-action' in vnode.props || 'closeOnClickAction' in vnode.props) &&
|
|
(vnode.props['close-on-click-action'] ?? vnode.props.closeOnClickAction) !== false
|
|
},
|
|
{
|
|
slots: {},
|
|
emit: (event: string, ...args: any[]) => {
|
|
events.push(event)
|
|
vnode.props[event === 'select' ? 'onSelect' : 'onUpdate:show']?.(...args)
|
|
}
|
|
}
|
|
)
|
|
const tree = render().children.default()
|
|
function buttons(node: any): any[] {
|
|
if (Array.isArray(node)) return node.flatMap(buttons)
|
|
if (!node) return []
|
|
return [...(node.type === 'button' ? [node] : []), ...buttons(node.children)]
|
|
}
|
|
buttons(tree)[1].props.onClick()
|
|
await tick()
|
|
assert.equal(h.c.formData.belongsCompany, 'yundong')
|
|
assert.equal(h.c.formData.planType, 'direct')
|
|
assert.ok(events.includes('select'))
|
|
h.stop()
|
|
})
|
|
|
|
test('quantity summary follows source rows and imbalance remains a warning', () => {
|
|
const h = mobileHarness()
|
|
h.c.formData.products = [{ quantity: 10, unit: '吨' }] as any
|
|
h.c.loadForm.products = [{ planQty: 10, unit: '吨' }] as any
|
|
h.c.deliveryForms = [{ products: [{ planQty: 9, unit: '吨' }] }] as any
|
|
assert.equal(h.isBalanced.value, false)
|
|
assert.equal(h.formatQty(1.12349), '1.123')
|
|
const actions = h.footer.value.primary.map((x: any) => x.key)
|
|
;(h.c.deliveryForms as any)[0].products[0].planQty = 10
|
|
assert.equal(h.isBalanced.value, true)
|
|
assert.deepEqual(
|
|
h.footer.value.primary.map((x: any) => x.key),
|
|
actions
|
|
)
|
|
h.stop()
|
|
})
|
|
test('vehicle certificates retain owner scope, main/trailer plates and missing hints', () => {
|
|
const h = mobileHarness()
|
|
h.c.vehicleBundle = {
|
|
detail: {
|
|
id: 'v',
|
|
vehicleType: 'self',
|
|
licensePlate: '主牌',
|
|
trailerNo: '挂牌',
|
|
certList: [
|
|
{
|
|
id: 'head',
|
|
ownerId: 'v',
|
|
vehiclePart: 'head',
|
|
certName: '行驶证',
|
|
expireDate: '2030-01-01'
|
|
},
|
|
{
|
|
id: 'trailer',
|
|
ownerId: 'v',
|
|
vehiclePart: 'trailer',
|
|
certName: '行驶证',
|
|
expireDate: '2030-01-01'
|
|
},
|
|
{ id: 'other', ownerId: 'other', vehiclePart: 'head', certName: '保险单' }
|
|
]
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
h.vehicleCertGroups.value.map((g: any) => [g.label, g.certs.map((x: any) => x.id)]),
|
|
[
|
|
['主车 · 主牌', ['head']],
|
|
['挂车 · 挂牌', ['trailer']]
|
|
]
|
|
)
|
|
assert.equal(h.vehicleMissingHint.value, '(证书缺失)')
|
|
assert.equal(h.personnelMissingHint({ role: 'driver', certList: [] }), '(证书缺失)')
|
|
h.c.vehicleBundle.detail.vehicleType = 'carrier'
|
|
assert.equal(h.personnelMissingHint({ role: 'driver', certList: [] }), '')
|
|
h.c.vehicleBundle.detail.trailerNo = '货拉拉'
|
|
assert.equal(h.vehicleCertGroups.value.length, 1)
|
|
h.stop()
|
|
})
|
|
|
|
import { ElForm, ElButton, ElIcon, formContextKey } from 'element-plus'
|
|
import { renderToString } from 'vue/server-renderer'
|
|
test('actual load/delivery slots provide top-label Element form context to original sections', async () => {
|
|
const mobileRoot = descriptor.template!.ast.children.find((n: any) => n.type === 1) as any
|
|
for (const name of ['load', 'delivery']) {
|
|
const slot = mobileRoot.children.find(
|
|
(n: any) =>
|
|
n.type === 1 && n.props.some((p: any) => p.name === 'slot' && p.arg?.content === name)
|
|
)
|
|
const form = slot.children.find((n: any) => n.type === 1)
|
|
const observed: string[] = []
|
|
const section = vue.defineComponent({
|
|
setup() {
|
|
const context = vue.inject(formContextKey)
|
|
observed.push(context?.labelPosition || 'missing')
|
|
return () => vue.h('span', 'section')
|
|
}
|
|
})
|
|
const app = vue.createSSRApp({
|
|
setup: () => ({
|
|
mobileController: { setLoadSectionRef() {}, setDeliverySectionRef() {} },
|
|
loadForm: { products: [] },
|
|
deliveryForms: [{}],
|
|
isPageVariant: true,
|
|
planId: '',
|
|
currentVehiclePlate: '',
|
|
loadContractProductRows: [],
|
|
openCustomerSelector() {},
|
|
openProductSelector() {},
|
|
deliveryActiveIndex: 0,
|
|
handleAddDelivery() {},
|
|
handleMoveDelivery() {},
|
|
handleRemoveDelivery() {},
|
|
planQtyUnitLabel: '吨',
|
|
showDeliverySplitHint: false,
|
|
contractProductRowsCache: {}
|
|
}),
|
|
render: vue.compile(form.loc.source)
|
|
})
|
|
app.component('ElForm', ElForm)
|
|
app.component('ElButton', ElButton)
|
|
app.component('ElIcon', ElIcon)
|
|
app.component('PlPlanLoadSection', section)
|
|
app.component('PlPlanDeliverySection', section)
|
|
for (const icon of ['Plus', 'Top', 'Bottom', 'Delete'])
|
|
app.component(icon, { render: () => null })
|
|
await renderToString(app)
|
|
assert.deepEqual(observed, ['top'])
|
|
}
|
|
})
|
|
|
|
test('real mobile lifecycle resets viewport baseline, hides focused keyboard and restores after blur', async () => {
|
|
const oldWindow = globalThis.window,
|
|
oldDocument = globalThis.document
|
|
const events = new Map<string, () => void>()
|
|
const surface = (prefix: string) => ({
|
|
addEventListener: (name: string, fn: () => void) => events.set(prefix + name, fn),
|
|
removeEventListener: (name: string) => events.delete(prefix + name)
|
|
})
|
|
const vv = { height: 1000, ...surface('visual:') }
|
|
const win = {
|
|
innerHeight: 1000,
|
|
innerWidth: 1440,
|
|
scrollY: 0,
|
|
visualViewport: vv,
|
|
...surface('window:')
|
|
}
|
|
const doc = {
|
|
activeElement: { tagName: 'BODY' },
|
|
querySelector: () => null,
|
|
getElementById: () => null,
|
|
documentElement: { scrollHeight: 2000 },
|
|
...surface('document:')
|
|
}
|
|
let h: any
|
|
try {
|
|
globalThis.window = win as any
|
|
globalThis.document = doc as any
|
|
h = mobileHarness()
|
|
h.mount()
|
|
win.innerWidth = 375
|
|
win.innerHeight = 812
|
|
vv.height = 812
|
|
events.get('window:resize')?.()
|
|
events.get('visual:resize')?.()
|
|
h.syncScroll()
|
|
assert.equal(h.keyboardOpen.value, false, 'normal viewport resize must not hide footer')
|
|
doc.activeElement = { tagName: 'INPUT' }
|
|
events.get('document:focusin')?.()
|
|
vv.height = 480
|
|
events.get('visual:resize')?.()
|
|
assert.equal(h.keyboardOpen.value, true, 'focused keyboard hides footer')
|
|
doc.activeElement = { tagName: 'BODY' }
|
|
events.get('document:focusout')?.()
|
|
await tick()
|
|
assert.equal(h.keyboardOpen.value, false, 'blur restores footer even before animation ends')
|
|
vv.height = 812
|
|
events.get('visual:resize')?.()
|
|
assert.equal(h.keyboardOpen.value, false)
|
|
h.unmount()
|
|
assert.equal(events.size, 0, 'unmount removes every viewport/focus listener')
|
|
} finally {
|
|
h?.stop()
|
|
globalThis.window = oldWindow
|
|
globalThis.document = oldDocument
|
|
}
|
|
})
|
|
|
|
import * as sass from 'sass'
|
|
import { createRequire } from 'node:module'
|
|
test('compiled mobile and teleported sheet CSS resolve actual Vant primary button background and border to brand green', () => {
|
|
const require = createRequire(import.meta.url)
|
|
const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
|
|
const rootCss = postcss.parse(
|
|
readFileSync(require.resolve('vant/lib/style/css-variables.css'), 'utf8') +
|
|
'\n' +
|
|
readFileSync(require.resolve('vant/lib/button/index.css'), 'utf8')
|
|
)
|
|
const localCss = postcss.parse(
|
|
sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
|
|
)
|
|
const resolveValue = (value: string, vars: Record<string, string>): string =>
|
|
value.replace(/var\((--[^),]+)(?:,([^)]*))?\)/g, (_, key, fallback) =>
|
|
resolveValue(vars[key] ?? fallback ?? '', vars)
|
|
)
|
|
const rootVars: Record<string, string> = {}
|
|
rootCss.walkRules((rule) => {
|
|
if (rule.selector.includes(':root'))
|
|
rule.walkDecls((d) => {
|
|
if (d.prop.startsWith('--')) rootVars[d.prop] = d.value
|
|
})
|
|
})
|
|
// Custom properties inherit computed values; a child-only primary override cannot change root-computed button tokens.
|
|
const inherited = Object.fromEntries(
|
|
Object.entries(rootVars).map(([key, value]) => [key, resolveValue(value, rootVars)])
|
|
)
|
|
for (const selector of ['.mobile-pl-plan-form', '.mobile-pl-plan-overlay']) {
|
|
const vars = { ...inherited }
|
|
localCss.walkRules((rule) => {
|
|
if (rule.selectors.includes(selector))
|
|
rule.walkDecls((d) => {
|
|
if (d.prop.startsWith('--')) vars[d.prop] = d.value
|
|
})
|
|
})
|
|
const values: Record<string, string> = {}
|
|
rootCss.walkRules((rule) => {
|
|
if (rule.selectors.includes('.van-button--primary'))
|
|
rule.walkDecls((d) => {
|
|
values[d.prop] = resolveValue(d.value, vars)
|
|
})
|
|
})
|
|
assert.equal(values.background, '#0a9f6d', selector + ' actual button background')
|
|
assert.match(values.border, /solid #0a9f6d$/, selector + ' actual button border')
|
|
}
|
|
})
|
|
|
|
test('compiled sheet styles keep 44px picker targets with compact search text', () => {
|
|
const require = createRequire(import.meta.url)
|
|
const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
|
|
const css = postcss.parse(
|
|
readFileSync(require.resolve('vant/lib/field/index.css'), 'utf8') +
|
|
'\n' +
|
|
sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
|
|
)
|
|
const cascade = (selectors: string[]) => {
|
|
const result: Record<string, string> = {}
|
|
css.walkRules((rule) => {
|
|
if (rule.selectors.some((s: string) => selectors.includes(s)))
|
|
rule.walkDecls((d) => {
|
|
result[d.prop] = d.value
|
|
})
|
|
})
|
|
return result
|
|
}
|
|
const clear = cascade(['.mobile-pl-plan-overlay .mobile-pl-plan-search-clear'])
|
|
assert.ok(parseFloat(clear['min-width'] || '0') >= 44, 'clear hit width must be at least 44')
|
|
assert.ok(parseFloat(clear['min-height'] || '0') >= 44, 'clear hit height must be at least 44')
|
|
assert.equal(clear.display, 'flex')
|
|
assert.equal(clear['align-items'], 'center')
|
|
const body = cascade(['.van-field__body'])
|
|
assert.equal(body.display, 'flex')
|
|
assert.equal(body['align-items'], 'center')
|
|
const input = cascade([
|
|
'.van-field__control',
|
|
'.mobile-pl-plan-overlay .van-search .van-field__control'
|
|
])
|
|
assert.equal(parseFloat(input['min-height'] || '0'), 24)
|
|
const row = cascade(['.mobile-pl-plan-overlay .van-cell--clickable'])
|
|
assert.ok(parseFloat(row['min-height'] || '0') >= 44)
|
|
})
|
|
|
|
test('compiled mobile certificate layout gives labels, values and attachment cells full width', () => {
|
|
const require = createRequire(import.meta.url)
|
|
const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
|
|
const css = postcss.parse(
|
|
sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
|
|
)
|
|
for (const part of ['', ' tbody', ' tr', ' th', ' td']) {
|
|
const selector = '.mobile-pl-plan-overlay .cert-view-card .view-kv-table' + part
|
|
const computed: Record<string, string> = {}
|
|
css.walkRules((rule) => {
|
|
if (rule.selectors.includes(selector))
|
|
rule.walkDecls((d) => {
|
|
computed[d.prop] = d.value
|
|
})
|
|
})
|
|
assert.equal(
|
|
computed.display,
|
|
'block',
|
|
selector + ' must stack instead of dividing a table row'
|
|
)
|
|
assert.equal(computed.width, '100%', selector + ' must span the certificate card')
|
|
assert.equal(computed['box-sizing'], 'border-box')
|
|
}
|
|
// Actual reused attachment table carries the same class; the full-width rules apply to it too.
|
|
const template = parse(
|
|
readFileSync(
|
|
new URL('../src/components/business/pl-cert-view-list/index.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
).descriptor.template!.content
|
|
const compiled = vue.compile(template)
|
|
const app = vue.createSSRApp({
|
|
render: compiled,
|
|
setup: () => ({
|
|
certList: [
|
|
{ id: 'x', certName: '身份证', certNo: '123456789012345678', attachmentUrls: '/test.png' }
|
|
],
|
|
layout: 'card',
|
|
tableMode: 'pair',
|
|
attachmentColspan: 1,
|
|
buildCertViewFields: (cert: any) => [{ label: '证号', value: cert.certNo }]
|
|
})
|
|
})
|
|
app.component('ViewKvTable', {
|
|
props: ['fields'],
|
|
setup: (props: any) => () => vue.h('div', props.fields[0].value)
|
|
})
|
|
app.component('AttachmentUploadField', {
|
|
props: ['modelValue'],
|
|
setup: (props: any) => () => vue.h('span', props.modelValue)
|
|
})
|
|
return renderToString(app).then((html) => {
|
|
assert.match(html, /123456789012345678/)
|
|
assert.match(html, /cert-view-attachment-table view-kv-table/)
|
|
assert.match(html, /<th>附件<\/th>/)
|
|
assert.match(html, /\/test.png/)
|
|
})
|
|
})
|
|
|
|
test('rendered personnel search clear button handles click and reloads unfiltered rows', async () => {
|
|
const h = mobileHarness()
|
|
h.openPersonnel('driver')
|
|
h.calls[0].resolve({ list: [{ id: 'a' }], total: 1 })
|
|
await tick()
|
|
h.keyword.value = '沈卫斌'
|
|
const filtered = h.picker.fetchList(true)
|
|
h.calls.at(-1).resolve({ list: [{ id: 'filtered' }], total: 1 })
|
|
await filtered
|
|
const source = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
|
|
).descriptor.template!.content
|
|
const template = source.match(/<van-search[\s\S]*?(?:\/>|<\/van-search>)/)![0]
|
|
const vnode = vue.compile(template)(vue.proxyRefs(h), [])
|
|
assert.equal(vnode.props.clearable, false, 'disable Vant touch-only clear')
|
|
const button = vnode.children['right-icon']().find((node: any) => node.type === 'button')
|
|
assert.equal(button.props['aria-label'], '清空人员搜索')
|
|
button.props.onClick()
|
|
assert.equal(h.keyword.value, '')
|
|
await new Promise((resolve) => setTimeout(resolve, 350))
|
|
assert.equal(h.calls.at(-1).params.q, undefined)
|
|
h.calls.at(-1).resolve({ list: [{ id: 'a' }, { id: 'b' }], total: 2 })
|
|
await tick()
|
|
assert.deepEqual(
|
|
h.rows.value.map((row: any) => row.id),
|
|
['a', 'b']
|
|
)
|
|
h.stop()
|
|
})
|
|
|
|
async function renderedQuantitySummary(h: ReturnType<typeof mobileHarness>) {
|
|
const content = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
|
|
).descriptor.template!.content
|
|
const template = content.match(/<div class="mobile-pl-plan-balance"[\s\S]*?<\/div>/)![0]
|
|
const app = vue.createSSRApp({
|
|
setup: () => ({
|
|
c: h.c,
|
|
quantitySummary: h.quantitySummary,
|
|
isBalanced: h.isBalanced,
|
|
formatQty: h.formatQty
|
|
}),
|
|
render: vue.compile(template)
|
|
})
|
|
app.component('VanTag', {
|
|
setup:
|
|
(_, { slots }) =>
|
|
() =>
|
|
vue.h('span', slots.default?.())
|
|
})
|
|
return (await renderToString(app))
|
|
.replace(/<[^>]*>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
for (const qty of [0, 1])
|
|
test(`actual mobile template renders delivery-only draft ${qty} 个 without guessed tonnes`, async () => {
|
|
const h = mobileHarness()
|
|
Object.assign(h.c, {
|
|
planTotalQty: 0,
|
|
loadTotalQty: 0,
|
|
deliveryTotalQty: qty,
|
|
planQtyUnitLabel: '吨'
|
|
})
|
|
h.c.deliveryForms = [{ products: [{ unit: '个', planQty: qty }] }] as any
|
|
const text = await renderedQuantitySummary(h)
|
|
assert.match(text, new RegExp(`送货合计 ${qty}个`))
|
|
assert.match(text, /计划总量 0 装货合计 0/)
|
|
assert.doesNotMatch(text, /吨/)
|
|
h.stop()
|
|
})
|
|
|
|
test('actual mobile summary groups mixed units and does not equate different quantities by scalar sum', async () => {
|
|
const h = mobileHarness()
|
|
h.c.formData.products = [
|
|
{ unit: '吨', quantity: 1 },
|
|
{ unit: '个', quantity: 2 }
|
|
] as any
|
|
h.c.loadForm.products = [
|
|
{ unit: '吨', planQty: 1 },
|
|
{ unit: '个', planQty: 2 }
|
|
] as any
|
|
h.c.deliveryForms = [
|
|
{ products: [{ unit: '吨', planQty: 2 }] },
|
|
{ products: [{ unit: '个', planQty: 1 }] }
|
|
] as any
|
|
Object.assign(h.c, {
|
|
planTotalQty: 3,
|
|
loadTotalQty: 3,
|
|
deliveryTotalQty: 3,
|
|
planQtyUnitLabel: '吨',
|
|
deliveryQtyBreakdown: '2 + 1 = 3'
|
|
})
|
|
const text = await renderedQuantitySummary(h)
|
|
assert.match(text, /计划总量 1吨 \+ 2个/)
|
|
assert.match(text, /装货合计 1吨 \+ 2个/)
|
|
assert.match(text, /送货合计 2吨 \+ 1个/)
|
|
assert.match(text, /数量不一致,仍可保存/)
|
|
assert.doesNotMatch(text, /2 \+ 1 = 3|3吨/)
|
|
h.stop()
|
|
})
|
|
|
|
test('actual mobile template keeps unknown units explicit and sums same units to three decimals', async () => {
|
|
const h = mobileHarness()
|
|
h.c.formData.products = [{ unit: '', quantity: 1.125 }] as any
|
|
h.c.loadForm.products = [{ unit: '', planQty: 1.125 }] as any
|
|
h.c.deliveryForms = [{ products: [{ unit: '', planQty: 1.125 }] }] as any
|
|
let text = await renderedQuantitySummary(h)
|
|
assert.match(text, /1.125(单位未填)/)
|
|
assert.match(text, /单位未填,暂无法核对平衡/)
|
|
assert.doesNotMatch(text, /数量平衡|吨/)
|
|
h.c.formData.products = [
|
|
{ unit: '个', quantity: 1.125 },
|
|
{ unit: '个', quantity: 2.001 }
|
|
] as any
|
|
h.c.loadForm.products = [{ unit: '个', planQty: 3.126 }] as any
|
|
h.c.deliveryForms = [
|
|
{ products: [{ unit: '个', planQty: 1.125 }] },
|
|
{ products: [{ unit: '个', planQty: 2.001 }] }
|
|
] as any
|
|
text = await renderedQuantitySummary(h)
|
|
assert.match(text, /计划总量 3.126个 装货合计 3.126个 送货合计 3.126个/)
|
|
assert.match(text, /数量平衡/)
|
|
assert.match(text, /1.125个.*2.001个.*3.126个/)
|
|
h.stop()
|
|
})
|
|
|
|
for (const [code, label, hasDictionary] of [
|
|
['dun', '吨', true],
|
|
['dun', '吨', false],
|
|
['jian', '件', true]
|
|
] as const) {
|
|
test(`actual SFC normalizes ${code}/${label} with dictionary ${hasDictionary} for display and balance`, async () => {
|
|
const h = mobileHarness()
|
|
h.c.dictStore.getLabel = (type, value) =>
|
|
hasDictionary && type === 'crm_product_unit' && value === code ? label : value
|
|
h.c.formData.products = [{ unit: code, quantity: 1 }] as any
|
|
h.c.loadForm.products = [{ unit: code, planQty: 1 }] as any
|
|
h.c.deliveryForms = [
|
|
{ products: [{ unit: label, planQty: 0.25 }] },
|
|
{ products: [{ unit: code, planQty: 0.75 }] }
|
|
] as any
|
|
const text = await renderedQuantitySummary(h)
|
|
assert.match(text, new RegExp(`计划总量 1${label} 装货合计 1${label} 送货合计 1${label}`))
|
|
assert.match(text, /✓ 数量平衡/)
|
|
assert.match(text, new RegExp(`0.25${label}.*0.75${label}.*1${label}`))
|
|
assert.ok(!text.includes(code))
|
|
// Pure display normalization must not rewrite any business unit value.
|
|
assert.equal((h.c.formData.products[0] as any).unit, code)
|
|
assert.equal((h.c.loadForm.products[0] as any).unit, code)
|
|
assert.equal((h.c.deliveryForms[0] as any).products[0].unit, label)
|
|
h.stop()
|
|
})
|
|
}
|