Files
shanxia2.0/web-admin/scripts/mobile-pl-plan-form.test.ts
T
wuzilongandCursor bde19c0644 feat: 锁定执行中计划并按装卸明细差量归档库存,补齐派车日历数量与酸碱类型。
执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 07:59:53 +08:00

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()
})
}