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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962
  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,typeLabel,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 keeps draft in 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. ['draft', 'save', 'saveAndArrange']
  322. )
  323. assert.deepEqual(
  324. h.footer.value.more.map((x: any) => x.key),
  325. ['cancel']
  326. )
  327. h.c.mpFooterSplit.primary = [{ key: 'save' }, { key: 'saveAndArrange' }]
  328. assert.deepEqual(
  329. h.footer.value.primary.map((x: any) => x.key),
  330. ['save', 'saveAndArrange']
  331. )
  332. assert.deepEqual(
  333. h.footer.value.more.map((x: any) => x.key),
  334. ['cancel']
  335. )
  336. h.openPersonnel('driver')
  337. assert.equal(h.calls[0].params.personnelType, 'driver')
  338. assert.equal(h.calls[0].params.status, '1')
  339. h.calls[0].resolve({ list: [{ id: 'd' }], total: 3 })
  340. await tick()
  341. h.picker.loadMore()
  342. h.calls[1].reject(new Error('offline'))
  343. await tick()
  344. h.picker.retry()
  345. assert.equal(h.calls[2].params.page, 2)
  346. h.openPersonnel('escort')
  347. assert.equal(h.calls[3].params.personnelTypeIn, 'escort,both')
  348. h.calls[2].resolve({ list: [{ id: 'stale' }], total: 3 })
  349. await tick()
  350. assert.deepEqual(h.rows.value, [])
  351. h.calls[3].resolve({ list: [{ id: 'e' }], total: 1 })
  352. await tick()
  353. h.selectPersonnel(h.rows.value[0])
  354. assert.equal(h.c.selectedEscortId, 'e')
  355. h.openPersonnel('driver')
  356. const pending = h.calls.at(-1)
  357. h.personnelKind.value = ''
  358. pending.resolve({ list: [{ id: 'closed' }], total: 1 })
  359. await tick()
  360. assert.deepEqual(h.rows.value, [])
  361. h.c.formData.vehicleId = ''
  362. h.openPersonnel('driver')
  363. assert.equal(h.personnelKind.value, '')
  364. h.stop()
  365. })
  366. test('actual mobile search uses server q and rejects account/unmount responses', async () => {
  367. const h = mobileHarness()
  368. h.openPersonnel('driver')
  369. h.keyword.value = '李'
  370. const request = h.picker.fetchList(true)
  371. assert.equal(h.calls.at(-1).params.q, '李')
  372. const old = h.calls.at(-1)
  373. h.c.userStore.accessToken = 'b'
  374. old.resolve({ list: [{ id: 'old' }], total: 1 })
  375. await request
  376. assert.deepEqual(h.rows.value, [])
  377. const latest = h.calls.at(-1)
  378. h.stop()
  379. latest.resolve({ list: [{ id: 'unmounted' }], total: 1 })
  380. await tick()
  381. assert.deepEqual(h.rows.value, [])
  382. })
  383. test('changing basic Vant models invokes original form field validation', async () => {
  384. const h = mobileHarness()
  385. h.c.formData.createDate = '2026-10-23'
  386. h.c.formData.belongsCompany = 'shanxia'
  387. await tick()
  388. assert.deepEqual(h.validations, ['createDate', 'belongsCompany'])
  389. h.stop()
  390. })
  391. test('manual scrolling resets first anchor and highlights final section at page bottom', () => {
  392. const h = mobileHarness()
  393. const oldWindow = globalThis.window,
  394. oldDocument = globalThis.document
  395. const positions: Record<string, number> = {
  396. basic: 120,
  397. transport: 400,
  398. load: 700,
  399. delivery: 900,
  400. remark: 1100
  401. }
  402. try {
  403. globalThis.window = { innerHeight: 700, scrollY: 0 } as any
  404. globalThis.document = {
  405. documentElement: { scrollHeight: 1500 },
  406. getElementById: (id: string) => ({
  407. getBoundingClientRect: () => ({ top: positions[id.replace('mp-plan-', '')] })
  408. })
  409. } as any
  410. h.navRef.value = { getBoundingClientRect: () => ({ bottom: 100 }) }
  411. h.active.value = 'delivery'
  412. h.syncScroll()
  413. assert.equal(h.active.value, 'basic')
  414. positions.basic = -800
  415. positions.transport = -500
  416. positions.load = -100
  417. positions.delivery = 50
  418. positions.remark = 500
  419. globalThis.window.scrollY = 700
  420. h.syncScroll()
  421. assert.equal(h.active.value, 'delivery')
  422. globalThis.window.scrollY = 800
  423. h.syncScroll()
  424. assert.equal(h.active.value, 'remark')
  425. } finally {
  426. globalThis.window = oldWindow
  427. globalThis.document = oldDocument
  428. h.stop()
  429. }
  430. })
  431. test('document type shows an empty value without a no-selection option', () => {
  432. const h = mobileHarness()
  433. try {
  434. h.choice.value = 'type'
  435. assert.deepEqual(h.choiceActions.value, [
  436. { name: '直销单', value: 'direct' },
  437. { name: '入库单', value: 'inbound' },
  438. { name: '出库单', value: 'outbound' }
  439. ])
  440. for (const value of ['', null, undefined]) {
  441. h.c.formData.planType = value
  442. assert.equal(h.typeLabel.value, '')
  443. }
  444. h.selectChoice({ value: 'inbound' })
  445. assert.equal(h.c.formData.planType, 'inbound')
  446. assert.equal(h.typeLabel.value, '入库单')
  447. assert.equal(h.choice.value, '')
  448. } finally {
  449. h.stop()
  450. }
  451. })
  452. import { ActionSheet } from 'vant'
  453. test('real Vant ActionSheet click changes company without writing document type', async () => {
  454. const h = mobileHarness()
  455. h.c.formData.belongsCompany = 'shanxia'
  456. h.c.formData.planType = 'direct'
  457. h.choice.value = 'company'
  458. const source = parse(
  459. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  460. ).descriptor.template!.content
  461. const sheet = source.match(/<van-action-sheet[\s\S]*?\/>/)![0]
  462. const vnode = vue.compile(sheet)(vue.proxyRefs(h), [])
  463. const events: string[] = []
  464. const render = (ActionSheet as any).setup(
  465. {
  466. actions: h.choiceActions.value,
  467. closeOnClickAction:
  468. ('close-on-click-action' in vnode.props || 'closeOnClickAction' in vnode.props) &&
  469. (vnode.props['close-on-click-action'] ?? vnode.props.closeOnClickAction) !== false
  470. },
  471. {
  472. slots: {},
  473. emit: (event: string, ...args: any[]) => {
  474. events.push(event)
  475. vnode.props[event === 'select' ? 'onSelect' : 'onUpdate:show']?.(...args)
  476. }
  477. }
  478. )
  479. const tree = render().children.default()
  480. function buttons(node: any): any[] {
  481. if (Array.isArray(node)) return node.flatMap(buttons)
  482. if (!node) return []
  483. return [...(node.type === 'button' ? [node] : []), ...buttons(node.children)]
  484. }
  485. buttons(tree)[1].props.onClick()
  486. await tick()
  487. assert.equal(h.c.formData.belongsCompany, 'yundong')
  488. assert.equal(h.c.formData.planType, 'direct')
  489. assert.ok(events.includes('select'))
  490. h.stop()
  491. })
  492. test('quantity summary follows source rows and imbalance remains a warning', () => {
  493. const h = mobileHarness()
  494. h.c.formData.products = [{ quantity: 10, unit: '吨' }] as any
  495. h.c.loadForm.products = [{ planQty: 10, unit: '吨' }] as any
  496. h.c.deliveryForms = [{ products: [{ planQty: 9, unit: '吨' }] }] as any
  497. assert.equal(h.isBalanced.value, false)
  498. assert.equal(h.formatQty(1.12349), '1.123')
  499. const actions = h.footer.value.primary.map((x: any) => x.key)
  500. ;(h.c.deliveryForms as any)[0].products[0].planQty = 10
  501. assert.equal(h.isBalanced.value, true)
  502. assert.deepEqual(
  503. h.footer.value.primary.map((x: any) => x.key),
  504. actions
  505. )
  506. h.stop()
  507. })
  508. test('vehicle certificates retain owner scope, main/trailer plates and missing hints', () => {
  509. const h = mobileHarness()
  510. h.c.vehicleBundle = {
  511. detail: {
  512. id: 'v',
  513. vehicleType: 'self',
  514. licensePlate: '主牌',
  515. trailerNo: '挂牌',
  516. certList: [
  517. {
  518. id: 'head',
  519. ownerId: 'v',
  520. vehiclePart: 'head',
  521. certName: '行驶证',
  522. expireDate: '2030-01-01'
  523. },
  524. {
  525. id: 'trailer',
  526. ownerId: 'v',
  527. vehiclePart: 'trailer',
  528. certName: '行驶证',
  529. expireDate: '2030-01-01'
  530. },
  531. { id: 'other', ownerId: 'other', vehiclePart: 'head', certName: '保险单' }
  532. ]
  533. }
  534. }
  535. assert.deepEqual(
  536. h.vehicleCertGroups.value.map((g: any) => [g.label, g.certs.map((x: any) => x.id)]),
  537. [
  538. ['主车 · 主牌', ['head']],
  539. ['挂车 · 挂牌', ['trailer']]
  540. ]
  541. )
  542. assert.equal(h.vehicleMissingHint.value, '(证书缺失)')
  543. assert.equal(h.personnelMissingHint({ role: 'driver', certList: [] }), '(证书缺失)')
  544. h.c.vehicleBundle.detail.vehicleType = 'carrier'
  545. assert.equal(h.personnelMissingHint({ role: 'driver', certList: [] }), '')
  546. h.c.vehicleBundle.detail.trailerNo = '货拉拉'
  547. assert.equal(h.vehicleCertGroups.value.length, 1)
  548. h.stop()
  549. })
  550. import { ElForm, ElButton, ElIcon, formContextKey } from 'element-plus'
  551. import { renderToString } from 'vue/server-renderer'
  552. test('actual load/delivery slots provide top-label Element form context to original sections', async () => {
  553. const mobileRoot = descriptor.template!.ast.children.find((n: any) => n.type === 1) as any
  554. for (const name of ['load', 'delivery']) {
  555. const slot = mobileRoot.children.find(
  556. (n: any) =>
  557. n.type === 1 && n.props.some((p: any) => p.name === 'slot' && p.arg?.content === name)
  558. )
  559. const form = slot.children.find((n: any) => n.type === 1)
  560. const observed: string[] = []
  561. const section = vue.defineComponent({
  562. setup() {
  563. const context = vue.inject(formContextKey)
  564. observed.push(context?.labelPosition || 'missing')
  565. return () => vue.h('span', 'section')
  566. }
  567. })
  568. const app = vue.createSSRApp({
  569. setup: () => ({
  570. mobileController: { setLoadSectionRef() {}, setDeliverySectionRef() {} },
  571. loadForm: { products: [] },
  572. deliveryForms: [{}],
  573. isPageVariant: true,
  574. planId: '',
  575. currentVehiclePlate: '',
  576. loadContractProductRows: [],
  577. openCustomerSelector() {},
  578. openProductSelector() {},
  579. deliveryActiveIndex: 0,
  580. handleAddDelivery() {},
  581. handleMoveDelivery() {},
  582. handleRemoveDelivery() {},
  583. planQtyUnitLabel: '吨',
  584. showDeliverySplitHint: false,
  585. contractProductRowsCache: {}
  586. }),
  587. render: vue.compile(form.loc.source)
  588. })
  589. app.component('ElForm', ElForm)
  590. app.component('ElButton', ElButton)
  591. app.component('ElIcon', ElIcon)
  592. app.component('PlPlanLoadSection', section)
  593. app.component('PlPlanDeliverySection', section)
  594. for (const icon of ['Plus', 'Top', 'Bottom', 'Delete'])
  595. app.component(icon, { render: () => null })
  596. await renderToString(app)
  597. assert.deepEqual(observed, ['top'])
  598. }
  599. })
  600. test('real mobile lifecycle resets viewport baseline, hides focused keyboard and restores after blur', async () => {
  601. const oldWindow = globalThis.window,
  602. oldDocument = globalThis.document
  603. const events = new Map<string, () => void>()
  604. const surface = (prefix: string) => ({
  605. addEventListener: (name: string, fn: () => void) => events.set(prefix + name, fn),
  606. removeEventListener: (name: string) => events.delete(prefix + name)
  607. })
  608. const vv = { height: 1000, ...surface('visual:') }
  609. const win = {
  610. innerHeight: 1000,
  611. innerWidth: 1440,
  612. scrollY: 0,
  613. visualViewport: vv,
  614. ...surface('window:')
  615. }
  616. const doc = {
  617. activeElement: { tagName: 'BODY' },
  618. querySelector: () => null,
  619. getElementById: () => null,
  620. documentElement: { scrollHeight: 2000 },
  621. ...surface('document:')
  622. }
  623. let h: any
  624. try {
  625. globalThis.window = win as any
  626. globalThis.document = doc as any
  627. h = mobileHarness()
  628. h.mount()
  629. win.innerWidth = 375
  630. win.innerHeight = 812
  631. vv.height = 812
  632. events.get('window:resize')?.()
  633. events.get('visual:resize')?.()
  634. h.syncScroll()
  635. assert.equal(h.keyboardOpen.value, false, 'normal viewport resize must not hide footer')
  636. doc.activeElement = { tagName: 'INPUT' }
  637. events.get('document:focusin')?.()
  638. vv.height = 480
  639. events.get('visual:resize')?.()
  640. assert.equal(h.keyboardOpen.value, true, 'focused keyboard hides footer')
  641. doc.activeElement = { tagName: 'BODY' }
  642. events.get('document:focusout')?.()
  643. await tick()
  644. assert.equal(h.keyboardOpen.value, false, 'blur restores footer even before animation ends')
  645. vv.height = 812
  646. events.get('visual:resize')?.()
  647. assert.equal(h.keyboardOpen.value, false)
  648. h.unmount()
  649. assert.equal(events.size, 0, 'unmount removes every viewport/focus listener')
  650. } finally {
  651. h?.stop()
  652. globalThis.window = oldWindow
  653. globalThis.document = oldDocument
  654. }
  655. })
  656. import * as sass from 'sass'
  657. import { createRequire } from 'node:module'
  658. test('compiled mobile and teleported sheet CSS resolve actual Vant primary button background and border to brand green', () => {
  659. const require = createRequire(import.meta.url)
  660. const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
  661. const rootCss = postcss.parse(
  662. readFileSync(require.resolve('vant/lib/style/css-variables.css'), 'utf8') +
  663. '\n' +
  664. readFileSync(require.resolve('vant/lib/button/index.css'), 'utf8')
  665. )
  666. const localCss = postcss.parse(
  667. sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
  668. )
  669. const resolveValue = (value: string, vars: Record<string, string>): string =>
  670. value.replace(/var\((--[^),]+)(?:,([^)]*))?\)/g, (_, key, fallback) =>
  671. resolveValue(vars[key] ?? fallback ?? '', vars)
  672. )
  673. const rootVars: Record<string, string> = {}
  674. rootCss.walkRules((rule) => {
  675. if (rule.selector.includes(':root'))
  676. rule.walkDecls((d) => {
  677. if (d.prop.startsWith('--')) rootVars[d.prop] = d.value
  678. })
  679. })
  680. // Custom properties inherit computed values; a child-only primary override cannot change root-computed button tokens.
  681. const inherited = Object.fromEntries(
  682. Object.entries(rootVars).map(([key, value]) => [key, resolveValue(value, rootVars)])
  683. )
  684. for (const selector of ['.mobile-pl-plan-form', '.mobile-pl-plan-overlay']) {
  685. const vars = { ...inherited }
  686. localCss.walkRules((rule) => {
  687. if (rule.selectors.includes(selector))
  688. rule.walkDecls((d) => {
  689. if (d.prop.startsWith('--')) vars[d.prop] = d.value
  690. })
  691. })
  692. const values: Record<string, string> = {}
  693. rootCss.walkRules((rule) => {
  694. if (rule.selectors.includes('.van-button--primary'))
  695. rule.walkDecls((d) => {
  696. values[d.prop] = resolveValue(d.value, vars)
  697. })
  698. })
  699. assert.equal(values.background, '#0a9f6d', selector + ' actual button background')
  700. assert.match(values.border, /solid #0a9f6d$/, selector + ' actual button border')
  701. }
  702. })
  703. test('compiled sheet styles keep 44px picker targets with compact search text', () => {
  704. const require = createRequire(import.meta.url)
  705. const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
  706. const css = postcss.parse(
  707. readFileSync(require.resolve('vant/lib/field/index.css'), 'utf8') +
  708. '\n' +
  709. sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
  710. )
  711. const cascade = (selectors: string[]) => {
  712. const result: Record<string, string> = {}
  713. css.walkRules((rule) => {
  714. if (rule.selectors.some((s: string) => selectors.includes(s)))
  715. rule.walkDecls((d) => {
  716. result[d.prop] = d.value
  717. })
  718. })
  719. return result
  720. }
  721. const clear = cascade(['.mobile-pl-plan-overlay .mobile-pl-plan-search-clear'])
  722. assert.ok(parseFloat(clear['min-width'] || '0') >= 44, 'clear hit width must be at least 44')
  723. assert.ok(parseFloat(clear['min-height'] || '0') >= 44, 'clear hit height must be at least 44')
  724. assert.equal(clear.display, 'flex')
  725. assert.equal(clear['align-items'], 'center')
  726. const body = cascade(['.van-field__body'])
  727. assert.equal(body.display, 'flex')
  728. assert.equal(body['align-items'], 'center')
  729. const input = cascade([
  730. '.van-field__control',
  731. '.mobile-pl-plan-overlay .van-search .van-field__control'
  732. ])
  733. assert.equal(parseFloat(input['min-height'] || '0'), 24)
  734. const row = cascade(['.mobile-pl-plan-overlay .van-cell--clickable'])
  735. assert.ok(parseFloat(row['min-height'] || '0') >= 44)
  736. })
  737. test('compiled mobile certificate layout gives labels, values and attachment cells full width', () => {
  738. const require = createRequire(import.meta.url)
  739. const postcss = createRequire(require.resolve('vite/package.json'))('postcss')
  740. const css = postcss.parse(
  741. sass.compile(new URL('../src/views/m/pl/plan/mobile-form.scss', import.meta.url).pathname).css
  742. )
  743. for (const part of ['', ' tbody', ' tr', ' th', ' td']) {
  744. const selector = '.mobile-pl-plan-overlay .cert-view-card .view-kv-table' + part
  745. const computed: Record<string, string> = {}
  746. css.walkRules((rule) => {
  747. if (rule.selectors.includes(selector))
  748. rule.walkDecls((d) => {
  749. computed[d.prop] = d.value
  750. })
  751. })
  752. assert.equal(
  753. computed.display,
  754. 'block',
  755. selector + ' must stack instead of dividing a table row'
  756. )
  757. assert.equal(computed.width, '100%', selector + ' must span the certificate card')
  758. assert.equal(computed['box-sizing'], 'border-box')
  759. }
  760. // Actual reused attachment table carries the same class; the full-width rules apply to it too.
  761. const template = parse(
  762. readFileSync(
  763. new URL('../src/components/business/pl-cert-view-list/index.vue', import.meta.url),
  764. 'utf8'
  765. )
  766. ).descriptor.template!.content
  767. const compiled = vue.compile(template)
  768. const app = vue.createSSRApp({
  769. render: compiled,
  770. setup: () => ({
  771. certList: [
  772. { id: 'x', certName: '身份证', certNo: '123456789012345678', attachmentUrls: '/test.png' }
  773. ],
  774. layout: 'card',
  775. tableMode: 'pair',
  776. attachmentColspan: 1,
  777. buildCertViewFields: (cert: any) => [{ label: '证号', value: cert.certNo }]
  778. })
  779. })
  780. app.component('ViewKvTable', {
  781. props: ['fields'],
  782. setup: (props: any) => () => vue.h('div', props.fields[0].value)
  783. })
  784. app.component('AttachmentUploadField', {
  785. props: ['modelValue'],
  786. setup: (props: any) => () => vue.h('span', props.modelValue)
  787. })
  788. return renderToString(app).then((html) => {
  789. assert.match(html, /123456789012345678/)
  790. assert.match(html, /cert-view-attachment-table view-kv-table/)
  791. assert.match(html, /<th>附件<\/th>/)
  792. assert.match(html, /\/test.png/)
  793. })
  794. })
  795. test('rendered personnel search clear button handles click and reloads unfiltered rows', async () => {
  796. const h = mobileHarness()
  797. h.openPersonnel('driver')
  798. h.calls[0].resolve({ list: [{ id: 'a' }], total: 1 })
  799. await tick()
  800. h.keyword.value = '沈卫斌'
  801. const filtered = h.picker.fetchList(true)
  802. h.calls.at(-1).resolve({ list: [{ id: 'filtered' }], total: 1 })
  803. await filtered
  804. const source = parse(
  805. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  806. ).descriptor.template!.content
  807. const template = source.match(/<van-search[\s\S]*?(?:\/>|<\/van-search>)/)![0]
  808. const vnode = vue.compile(template)(vue.proxyRefs(h), [])
  809. assert.equal(vnode.props.clearable, false, 'disable Vant touch-only clear')
  810. const button = vnode.children['right-icon']().find((node: any) => node.type === 'button')
  811. assert.equal(button.props['aria-label'], '清空人员搜索')
  812. button.props.onClick()
  813. assert.equal(h.keyword.value, '')
  814. await new Promise((resolve) => setTimeout(resolve, 350))
  815. assert.equal(h.calls.at(-1).params.q, undefined)
  816. h.calls.at(-1).resolve({ list: [{ id: 'a' }, { id: 'b' }], total: 2 })
  817. await tick()
  818. assert.deepEqual(
  819. h.rows.value.map((row: any) => row.id),
  820. ['a', 'b']
  821. )
  822. h.stop()
  823. })
  824. async function renderedQuantitySummary(h: ReturnType<typeof mobileHarness>) {
  825. const content = parse(
  826. readFileSync(new URL('../src/views/m/pl/plan/MobilePlPlanForm.vue', import.meta.url), 'utf8')
  827. ).descriptor.template!.content
  828. const template = content.match(/<div class="mobile-pl-plan-balance"[\s\S]*?<\/div>/)![0]
  829. const app = vue.createSSRApp({
  830. setup: () => ({
  831. c: h.c,
  832. quantitySummary: h.quantitySummary,
  833. isBalanced: h.isBalanced,
  834. formatQty: h.formatQty
  835. }),
  836. render: vue.compile(template)
  837. })
  838. app.component('VanTag', {
  839. setup:
  840. (_, { slots }) =>
  841. () =>
  842. vue.h('span', slots.default?.())
  843. })
  844. return (await renderToString(app))
  845. .replace(/<[^>]*>/g, ' ')
  846. .replace(/\s+/g, ' ')
  847. .trim()
  848. }
  849. for (const qty of [0, 1])
  850. test(`actual mobile template renders delivery-only draft ${qty} 个 without guessed tonnes`, async () => {
  851. const h = mobileHarness()
  852. Object.assign(h.c, {
  853. planTotalQty: 0,
  854. loadTotalQty: 0,
  855. deliveryTotalQty: qty,
  856. planQtyUnitLabel: '吨'
  857. })
  858. h.c.deliveryForms = [{ products: [{ unit: '个', planQty: qty }] }] as any
  859. const text = await renderedQuantitySummary(h)
  860. assert.match(text, new RegExp(`送货合计 ${qty}个`))
  861. assert.match(text, /计划总量 0 装货合计 0/)
  862. assert.doesNotMatch(text, /吨/)
  863. h.stop()
  864. })
  865. test('actual mobile summary groups mixed units and does not equate different quantities by scalar sum', async () => {
  866. const h = mobileHarness()
  867. h.c.formData.products = [
  868. { unit: '吨', quantity: 1 },
  869. { unit: '个', quantity: 2 }
  870. ] as any
  871. h.c.loadForm.products = [
  872. { unit: '吨', planQty: 1 },
  873. { unit: '个', planQty: 2 }
  874. ] as any
  875. h.c.deliveryForms = [
  876. { products: [{ unit: '吨', planQty: 2 }] },
  877. { products: [{ unit: '个', planQty: 1 }] }
  878. ] as any
  879. Object.assign(h.c, {
  880. planTotalQty: 3,
  881. loadTotalQty: 3,
  882. deliveryTotalQty: 3,
  883. planQtyUnitLabel: '吨',
  884. deliveryQtyBreakdown: '2 + 1 = 3'
  885. })
  886. const text = await renderedQuantitySummary(h)
  887. assert.match(text, /计划总量 1吨 \+ 2个/)
  888. assert.match(text, /装货合计 1吨 \+ 2个/)
  889. assert.match(text, /送货合计 2吨 \+ 1个/)
  890. assert.match(text, /数量不一致,仍可保存/)
  891. assert.doesNotMatch(text, /2 \+ 1 = 3|3吨/)
  892. h.stop()
  893. })
  894. test('actual mobile template keeps unknown units explicit and sums same units to three decimals', async () => {
  895. const h = mobileHarness()
  896. h.c.formData.products = [{ unit: '', quantity: 1.125 }] as any
  897. h.c.loadForm.products = [{ unit: '', planQty: 1.125 }] as any
  898. h.c.deliveryForms = [{ products: [{ unit: '', planQty: 1.125 }] }] as any
  899. let text = await renderedQuantitySummary(h)
  900. assert.match(text, /1.125(单位未填)/)
  901. assert.match(text, /单位未填,暂无法核对平衡/)
  902. assert.doesNotMatch(text, /数量平衡|吨/)
  903. h.c.formData.products = [
  904. { unit: '个', quantity: 1.125 },
  905. { unit: '个', quantity: 2.001 }
  906. ] as any
  907. h.c.loadForm.products = [{ unit: '个', planQty: 3.126 }] as any
  908. h.c.deliveryForms = [
  909. { products: [{ unit: '个', planQty: 1.125 }] },
  910. { products: [{ unit: '个', planQty: 2.001 }] }
  911. ] as any
  912. text = await renderedQuantitySummary(h)
  913. assert.match(text, /计划总量 3.126个 装货合计 3.126个 送货合计 3.126个/)
  914. assert.match(text, /数量平衡/)
  915. assert.match(text, /1.125个.*2.001个.*3.126个/)
  916. h.stop()
  917. })
  918. for (const [code, label, hasDictionary] of [
  919. ['dun', '吨', true],
  920. ['dun', '吨', false],
  921. ['jian', '件', true]
  922. ] as const) {
  923. test(`actual SFC normalizes ${code}/${label} with dictionary ${hasDictionary} for display and balance`, async () => {
  924. const h = mobileHarness()
  925. h.c.dictStore.getLabel = (type, value) =>
  926. hasDictionary && type === 'crm_product_unit' && value === code ? label : value
  927. h.c.formData.products = [{ unit: code, quantity: 1 }] as any
  928. h.c.loadForm.products = [{ unit: code, planQty: 1 }] as any
  929. h.c.deliveryForms = [
  930. { products: [{ unit: label, planQty: 0.25 }] },
  931. { products: [{ unit: code, planQty: 0.75 }] }
  932. ] as any
  933. const text = await renderedQuantitySummary(h)
  934. assert.match(text, new RegExp(`计划总量 1${label} 装货合计 1${label} 送货合计 1${label}`))
  935. assert.match(text, /✓ 数量平衡/)
  936. assert.match(text, new RegExp(`0.25${label}.*0.75${label}.*1${label}`))
  937. assert.ok(!text.includes(code))
  938. // Pure display normalization must not rewrite any business unit value.
  939. assert.equal((h.c.formData.products[0] as any).unit, code)
  940. assert.equal((h.c.loadForm.products[0] as any).unit, code)
  941. assert.equal((h.c.deliveryForms[0] as any).products[0].unit, label)
  942. h.stop()
  943. })
  944. }