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