test-mobile-dispatch-calendar.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import { chromium } from 'playwright'
  4. const menuSource = await readFile(
  5. new URL('../../miniprogram/config/workbench-menus.js', import.meta.url),
  6. 'utf8'
  7. )
  8. const menuModule = await import(
  9. `data:text/javascript;base64,${Buffer.from(menuSource).toString('base64')}`
  10. )
  11. const groups = Object.values(menuModule).find(Array.isArray)
  12. const entry = groups
  13. .find((group) => group.key === 'pl')
  14. .menus.find((menu) => menu.code === 'pl-dispatch-calendar-webview')
  15. assert.equal(entry.name, '派车日历')
  16. assert.equal(entry.perms, 'mini:pl:dispatch-calendar:list')
  17. assert.equal(entry.enabled, true)
  18. const config = await readFile(new URL('../../miniprogram/config/api.js', import.meta.url), 'utf8')
  19. assert.match(
  20. config,
  21. /'pl-dispatch-calendar-webview':\s*PAGE_PATHS.mpWebview\s*\+\s*'\?path='\s*\+\s*encodeURIComponent\('\/m\/pl\/dispatch-calendar'\)/
  22. )
  23. const browser = await chromium.launch({ headless: true, channel: 'chrome' })
  24. const context = await browser.newContext({
  25. viewport: { width: 375, height: 812 },
  26. isMobile: true,
  27. hasTouch: true,
  28. permissions: ['clipboard-read', 'clipboard-write']
  29. })
  30. const page = await context.newPage()
  31. page.setDefaultTimeout(10000)
  32. await page.clock.setFixedTime(new Date('2026-10-25T04:00:00Z'))
  33. const errors = []
  34. page.on('pageerror', (error) => errors.push(error.message))
  35. const plan = (id, date, vehicle = 'v1') => ({
  36. id,
  37. planNo: id,
  38. planDate: date,
  39. followStatus: 'pending',
  40. vehicleId: vehicle,
  41. licensePlate: vehicle === 'v1' ? '皖H21522' : '皖HA4855',
  42. load: { customerName: '安庆山峡' },
  43. deliveries: [
  44. {
  45. id: `${id}-a`,
  46. customerName: '安庆奇创',
  47. products: [{ productId: 'acid', productName: '98酸' }]
  48. },
  49. {
  50. id: `${id}-b`,
  51. customerName: '安庆奇创',
  52. products: [{ productId: 'acid', productName: '98酸' }]
  53. },
  54. {
  55. id: `${id}-c`,
  56. customerName: '安徽云栋',
  57. products: [{ productId: 'base', productName: '液碱' }]
  58. }
  59. ]
  60. })
  61. const rows = [plan('p1', '2026-10-25'), plan('p2', '2026-10-26', 'v2')]
  62. let failNext = false
  63. let delayOctober = false
  64. let exchanges = 0
  65. const queries = []
  66. await context.addInitScript(() => {
  67. window.wx = {
  68. miniProgram: {
  69. reLaunch: ({ url }) => {
  70. window.__backTarget = url
  71. }
  72. }
  73. }
  74. window.__wxjs_environment = 'miniprogram'
  75. })
  76. await context.route('**/api/**', async (route) => {
  77. const url = new URL(route.request().url())
  78. if (!url.pathname.startsWith('/api/')) return route.continue()
  79. const path = url.pathname.slice(4)
  80. if (path === '/web/auth/mp-webview-exchange') {
  81. exchanges++
  82. return route.fulfill({
  83. json: {
  84. code: 200,
  85. data: {
  86. token: 'mobile-calendar-fixture',
  87. refreshToken: 'fixture-refresh',
  88. expiresIn: 3600,
  89. userInfo: { id: 'fixture', userId: 'fixture', userName: '日历测试', roles: ['admin'] }
  90. }
  91. }
  92. })
  93. }
  94. if (path === '/transport/plan/') {
  95. assert.equal(exchanges, 1, 'business request after ticket exchange')
  96. queries.push(Object.fromEntries(url.searchParams))
  97. if (failNext) {
  98. failNext = false
  99. return route.fulfill({ status: 500, json: { detail: 'expected fixture error' } })
  100. }
  101. const october = url.searchParams.get('planDateFrom') === '2026-10-01'
  102. if (delayOctober && october) await new Promise((resolve) => setTimeout(resolve, 800))
  103. return route.fulfill({
  104. json: {
  105. list: october ? rows.map((item) => ({ calendarVehicleType: 'self', ...item })) : [],
  106. total: october ? rows.length : 0
  107. }
  108. })
  109. }
  110. return route.fulfill({ json: { code: 200, data: {} } })
  111. })
  112. async function chooseVehicle(label, cancel = false) {
  113. await page.getByLabel('车辆筛选', { exact: true }).click()
  114. const popup = page.locator('.mp-calendar-vehicle-picker:visible')
  115. await popup.waitFor()
  116. await popup.locator('.van-picker-column__item').getByText(label, { exact: true }).click()
  117. await popup.getByRole('button', { name: cancel ? '取消' : '确认', exact: true }).click()
  118. await popup.waitFor({ state: 'hidden' })
  119. }
  120. async function selectMonth(value) {
  121. await page.getByLabel('查看月份', { exact: true }).click()
  122. const popup = page.locator('.mp-calendar-date-picker:visible')
  123. await popup.locator('.van-picker-column').nth(1).getByText(value, { exact: true }).click()
  124. await popup.getByRole('button', { name: '确认', exact: true }).click()
  125. await popup.waitFor({ state: 'hidden' })
  126. }
  127. async function expand(day) {
  128. await day.waitFor()
  129. await day.locator('.van-collapse-item__title > .van-cell__right-icon').click()
  130. await day.locator('.mp-calendar-plate').first().waitFor()
  131. }
  132. try {
  133. await page.goto(
  134. `${process.env.CALENDAR_TEST_ORIGIN || 'http://localhost:3006'}/#/m/pl/dispatch-calendar?mpTicket=calendar-test`
  135. )
  136. await page.getByRole('tab', { name: '实际派车信息', exact: true }).click()
  137. const day = page.locator('[data-date="2026-10-25"]')
  138. await day.waitFor()
  139. assert.equal(await page.getByLabel('定位日期', { exact: true }).count(), 0, '移除日期筛选')
  140. assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 0, '日期默认折叠')
  141. assert.equal(
  142. await page
  143. .locator(
  144. '.mp-calendar-controls select, .mp-calendar-controls input[type="date"], .mp-calendar-controls input[type="month"]'
  145. )
  146. .count(),
  147. 0,
  148. '使用 Vant 选择器'
  149. )
  150. await day.locator('.van-collapse-item__title > .van-cell__right-icon').click()
  151. await day.locator('.mp-calendar-plate').first().waitFor()
  152. assert.equal(await day.locator('.mp-calendar-task').count(), 2)
  153. assert.match(await day.textContent(), /1车次\s*\/\s*2段次/)
  154. assert.match(await day.textContent(), /山峡 → 云栋/)
  155. assert.match(await day.locator('.mp-calendar-day-count').textContent(), /1\s*车次/)
  156. assert.equal(await day.locator('.mp-calendar-day-count').evaluate((el) => el.tagName), 'BUTTON')
  157. assert.ok(!page.url().includes('mpTicket'))
  158. assert.equal(queries[0].includeCustomerShortNames, '1')
  159. assert.equal(queries[0].planDateTo, '2026-10-31')
  160. assert.equal(await page.locator('.calendar-grid').count(), 0)
  161. await day.locator('.mp-calendar-day-count').click()
  162. await page.locator('.mp-calendar-summary:visible').waitFor()
  163. assert.match(await page.locator('.mp-calendar-summary-total').textContent(), /合计\s*1\s*车次/)
  164. assert.equal(await page.locator('.mp-calendar-summary-row').count(), 2)
  165. await page.screenshot({ path: '/tmp/mobile-calendar-implemented-summary.png' })
  166. await page.getByRole('button', { name: '完成', exact: true }).click()
  167. await day.locator('.van-collapse-item__title > .van-cell__right-icon').click()
  168. await day.locator('.mp-calendar-vehicle').first().waitFor({ state: 'hidden' })
  169. assert.equal(await day.locator('.mp-calendar-total').count(), 0, '删除展开区域的合计按钮')
  170. await day.locator('.mp-calendar-day-count').click()
  171. await page.locator('.mp-calendar-summary:visible').waitFor()
  172. await page.getByRole('button', { name: '完成', exact: true }).click()
  173. await page.locator('.mp-calendar-summary').waitFor({ state: 'hidden' })
  174. assert.equal(
  175. await day.locator('.mp-calendar-vehicle:visible').count(),
  176. 0,
  177. '折叠时点击车次不展开日期'
  178. )
  179. await day.locator('.mp-calendar-day-count').press('Enter')
  180. await page.locator('.mp-calendar-summary:visible').waitFor()
  181. await page.getByRole('button', { name: '完成', exact: true }).click()
  182. await page.locator('.mp-calendar-summary').waitFor({ state: 'hidden' })
  183. assert.equal(
  184. await day.locator('.mp-calendar-vehicle:visible').count(),
  185. 0,
  186. '键盘查看汇总不改变折叠状态'
  187. )
  188. await page.screenshot({ path: '/tmp/mobile-calendar-collapsed.png' })
  189. await page.getByLabel('车辆筛选', { exact: true }).click()
  190. const vehiclePopup = page.locator('.mp-calendar-vehicle-picker:visible')
  191. const search = vehiclePopup.getByPlaceholder('输入车牌关键词')
  192. await search.fill('h215')
  193. await vehiclePopup.getByText('皖H21522', { exact: true }).waitFor()
  194. assert.equal(await vehiclePopup.getByText('皖HA4855', { exact: true }).count(), 0)
  195. await search.fill('不存在')
  196. await vehiclePopup.getByText('没有匹配的车辆').waitFor()
  197. await vehiclePopup.getByRole('button', { name: '确认', exact: true }).click()
  198. assert.ok(await vehiclePopup.isVisible(), '无匹配结果不能误提交全部车辆')
  199. await vehiclePopup.getByRole('button', { name: '取消', exact: true }).click()
  200. await vehiclePopup.waitFor({ state: 'hidden' })
  201. await chooseVehicle('皖H21522', true)
  202. assert.equal(
  203. await page.getByLabel('车辆筛选', { exact: true }).locator('input').inputValue(),
  204. '全部车辆'
  205. )
  206. await page.getByLabel('车辆筛选', { exact: true }).click()
  207. await vehiclePopup.getByPlaceholder('输入车牌关键词').fill(' h215 ')
  208. await vehiclePopup.getByRole('button', { name: '确认', exact: true }).click()
  209. await vehiclePopup.waitFor({ state: 'hidden' })
  210. assert.equal(
  211. await page.getByLabel('车辆筛选', { exact: true }).locator('input').inputValue(),
  212. '皖H21522'
  213. )
  214. assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 0, '筛选后默认折叠')
  215. await expand(day)
  216. assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 1)
  217. await chooseVehicle('全部车辆')
  218. await page.getByLabel('查看月份', { exact: true }).click()
  219. const monthPicker = page.locator('.mp-calendar-date-picker:visible')
  220. assert.equal(await monthPicker.locator('.van-picker-column').count(), 2)
  221. await monthPicker.locator('.van-picker-column').nth(1).getByText('11', { exact: true }).click()
  222. await monthPicker.getByRole('button', { name: '确认', exact: true }).click()
  223. await page.getByText('本月暂无派车计划', { exact: true }).waitFor()
  224. failNext = true
  225. await selectMonth('10')
  226. await page.getByRole('button', { name: '重试加载' }).waitFor()
  227. await page.getByRole('button', { name: '重试加载' }).click()
  228. await expand(day)
  229. await page.getByRole('button', { name: '今天', exact: true }).click()
  230. assert.ok(await day.evaluate((el) => el.classList.contains('is-selected')))
  231. // 密集内容只增加文档高度,不引入日内滚动或多列。
  232. for (let i = 0; i < 12; i++) rows.push(plan(`dense-${i}`, '2026-10-25', `v${i + 3}`))
  233. await selectMonth('11')
  234. await page.getByText('本月暂无派车计划', { exact: true }).waitFor()
  235. await selectMonth('10')
  236. assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 0)
  237. await expand(day)
  238. for (const width of [375, 320, 414]) {
  239. await page.setViewportSize({ width, height: 812 })
  240. assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
  241. assert.ok(
  242. await day.evaluate((el) => !['auto', 'scroll'].includes(getComputedStyle(el).overflowY))
  243. )
  244. const cards = await day.locator('.mp-calendar-vehicle').evaluateAll((els) =>
  245. els.map((el) => {
  246. const r = el.getBoundingClientRect()
  247. return { x: r.x, y: r.y, bottom: r.bottom }
  248. })
  249. )
  250. assert.equal(cards.length, 13)
  251. assert.ok(
  252. cards.every(
  253. (card, index) => !index || (card.x === cards[0].x && card.y >= cards[index - 1].bottom)
  254. )
  255. )
  256. }
  257. await page.setViewportSize({ width: 375, height: 812 })
  258. await page.screenshot({ path: '/tmp/mobile-calendar-implemented.png' })
  259. // 快速来回切月,旧月份响应不得覆盖新月份。
  260. await selectMonth('11')
  261. await page.getByText('本月暂无派车计划', { exact: true }).waitFor()
  262. delayOctober = true
  263. await selectMonth('10')
  264. await selectMonth('11')
  265. await page.getByText('本月暂无派车计划', { exact: true }).waitFor()
  266. await page.waitForTimeout(1000)
  267. assert.equal(await page.locator('.mp-calendar-vehicle').count(), 0)
  268. delayOctober = false
  269. rows.splice(
  270. 0,
  271. rows.length,
  272. { ...plan('own', '2026-10-25'), calendarVehicleType: 'self' },
  273. { ...plan('carrier', '2026-10-25', 'v2'), calendarVehicleType: 'carrier' },
  274. { ...plan('unknown', '2026-10-25', 'v3'), calendarVehicleType: 'unknown' },
  275. { ...plan('other-day', '2026-10-26', 'v2'), calendarVehicleType: 'carrier' }
  276. )
  277. await selectMonth('10')
  278. await expand(day)
  279. assert.equal(await day.locator('.mp-calendar-vehicle').count(), 1)
  280. const nextDay = page.locator('[data-date="2026-10-26"]')
  281. await nextDay.locator('.van-collapse-item__title > .van-cell__right-icon').click()
  282. await nextDay.getByText('当天暂无山峡自有车辆').waitFor()
  283. await day.getByRole('button', { name: '全部', exact: true }).click()
  284. await day.getByRole('button', { name: '复制', exact: true }).click()
  285. let copied = await page.evaluate(() => navigator.clipboard.readText())
  286. assert.match(copied, /^运输计划 10\.25/)
  287. assert.equal(copied.split('\n').length, 4, '全部范围复制三个车辆组')
  288. assert.equal(await day.locator('.mp-calendar-vehicle').count(), 3)
  289. assert.match(await day.locator('.mp-calendar-day-count').textContent(), /3\s*车次/)
  290. assert.equal(await nextDay.locator('.mp-calendar-vehicle').count(), 0)
  291. assert.ok(await nextDay.getByRole('button', { name: '复制', exact: true }).isDisabled())
  292. await day.locator('.mp-calendar-day-count').click()
  293. assert.match(await page.locator('.mp-calendar-summary-total').textContent(), /合计\s*3\s*车次/)
  294. await page.getByRole('button', { name: '完成', exact: true }).click()
  295. await page.locator('.mp-calendar-summary').waitFor({ state: 'hidden' })
  296. await nextDay.getByRole('button', { name: '全部', exact: true }).click()
  297. await day.getByRole('button', { name: '山峡车辆', exact: true }).click()
  298. assert.equal(await day.locator('.mp-calendar-vehicle').count(), 1)
  299. assert.equal(await nextDay.locator('.mp-calendar-vehicle').count(), 1)
  300. await day.getByRole('button', { name: '复制', exact: true }).click()
  301. copied = await page.evaluate(() => navigator.clipboard.readText())
  302. assert.equal(copied.split('\n').length, 2, '山峡车辆范围仅复制自有车辆')
  303. assert.match(copied, /皖H21522/)
  304. assert.ok(!copied.includes('皖HA4855'))
  305. await nextDay.getByRole('button', { name: '复制', exact: true }).click()
  306. assert.match(await page.evaluate(() => navigator.clipboard.readText()), /^明日计划 10\.26/)
  307. await page.evaluate(() => {
  308. window.__copyWrite = navigator.clipboard.writeText.bind(navigator.clipboard)
  309. window.__copyExec = document.execCommand
  310. navigator.clipboard.writeText = async () => {
  311. throw new Error('fixture clipboard denied')
  312. }
  313. document.execCommand = () => false
  314. })
  315. await day.getByRole('button', { name: '复制', exact: true }).click()
  316. await page.getByText('复制失败,请重试', { exact: true }).waitFor()
  317. await page.evaluate(() => {
  318. navigator.clipboard.writeText = window.__copyWrite
  319. document.execCommand = window.__copyExec
  320. })
  321. for (const width of [320, 375, 414]) {
  322. await page.setViewportSize({ width, height: 812 })
  323. assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
  324. const controls = await page.locator('.mp-calendar-month-row > *').evaluateAll((els) =>
  325. els.map((el) => {
  326. const r = el.getBoundingClientRect()
  327. return { top: r.top, bottom: r.bottom, width: r.width }
  328. })
  329. )
  330. assert.equal(controls.length, 3)
  331. assert.ok(
  332. controls.every(
  333. (r) => Math.abs(r.top - controls[0].top) < 1 && r.bottom - r.top >= 44 && r.width >= 44
  334. ),
  335. '月份、今天、车辆同一行且触达尺寸足够'
  336. )
  337. assert.ok(
  338. await day.locator('.mp-calendar-day-count').evaluate((button) => {
  339. const rect = button.getBoundingClientRect()
  340. const arrow = button
  341. .closest('.van-cell')
  342. .querySelector('.van-cell__right-icon')
  343. .getBoundingClientRect()
  344. return rect.width >= 44 && rect.height >= 44 && arrow.left - rect.right >= 8
  345. }),
  346. '车次按钮触达至少44px,且与展开箭头留出间隔'
  347. )
  348. const centered = await page.locator('.mp-calendar-today').evaluate((button) => {
  349. const outer = button.getBoundingClientRect(),
  350. text = button.querySelector('.van-button__text').getBoundingClientRect()
  351. return Math.abs((outer.left + outer.right - text.left - text.right) / 2) < 1
  352. })
  353. assert.ok(centered, '今天文字水平居中')
  354. }
  355. await page.setViewportSize({ width: 375, height: 812 })
  356. await page.getByRole('button', { name: '今天', exact: true }).click()
  357. await page.screenshot({ path: '/tmp/calendar-daily-ownership-mini.png' })
  358. await page.getByRole('button', { name: '返回工作台', exact: true }).click()
  359. assert.equal(await page.evaluate(() => window.__backTarget), '/pages/workbench/workbench')
  360. assert.deepEqual(errors, [])
  361. console.log(
  362. 'PASS mobile calendar: native menu, ticket, split/merged stops, unique totals, Vant pickers confirm/cancel, collapsed days, empty day/month, retry, race, back, 320/375/414px and dense layout'
  363. )
  364. } catch (error) {
  365. await page.screenshot({ path: '/tmp/mobile-calendar-vant-failure.png' })
  366. console.error('Test failure:', error)
  367. console.error('Page errors:', errors)
  368. console.error('Picker DOM:', await page.locator('.mp-calendar-picker').allTextContents())
  369. throw error
  370. } finally {
  371. await context.close()
  372. await browser.close()
  373. }