mobile-pl-plan-form.test.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. import assert from 'node:assert/strict'
  2. import { readFileSync, existsSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import { computed, ref, reactive } from 'vue'
  7. const file = new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url)
  8. const source = readFileSync(file, 'utf8')
  9. const descriptor = parse(source).descriptor
  10. const ast = ts.createSourceFile(
  11. 'form.ts',
  12. descriptor.scriptSetup!.content,
  13. ts.ScriptTarget.Latest,
  14. true
  15. )
  16. function evaluate(names: string[], bindings: Record<string, any>) {
  17. const nodes = names.map((name) => {
  18. const node = ast.statements.find((n) =>
  19. ts.isFunctionDeclaration(n)
  20. ? n.name?.text === name
  21. : ts.isVariableStatement(n) &&
  22. n.declarationList.declarations.some((d) => d.name.getText(ast) === name)
  23. )
  24. assert.ok(node, `production declaration ${name}`)
  25. return node.getText(ast)
  26. })
  27. return new Function(
  28. ...Object.keys(bindings),
  29. ts.transpile(nodes.join('\n'), { target: ts.ScriptTarget.ESNext }) +
  30. `;return {${names.join(',')}}`
  31. )(...Object.values(bindings))
  32. }
  33. test('mobile has a separate first root; desktop root retained', () => {
  34. const roots = descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[]
  35. assert.equal(roots[0].tag, 'MobilePlPlanForm')
  36. assert.match(roots[1].loc.source, /v-else/)
  37. })
  38. test('actual controller preserves ref identity and handler references', async () => {
  39. const declaration = ast.statements.find(
  40. (n) =>
  41. ts.isVariableStatement(n) &&
  42. n.declarationList.declarations.some((d) => d.name.getText(ast) === 'mobileController')
  43. )
  44. assert.ok(declaration)
  45. const text = declaration.getText(ast)
  46. const properties = text.match(/reactive\(\{([\s\S]*)\}\)/)![1]
  47. const names = properties
  48. .split(',')
  49. .map((x) => x.trim())
  50. .filter((x) => /^\w+$/.test(x))
  51. const bindings: Record<string, any> = {
  52. reactive,
  53. basicFormRef: ref(null),
  54. loadSectionRef: ref(null),
  55. deliverySectionRef: ref(null)
  56. }
  57. names.forEach((n) => {
  58. bindings[n] = ref(n)
  59. })
  60. const c = evaluate(['mobileController'], bindings).mobileController
  61. const fields: string[] = []
  62. const validator = {
  63. validate: () => true,
  64. validateField: async (field: string) => {
  65. fields.push(field)
  66. }
  67. }
  68. c.setBasicFormRef(validator)
  69. assert.equal(bindings.basicFormRef.value.validate, validator.validate)
  70. await c.validateBasicField('belongsCompany')
  71. await c.validateBasicField('createDate')
  72. assert.deepEqual(fields, ['belongsCompany', 'createDate'])
  73. c.setLoadSectionRef(validator)
  74. assert.equal(bindings.loadSectionRef.value.validate, validator.validate)
  75. })
  76. test('original required rules and button matrix execute', () => {
  77. const props = reactive({ planId: '', planEditOnly: false, openForArrange: false })
  78. const status = ref('pending')
  79. const bindings = {
  80. computed,
  81. props,
  82. isEdit: computed(() => !!props.planId),
  83. loadedTransportStatus: status,
  84. loadedFollowStatus: ref('pending_arrange')
  85. }
  86. const c = evaluate(
  87. [
  88. 'basicRules',
  89. 'showSaveButton',
  90. 'showDraftButton',
  91. 'showSaveAndArrangeButton',
  92. 'isRearrangeMode',
  93. 'showRearrangeButton'
  94. ],
  95. bindings
  96. )
  97. assert.deepEqual(Object.keys(c.basicRules), ['createDate', 'belongsCompany'])
  98. assert.equal(c.showSaveButton.value, true)
  99. props.planId = 'p1'
  100. status.value = 'arranged'
  101. assert.equal(c.showSaveButton.value, false)
  102. props.openForArrange = true
  103. assert.equal(c.showRearrangeButton.value, true)
  104. props.planEditOnly = true
  105. assert.equal(c.showSaveButton.value, true)
  106. assert.equal(c.showDraftButton.value, false)
  107. assert.equal(c.showSaveAndArrangeButton.value, false)
  108. })
  109. test('mobile validator registers basic fields and all five sections; SFC compiles', () => {
  110. const path = new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url)
  111. assert.ok(existsSync(path))
  112. const d = parse(readFileSync(path, 'utf8')).descriptor
  113. for (const id of ['basic', 'transport', 'load', 'delivery', 'remark'])
  114. assert.match(d.template!.content, new RegExp(`id="mp-plan-${id}"`))
  115. for (const prop of ['createDate', 'belongsCompany'])
  116. assert.match(d.template!.content, new RegExp(`prop="${prop}"`))
  117. compileScript(d, { id: 'r2' })
  118. assert.deepEqual(
  119. compileTemplate({ source: d.template!.content, filename: path.pathname, id: 'r2' }).errors,
  120. []
  121. )
  122. compileScript(descriptor, { id: 'parent' })
  123. assert.deepEqual(
  124. compileTemplate({ source: descriptor.template!.content, filename: file.pathname, id: 'parent' })
  125. .errors,
  126. []
  127. )
  128. })
  129. test('plan date applies to each stop and vehicle default/clear handlers remain functional', async () => {
  130. const loadForm = ref({ loadTime: '' })
  131. const deliveryForms = ref([
  132. { deliveryTime: 'old', id: 1 },
  133. { deliveryTime: 'old2', id: 2 }
  134. ])
  135. const driver = ref(''),
  136. escort = ref(''),
  137. carrier = reactive({ carrierId: 'old' })
  138. const bundle = ref<any>({ detail: { licensePlate: 'A' } })
  139. const calls: string[] = []
  140. const b = {
  141. loadForm,
  142. deliveryForms,
  143. selectedDriverId: driver,
  144. selectedEscortId: escort,
  145. formData: carrier,
  146. vehicleBundle: bundle,
  147. pageLoadGeneration: 1,
  148. assertPageLoadCurrent: () => {},
  149. reapplyStopDibangByVehiclePlate: (plate: string) => calls.push(plate),
  150. vehicleList: ref([{ id: 'v', carrierId: 'c' }]),
  151. vehicleLoading: ref(false),
  152. isPageLoadCurrent: () => true,
  153. ensurePageSelectedOption: async () => {},
  154. loadVehicleViewBundle: async () => ({
  155. detail: { licensePlate: 'V', carrierId: 'carrier' },
  156. personnelList: [
  157. { role: 'driver', personnelId: 'd', isDefault: 1 },
  158. { role: 'escort', personnelId: 'e', isDefault: 1 }
  159. ]
  160. })
  161. }
  162. const f = evaluate(
  163. ['handlePlanDateChange', 'resolveDefaultDriver', 'resolveDefaultEscort', 'handleVehicleChange'],
  164. b
  165. )
  166. f.handlePlanDateChange('2026-10-24')
  167. assert.equal(loadForm.value.loadTime, '2026-10-24')
  168. assert.deepEqual(
  169. deliveryForms.value.map((x) => x.deliveryTime),
  170. ['2026-10-24', '2026-10-24']
  171. )
  172. await f.handleVehicleChange('v')
  173. assert.equal(driver.value, 'd')
  174. assert.equal(escort.value, 'e')
  175. assert.equal(carrier.carrierId, 'carrier')
  176. await f.handleVehicleChange('')
  177. assert.equal(driver.value, '')
  178. assert.equal(escort.value, '')
  179. assert.equal(carrier.carrierId, '')
  180. assert.equal(bundle.value, null)
  181. assert.deepEqual(calls, ['V', ''])
  182. })
  183. test('desktop root and all pre-existing setup declarations are byte-identical to before R2', () => {
  184. const baselinePath = '/tmp/mobile-r2-before/index.vue'
  185. if (!existsSync(baselinePath)) return
  186. const before = parse(readFileSync(baselinePath, 'utf8')).descriptor
  187. const root = descriptor.template!.ast.children.filter((n: any) => n.type === 1)[1] as any
  188. assert.equal(
  189. root.loc.source.replace(' v-else\n', ''),
  190. (before.template!.ast.children.find((n: any) => n.type === 1) as any).loc.source
  191. )
  192. const oldAst = ts.createSourceFile(
  193. 'before.ts',
  194. before.scriptSetup!.content,
  195. ts.ScriptTarget.Latest,
  196. true
  197. )
  198. const old = oldAst.statements
  199. .filter((n) => !ts.isImportDeclaration(n))
  200. .map((n) => n.getText(oldAst))
  201. const next = ast.statements.filter((n) => !ts.isImportDeclaration(n)).map((n) => n.getText(ast))
  202. for (const declaration of old) {
  203. // R3 adds opt-in metadata submit guard and stable delivery request identity.
  204. // Their desktop behavior is exercised by mobile-pl-plan-load-state.test.ts.
  205. if (
  206. [
  207. 'async function handleSubmit(',
  208. 'async function performArrangeAfterSave(',
  209. 'async function handlePasteRecognizeDone(',
  210. 'async function handleProductConfirm(',
  211. 'async function openProductSelector(',
  212. 'async function handleLinePickerConfirm(',
  213. 'const productSelectorTarget =',
  214. 'function mirrorPlanProductsFromLoad(',
  215. 'function applyStopsFromPlan(',
  216. 'function enrichStopFormsDisplayFromPlanProducts(',
  217. 'function buildPlanCommonPayload(',
  218. 'async function loadPlanForEdit(',
  219. 'async function loadPlanForCopy(',
  220. 'async function handleOpened('
  221. ].some((prefix) => declaration.startsWith(prefix))
  222. )
  223. continue
  224. if (
  225. declaration.startsWith('watch(') &&
  226. (declaration.includes('deliveryForms.value.map((row) => ({') ||
  227. declaration.includes('() => loadForm.value.contracts'))
  228. )
  229. continue
  230. assert.ok(
  231. next.includes(declaration),
  232. `unchanged pre-R2 declaration: ${declaration.slice(0, 100)}`
  233. )
  234. }
  235. })
  236. import * as vue from 'vue'
  237. import * as vehicleCert from '../src/utils/pl-vehicle-cert'
  238. import { getPersonnelCertMissingSuffix } from '../src/utils/pl-personnel-cert'
  239. import { useMobilePickerList } from '../src/components/mp/useMobilePickerList'
  240. import { getProductUnitLabel, TON_PRODUCT_UNIT_VALUES } from '../src/utils/crm-product-display'
  241. function mobileHarness() {
  242. const content = parse(
  243. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  244. ).descriptor.scriptSetup!.content
  245. const tree = ts.createSourceFile('mobile.ts', content, ts.ScriptTarget.Latest, true)
  246. const code = tree.statements
  247. .filter((n) => !ts.isImportDeclaration(n))
  248. .map((n) => n.getText(tree))
  249. .join('\n')
  250. const calls: any[] = []
  251. const validations: string[] = []
  252. const mounts: (() => void)[] = [],
  253. cleanups: (() => void)[] = []
  254. const c = reactive({
  255. validateBasicField: async (field: string) => {
  256. validations.push(field)
  257. },
  258. pageReady: true,
  259. formData: { vehicleId: 'v', products: [] },
  260. loadForm: { products: [] },
  261. deliveryForms: [],
  262. dictStore: { getLabel: (_type: string, value: string) => value },
  263. vehicleLoading: false,
  264. driverList: [],
  265. escortList: [],
  266. selectedDriverId: '',
  267. selectedEscortId: '',
  268. userStore: { accessToken: 'a', getUserInfo: { id: 'a' } },
  269. mpFooterSplit: {
  270. primary: [{ key: 'draft' }, { key: 'save' }, { key: 'saveAndArrange' }],
  271. more: [{ key: 'cancel' }]
  272. }
  273. })
  274. const bindings = {
  275. ref: vue.ref,
  276. computed: vue.computed,
  277. watch: vue.watch,
  278. onScopeDispose: vue.onScopeDispose,
  279. defineProps: () => ({ controller: c }),
  280. nextTick: vue.nextTick,
  281. onMounted: (fn: () => void) => mounts.push(fn),
  282. onBeforeUnmount: (fn: () => void) => cleanups.push(fn),
  283. useMobilePickerList,
  284. getProductUnitLabel,
  285. TON_PRODUCT_UNIT_VALUES,
  286. ...vehicleCert,
  287. getPersonnelCertMissingSuffix,
  288. personnelApi: {
  289. list: (params: any) =>
  290. new Promise((resolve, reject) => calls.push({ params, resolve, reject }))
  291. },
  292. pickBxPageResult: (x: any) => x
  293. }
  294. const scope = vue.effectScope()
  295. const h = scope.run(() =>
  296. new Function(
  297. ...Object.keys(bindings),
  298. ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
  299. ';return {openPersonnel,selectPersonnel,personnelKind,keyword,picker,rows,error,loading,footer,jump,active,syncScroll,headerHeight,navRef,choice,choiceActions,selectChoice,isBalanced,formatQty,quantitySummary:typeof quantitySummary === "undefined" ? undefined : quantitySummary,vehicleCertGroups,vehicleMissingHint,personnelMissingHint,keyboardOpen}'
  300. )(...Object.values(bindings))
  301. )
  302. return {
  303. ...h,
  304. validations,
  305. c,
  306. calls,
  307. mount: () => mounts.forEach((fn) => fn()),
  308. unmount: () => cleanups.forEach((fn) => fn()),
  309. stop: () => scope.stop()
  310. }
  311. }
  312. const tick = async () => {
  313. await vue.nextTick()
  314. await Promise.resolve()
  315. await Promise.resolve()
  316. }
  317. test('actual mobile setup caps footer and personnel query filters, pagination retry and stale writes', async () => {
  318. const h = mobileHarness()
  319. assert.deepEqual(
  320. h.footer.value.primary.map((x: any) => x.key),
  321. ['save', 'saveAndArrange']
  322. )
  323. assert.deepEqual(
  324. h.footer.value.more.map((x: any) => x.key),
  325. ['cancel', 'draft']
  326. )
  327. h.openPersonnel('driver')
  328. assert.equal(h.calls[0].params.personnelType, 'driver')
  329. assert.equal(h.calls[0].params.status, '1')
  330. h.calls[0].resolve({ list: [{ id: 'd' }], total: 3 })
  331. await tick()
  332. h.picker.loadMore()
  333. h.calls[1].reject(new Error('offline'))
  334. await tick()
  335. h.picker.retry()
  336. assert.equal(h.calls[2].params.page, 2)
  337. h.openPersonnel('escort')
  338. assert.equal(h.calls[3].params.personnelTypeIn, 'escort,both')
  339. h.calls[2].resolve({ list: [{ id: 'stale' }], total: 3 })
  340. await tick()
  341. assert.deepEqual(h.rows.value, [])
  342. h.calls[3].resolve({ list: [{ id: 'e' }], total: 1 })
  343. await tick()
  344. h.selectPersonnel(h.rows.value[0])
  345. assert.equal(h.c.selectedEscortId, 'e')
  346. h.openPersonnel('driver')
  347. const pending = h.calls.at(-1)
  348. h.personnelKind.value = ''
  349. pending.resolve({ list: [{ id: 'closed' }], total: 1 })
  350. await tick()
  351. assert.deepEqual(h.rows.value, [])
  352. h.c.formData.vehicleId = ''
  353. h.openPersonnel('driver')
  354. assert.equal(h.personnelKind.value, '')
  355. h.stop()
  356. })
  357. test('actual mobile search uses server q and rejects account/unmount responses', async () => {
  358. const h = mobileHarness()
  359. h.openPersonnel('driver')
  360. h.keyword.value = '李'
  361. const request = h.picker.fetchList(true)
  362. assert.equal(h.calls.at(-1).params.q, '李')
  363. const old = h.calls.at(-1)
  364. h.c.userStore.accessToken = 'b'
  365. old.resolve({ list: [{ id: 'old' }], total: 1 })
  366. await request
  367. assert.deepEqual(h.rows.value, [])
  368. const latest = h.calls.at(-1)
  369. h.stop()
  370. latest.resolve({ list: [{ id: 'unmounted' }], total: 1 })
  371. await tick()
  372. assert.deepEqual(h.rows.value, [])
  373. })
  374. test('changing basic Vant models invokes original form field validation', async () => {
  375. const h = mobileHarness()
  376. h.c.formData.createDate = '2026-10-23'
  377. h.c.formData.belongsCompany = 'shanxia'
  378. await tick()
  379. assert.deepEqual(h.validations, ['createDate', 'belongsCompany'])
  380. h.stop()
  381. })
  382. test('manual scrolling resets first anchor and highlights final section at page bottom', () => {
  383. const h = mobileHarness()
  384. const oldWindow = globalThis.window,
  385. oldDocument = globalThis.document
  386. const positions: Record<string, number> = {
  387. basic: 120,
  388. transport: 400,
  389. load: 700,
  390. delivery: 900,
  391. remark: 1100
  392. }
  393. try {
  394. globalThis.window = { innerHeight: 700, scrollY: 0 } as any
  395. globalThis.document = {
  396. documentElement: { scrollHeight: 1500 },
  397. getElementById: (id: string) => ({
  398. getBoundingClientRect: () => ({ top: positions[id.replace('mp-plan-', '')] })
  399. })
  400. } as any
  401. h.navRef.value = { getBoundingClientRect: () => ({ bottom: 100 }) }
  402. h.active.value = 'delivery'
  403. h.syncScroll()
  404. assert.equal(h.active.value, 'basic')
  405. positions.basic = -800
  406. positions.transport = -500
  407. positions.load = -100
  408. positions.delivery = 50
  409. positions.remark = 500
  410. globalThis.window.scrollY = 700
  411. h.syncScroll()
  412. assert.equal(h.active.value, 'delivery')
  413. globalThis.window.scrollY = 800
  414. h.syncScroll()
  415. assert.equal(h.active.value, 'remark')
  416. } finally {
  417. globalThis.window = oldWindow
  418. globalThis.document = oldDocument
  419. h.stop()
  420. }
  421. })
  422. import { ActionSheet } from 'vant'
  423. test('real Vant ActionSheet click changes company without writing document type', async () => {
  424. const h = mobileHarness()
  425. h.c.formData.belongsCompany = 'shanxia'
  426. h.c.formData.planType = 'direct'
  427. h.choice.value = 'company'
  428. const source = parse(
  429. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  430. ).descriptor.template!.content
  431. const sheet = source.match(/<van-action-sheet[\s\S]*?\/>/)![0]
  432. const vnode = vue.compile(sheet)(vue.proxyRefs(h), [])
  433. const events: string[] = []
  434. const render = (ActionSheet as any).setup(
  435. {
  436. actions: h.choiceActions.value,
  437. closeOnClickAction:
  438. ('close-on-click-action' in vnode.props || 'closeOnClickAction' in vnode.props) &&
  439. (vnode.props['close-on-click-action'] ?? vnode.props.closeOnClickAction) !== false
  440. },
  441. {
  442. slots: {},
  443. emit: (event: string, ...args: any[]) => {
  444. events.push(event)
  445. vnode.props[event === 'select' ? 'onSelect' : 'onUpdate:show']?.(...args)
  446. }
  447. }
  448. )
  449. const tree = render().children.default()
  450. function buttons(node: any): any[] {
  451. if (Array.isArray(node)) return node.flatMap(buttons)
  452. if (!node) return []
  453. return [...(node.type === 'button' ? [node] : []), ...buttons(node.children)]
  454. }
  455. buttons(tree)[1].props.onClick()
  456. await tick()
  457. assert.equal(h.c.formData.belongsCompany, 'yundong')
  458. assert.equal(h.c.formData.planType, 'direct')
  459. assert.ok(events.includes('select'))
  460. h.stop()
  461. })
  462. test('quantity summary follows source rows and imbalance remains a warning', () => {
  463. const h = mobileHarness()
  464. h.c.formData.products = [{ quantity: 10, unit: '吨' }] as any
  465. h.c.loadForm.products = [{ planQty: 10, unit: '吨' }] as any
  466. h.c.deliveryForms = [{ products: [{ planQty: 9, unit: '吨' }] }] as any
  467. assert.equal(h.isBalanced.value, false)
  468. assert.equal(h.formatQty(1.12349), '1.123')
  469. const actions = h.footer.value.primary.map((x: any) => x.key)
  470. ;(h.c.deliveryForms as any)[0].products[0].planQty = 10
  471. assert.equal(h.isBalanced.value, true)
  472. assert.deepEqual(
  473. h.footer.value.primary.map((x: any) => x.key),
  474. actions
  475. )
  476. h.stop()
  477. })
  478. test('vehicle certificates retain owner scope, main/trailer plates and missing hints', () => {
  479. const h = mobileHarness()
  480. h.c.vehicleBundle = {
  481. detail: {
  482. id: 'v',
  483. vehicleType: 'self',
  484. licensePlate: '主牌',
  485. trailerNo: '挂牌',
  486. certList: [
  487. {
  488. id: 'head',
  489. ownerId: 'v',
  490. vehiclePart: 'head',
  491. certName: '行驶证',
  492. expireDate: '2030-01-01'
  493. },
  494. {
  495. id: 'trailer',
  496. ownerId: 'v',
  497. vehiclePart: 'trailer',
  498. certName: '行驶证',
  499. expireDate: '2030-01-01'
  500. },
  501. { id: 'other', ownerId: 'other', vehiclePart: 'head', certName: '保险单' }
  502. ]
  503. }
  504. }
  505. assert.deepEqual(
  506. h.vehicleCertGroups.value.map((g: any) => [g.label, g.certs.map((x: any) => x.id)]),
  507. [
  508. ['主车 · 主牌', ['head']],
  509. ['挂车 · 挂牌', ['trailer']]
  510. ]
  511. )
  512. assert.equal(h.vehicleMissingHint.value, '(证书缺失)')
  513. assert.equal(h.personnelMissingHint({ role: 'driver', certList: [] }), '(证书缺失)')
  514. h.c.vehicleBundle.detail.vehicleType = 'carrier'
  515. assert.equal(h.personnelMissingHint({ role: 'driver', certList: [] }), '')
  516. h.c.vehicleBundle.detail.trailerNo = '货拉拉'
  517. assert.equal(h.vehicleCertGroups.value.length, 1)
  518. h.stop()
  519. })
  520. import { ElForm, ElButton, ElIcon, formContextKey } from 'element-plus'
  521. import { renderToString } from 'vue/server-renderer'
  522. test('actual load/delivery slots provide top-label Element form context to original sections', async () => {
  523. const mobileRoot = descriptor.template!.ast.children.find((n: any) => n.type === 1) as any
  524. for (const name of ['load', 'delivery']) {
  525. const slot = mobileRoot.children.find(
  526. (n: any) =>
  527. n.type === 1 && n.props.some((p: any) => p.name === 'slot' && p.arg?.content === name)
  528. )
  529. const form = slot.children.find((n: any) => n.type === 1)
  530. const observed: string[] = []
  531. const section = vue.defineComponent({
  532. setup() {
  533. const context = vue.inject(formContextKey)
  534. observed.push(context?.labelPosition || 'missing')
  535. return () => vue.h('span', 'section')
  536. }
  537. })
  538. const app = vue.createSSRApp({
  539. setup: () => ({
  540. mobileController: { setLoadSectionRef() {}, setDeliverySectionRef() {} },
  541. loadForm: { products: [] },
  542. deliveryForms: [{}],
  543. isPageVariant: true,
  544. planId: '',
  545. currentVehiclePlate: '',
  546. loadContractProductRows: [],
  547. openCustomerSelector() {},
  548. openProductSelector() {},
  549. deliveryActiveIndex: 0,
  550. handleAddDelivery() {},
  551. handleMoveDelivery() {},
  552. handleRemoveDelivery() {},
  553. planQtyUnitLabel: '吨',
  554. showDeliverySplitHint: false,
  555. contractProductRowsCache: {}
  556. }),
  557. render: vue.compile(form.loc.source)
  558. })
  559. app.component('ElForm', ElForm)
  560. app.component('ElButton', ElButton)
  561. app.component('ElIcon', ElIcon)
  562. app.component('PlPlanLoadSection', section)
  563. app.component('PlPlanDeliverySection', section)
  564. for (const icon of ['Plus', 'Top', 'Bottom', 'Delete'])
  565. app.component(icon, { render: () => null })
  566. await renderToString(app)
  567. assert.deepEqual(observed, ['top'])
  568. }
  569. })
  570. test('real mobile lifecycle resets viewport baseline, hides focused keyboard and restores after blur', async () => {
  571. const oldWindow = globalThis.window,
  572. oldDocument = globalThis.document
  573. const events = new Map<string, () => void>()
  574. const surface = (prefix: string) => ({
  575. addEventListener: (name: string, fn: () => void) => events.set(prefix + name, fn),
  576. removeEventListener: (name: string) => events.delete(prefix + name)
  577. })
  578. const vv = { height: 1000, ...surface('visual:') }
  579. const win = {
  580. innerHeight: 1000,
  581. innerWidth: 1440,
  582. scrollY: 0,
  583. visualViewport: vv,
  584. ...surface('window:')
  585. }
  586. const doc = {
  587. activeElement: { tagName: 'BODY' },
  588. querySelector: () => null,
  589. getElementById: () => null,
  590. documentElement: { scrollHeight: 2000 },
  591. ...surface('document:')
  592. }
  593. let h: any
  594. try {
  595. globalThis.window = win as any
  596. globalThis.document = doc as any
  597. h = mobileHarness()
  598. h.mount()
  599. win.innerWidth = 375
  600. win.innerHeight = 812
  601. vv.height = 812
  602. events.get('window:resize')?.()
  603. events.get('visual:resize')?.()
  604. h.syncScroll()
  605. assert.equal(h.keyboardOpen.value, false, 'normal viewport resize must not hide footer')
  606. doc.activeElement = { tagName: 'INPUT' }
  607. events.get('document:focusin')?.()
  608. vv.height = 480
  609. events.get('visual:resize')?.()
  610. assert.equal(h.keyboardOpen.value, true, 'focused keyboard hides footer')
  611. doc.activeElement = { tagName: 'BODY' }
  612. events.get('document:focusout')?.()
  613. await tick()
  614. assert.equal(h.keyboardOpen.value, false, 'blur restores footer even before animation ends')
  615. vv.height = 812
  616. events.get('visual:resize')?.()
  617. assert.equal(h.keyboardOpen.value, false)
  618. h.unmount()
  619. assert.equal(events.size, 0, 'unmount removes every viewport/focus listener')
  620. } finally {
  621. h?.stop()
  622. globalThis.window = oldWindow
  623. globalThis.document = oldDocument
  624. }
  625. })
  626. import * as sass from 'sass'
  627. import { createRequire } from 'node:module'
  628. test('compiled mobile and teleported sheet CSS resolve actual Vant primary button background and border to brand green', () => {
  629. const require = createRequire(import.meta.url)
  630. const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
  631. const rootCss = postcss.parse(
  632. readFileSync(require.resolve('vant/lib/style/css-variables.css'), 'utf8') +
  633. '\n' +
  634. readFileSync(require.resolve('vant/lib/button/index.css'), 'utf8')
  635. )
  636. const localCss = postcss.parse(
  637. sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
  638. )
  639. const resolveValue = (value: string, vars: Record<string, string>): string =>
  640. value.replace(/var\((--[^),]+)(?:,([^)]*))?\)/g, (_, key, fallback) =>
  641. resolveValue(vars[key] ?? fallback ?? '', vars)
  642. )
  643. const rootVars: Record<string, string> = {}
  644. rootCss.walkRules((rule) => {
  645. if (rule.selector.includes(':root'))
  646. rule.walkDecls((d) => {
  647. if (d.prop.startsWith('--')) rootVars[d.prop] = d.value
  648. })
  649. })
  650. // Custom properties inherit computed values; a child-only primary override cannot change root-computed button tokens.
  651. const inherited = Object.fromEntries(
  652. Object.entries(rootVars).map(([key, value]) => [key, resolveValue(value, rootVars)])
  653. )
  654. for (const selector of ['.mobile-pl-plan-form', '.mobile-pl-plan-overlay']) {
  655. const vars = { ...inherited }
  656. localCss.walkRules((rule) => {
  657. if (rule.selectors.includes(selector))
  658. rule.walkDecls((d) => {
  659. if (d.prop.startsWith('--')) vars[d.prop] = d.value
  660. })
  661. })
  662. const values: Record<string, string> = {}
  663. rootCss.walkRules((rule) => {
  664. if (rule.selectors.includes('.van-button--primary'))
  665. rule.walkDecls((d) => {
  666. values[d.prop] = resolveValue(d.value, vars)
  667. })
  668. })
  669. assert.equal(values.background, '#0a9f6d', selector + ' actual button background')
  670. assert.match(values.border, /solid #0a9f6d$/, selector + ' actual button border')
  671. }
  672. })
  673. test('compiled sheet styles give the explicit clear button and personnel rows 44px touch bounds', () => {
  674. const require = createRequire(import.meta.url)
  675. const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
  676. const css = postcss.parse(
  677. readFileSync(require.resolve('vant/lib/field/index.css'), 'utf8') +
  678. '\n' +
  679. sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
  680. )
  681. const cascade = (selectors: string[]) => {
  682. const result: Record<string, string> = {}
  683. css.walkRules((rule) => {
  684. if (rule.selectors.some((s: string) => selectors.includes(s)))
  685. rule.walkDecls((d) => {
  686. result[d.prop] = d.value
  687. })
  688. })
  689. return result
  690. }
  691. const clear = cascade(['.mobile-pl-plan-overlay .mobile-pl-plan-search-clear'])
  692. assert.ok(parseFloat(clear['min-width'] || '0') >= 44, 'clear hit width must be at least 44')
  693. assert.ok(parseFloat(clear['min-height'] || '0') >= 44, 'clear hit height must be at least 44')
  694. assert.equal(clear.display, 'flex')
  695. assert.equal(clear['align-items'], 'center')
  696. const body = cascade(['.van-field__body'])
  697. assert.equal(body.display, 'flex')
  698. assert.equal(body['align-items'], 'center')
  699. const input = cascade([
  700. '.van-field__control',
  701. '.mobile-pl-plan-overlay .van-search .van-field__control'
  702. ])
  703. assert.ok(parseFloat(input['min-height'] || '0') >= 44)
  704. const row = cascade(['.mobile-pl-plan-overlay .van-cell--clickable'])
  705. assert.ok(parseFloat(row['min-height'] || '0') >= 44)
  706. })
  707. test('compiled mobile certificate layout gives labels, values and attachment cells full width', () => {
  708. const require = createRequire(import.meta.url)
  709. const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
  710. const css = postcss.parse(
  711. sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
  712. )
  713. for (const part of ['', ' tbody', ' tr', ' th', ' td']) {
  714. const selector = '.mobile-pl-plan-overlay .cert-view-card .view-kv-table' + part
  715. const computed: Record<string, string> = {}
  716. css.walkRules((rule) => {
  717. if (rule.selectors.includes(selector))
  718. rule.walkDecls((d) => {
  719. computed[d.prop] = d.value
  720. })
  721. })
  722. assert.equal(
  723. computed.display,
  724. 'block',
  725. selector + ' must stack instead of dividing a table row'
  726. )
  727. assert.equal(computed.width, '100%', selector + ' must span the certificate card')
  728. assert.equal(computed['box-sizing'], 'border-box')
  729. }
  730. // Actual reused attachment table carries the same class; the full-width rules apply to it too.
  731. const template = parse(
  732. readFileSync(
  733. new URL('../src/components/business/pl-cert-view-list/index.vue', import.meta.url),
  734. 'utf8'
  735. )
  736. ).descriptor.template!.content
  737. const compiled = vue.compile(template)
  738. const app = vue.createSSRApp({
  739. render: compiled,
  740. setup: () => ({
  741. certList: [
  742. { id: 'x', certName: '身份证', certNo: '123456789012345678', attachmentUrls: '/test.png' }
  743. ],
  744. layout: 'card',
  745. tableMode: 'pair',
  746. attachmentColspan: 1,
  747. buildCertViewFields: (cert: any) => [{ label: '证号', value: cert.certNo }]
  748. })
  749. })
  750. app.component('ViewKvTable', {
  751. props: ['fields'],
  752. setup: (props: any) => () => vue.h('div', props.fields[0].value)
  753. })
  754. app.component('AttachmentUploadField', {
  755. props: ['modelValue'],
  756. setup: (props: any) => () => vue.h('span', props.modelValue)
  757. })
  758. return renderToString(app).then((html) => {
  759. assert.match(html, /123456789012345678/)
  760. assert.match(html, /cert-view-attachment-table view-kv-table/)
  761. assert.match(html, /<th>附件<\/th>/)
  762. assert.match(html, /\/test.png/)
  763. })
  764. })
  765. test('rendered personnel search clear button handles click and reloads unfiltered rows', async () => {
  766. const h = mobileHarness()
  767. h.openPersonnel('driver')
  768. h.calls[0].resolve({ list: [{ id: 'a' }], total: 1 })
  769. await tick()
  770. h.keyword.value = '沈卫斌'
  771. const filtered = h.picker.fetchList(true)
  772. h.calls.at(-1).resolve({ list: [{ id: 'filtered' }], total: 1 })
  773. await filtered
  774. const source = parse(
  775. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  776. ).descriptor.template!.content
  777. const template = source.match(/<van-search[\s\S]*?(?:\/>|<\/van-search>)/)![0]
  778. const vnode = vue.compile(template)(vue.proxyRefs(h), [])
  779. assert.equal(vnode.props.clearable, false, 'disable Vant touch-only clear')
  780. const button = vnode.children['right-icon']().find((node: any) => node.type === 'button')
  781. assert.equal(button.props['aria-label'], '清空人员搜索')
  782. button.props.onClick()
  783. assert.equal(h.keyword.value, '')
  784. await new Promise((resolve) => setTimeout(resolve, 350))
  785. assert.equal(h.calls.at(-1).params.q, undefined)
  786. h.calls.at(-1).resolve({ list: [{ id: 'a' }, { id: 'b' }], total: 2 })
  787. await tick()
  788. assert.deepEqual(
  789. h.rows.value.map((row: any) => row.id),
  790. ['a', 'b']
  791. )
  792. h.stop()
  793. })
  794. async function renderedQuantitySummary(h: ReturnType<typeof mobileHarness>) {
  795. const content = parse(
  796. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  797. ).descriptor.template!.content
  798. const template = content.match(/<div class="mobile-pl-plan-balance"[\s\S]*?<\/div>/)![0]
  799. const app = vue.createSSRApp({
  800. setup: () => ({
  801. c: h.c,
  802. quantitySummary: h.quantitySummary,
  803. isBalanced: h.isBalanced,
  804. formatQty: h.formatQty
  805. }),
  806. render: vue.compile(template)
  807. })
  808. app.component('VanTag', {
  809. setup:
  810. (_, { slots }) =>
  811. () =>
  812. vue.h('span', slots.default?.())
  813. })
  814. return (await renderToString(app))
  815. .replace(/<[^>]*>/g, ' ')
  816. .replace(/\s+/g, ' ')
  817. .trim()
  818. }
  819. for (const qty of [0, 1])
  820. test(`actual mobile template renders delivery-only draft ${qty} 个 without guessed tonnes`, async () => {
  821. const h = mobileHarness()
  822. Object.assign(h.c, {
  823. planTotalQty: 0,
  824. loadTotalQty: 0,
  825. deliveryTotalQty: qty,
  826. planQtyUnitLabel: '吨'
  827. })
  828. h.c.deliveryForms = [{ products: [{ unit: '个', planQty: qty }] }] as any
  829. const text = await renderedQuantitySummary(h)
  830. assert.match(text, new RegExp(`送货合计 ${qty}个`))
  831. assert.match(text, /计划总量 0 装货合计 0/)
  832. assert.doesNotMatch(text, /吨/)
  833. h.stop()
  834. })
  835. test('actual mobile summary groups mixed units and does not equate different quantities by scalar sum', async () => {
  836. const h = mobileHarness()
  837. h.c.formData.products = [
  838. { unit: '吨', quantity: 1 },
  839. { unit: '个', quantity: 2 }
  840. ] as any
  841. h.c.loadForm.products = [
  842. { unit: '吨', planQty: 1 },
  843. { unit: '个', planQty: 2 }
  844. ] as any
  845. h.c.deliveryForms = [
  846. { products: [{ unit: '吨', planQty: 2 }] },
  847. { products: [{ unit: '个', planQty: 1 }] }
  848. ] as any
  849. Object.assign(h.c, {
  850. planTotalQty: 3,
  851. loadTotalQty: 3,
  852. deliveryTotalQty: 3,
  853. planQtyUnitLabel: '吨',
  854. deliveryQtyBreakdown: '2 + 1 = 3'
  855. })
  856. const text = await renderedQuantitySummary(h)
  857. assert.match(text, /计划总量 1吨 \+ 2个/)
  858. assert.match(text, /装货合计 1吨 \+ 2个/)
  859. assert.match(text, /送货合计 2吨 \+ 1个/)
  860. assert.match(text, /数量不平衡/)
  861. assert.doesNotMatch(text, /2 \+ 1 = 3|3吨/)
  862. h.stop()
  863. })
  864. test('actual mobile template keeps unknown units explicit and sums same units to three decimals', async () => {
  865. const h = mobileHarness()
  866. h.c.formData.products = [{ unit: '', quantity: 1.125 }] as any
  867. h.c.loadForm.products = [{ unit: '', planQty: 1.125 }] as any
  868. h.c.deliveryForms = [{ products: [{ unit: '', planQty: 1.125 }] }] as any
  869. let text = await renderedQuantitySummary(h)
  870. assert.match(text, /1.125(单位未填)/)
  871. assert.match(text, /单位未填,暂无法核对平衡/)
  872. assert.doesNotMatch(text, /数量平衡|吨/)
  873. h.c.formData.products = [
  874. { unit: '个', quantity: 1.125 },
  875. { unit: '个', quantity: 2.001 }
  876. ] as any
  877. h.c.loadForm.products = [{ unit: '个', planQty: 3.126 }] as any
  878. h.c.deliveryForms = [
  879. { products: [{ unit: '个', planQty: 1.125 }] },
  880. { products: [{ unit: '个', planQty: 2.001 }] }
  881. ] as any
  882. text = await renderedQuantitySummary(h)
  883. assert.match(text, /计划总量 3.126个 装货合计 3.126个 送货合计 3.126个/)
  884. assert.match(text, /数量平衡/)
  885. assert.match(text, /1.125个.*2.001个.*3.126个/)
  886. h.stop()
  887. })
  888. for (const [code, label, hasDictionary] of [
  889. ['dun', '吨', true],
  890. ['dun', '吨', false],
  891. ['jian', '件', true]
  892. ] as const) {
  893. test(`actual SFC normalizes ${code}/${label} with dictionary ${hasDictionary} for display and balance`, async () => {
  894. const h = mobileHarness()
  895. h.c.dictStore.getLabel = (type, value) =>
  896. hasDictionary && type === 'crm_product_unit' && value === code ? label : value
  897. h.c.formData.products = [{ unit: code, quantity: 1 }] as any
  898. h.c.loadForm.products = [{ unit: code, planQty: 1 }] as any
  899. h.c.deliveryForms = [
  900. { products: [{ unit: label, planQty: 0.25 }] },
  901. { products: [{ unit: code, planQty: 0.75 }] }
  902. ] as any
  903. const text = await renderedQuantitySummary(h)
  904. assert.match(text, new RegExp(`计划总量 1${label} 装货合计 1${label} 送货合计 1${label}`))
  905. assert.match(text, /✓ 数量平衡/)
  906. assert.match(text, new RegExp(`0.25${label}.*0.75${label}.*1${label}`))
  907. assert.ok(!text.includes(code))
  908. // Pure display normalization must not rewrite any business unit value.
  909. assert.equal((h.c.formData.products[0] as any).unit, code)
  910. assert.equal((h.c.loadForm.products[0] as any).unit, code)
  911. assert.equal((h.c.deliveryForms[0] as any).products[0].unit, label)
  912. h.stop()
  913. })
  914. }