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) { 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 = { 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({ 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 = { 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(//)![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 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 => value.replace(/var\((--[^),]+)(?:,([^)]*))?\)/g, (_, key, fallback) => resolveValue(vars[key] ?? fallback ?? '', vars) ) const rootVars: Record = {} 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 = {} 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 = {} 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 = {} 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>/) 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>)/)![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) { const content = parse( readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8') ).descriptor.template!.content const template = content.match(/
/)![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() }) }