test-mp-pl-plan-cert.mjs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. import { chromium } from 'playwright'
  2. import assert from 'node:assert/strict'
  3. import { mkdir } from 'node:fs/promises'
  4. import { installCertFixture } from './mp-pl-plan-cert-fixture.mjs'
  5. const origin = process.env.PLAN_CERT_TEST_ORIGIN || 'http://localhost:3006'
  6. const artifacts = '/tmp/shanxia-plan-cert-e2e'
  7. await mkdir(artifacts, { recursive: true })
  8. const browser = await chromium.launch({ headless: true, channel: 'chrome' })
  9. const context = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true })
  10. await context.addInitScript(() => {
  11. const start = () => {
  12. const root = document.documentElement
  13. if (!root) return
  14. const hide = () => {
  15. const el = document.getElementById('__vue-devtools-container__')
  16. if (el) el.style.setProperty('display', 'none', 'important')
  17. }
  18. hide()
  19. new MutationObserver(hide).observe(root, { childList: true, subtree: true })
  20. }
  21. if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start)
  22. else start()
  23. })
  24. const fixture = await installCertFixture(context)
  25. const page = await context.newPage()
  26. const pageErrors = []
  27. let releaseLinkedList
  28. page.on('pageerror', (error) => pageErrors.push(error.message))
  29. page.setDefaultTimeout(12000)
  30. const button = (name) => page.getByRole('button', { name, exact: true }).filter({ visible: true })
  31. const card = (id) => page.locator(`.cert-card[data-plan-id="plan-${id}"]`)
  32. const sheet = (title) =>
  33. page.locator('.van-popup, .mp-sheet').filter({
  34. has: page.getByText(title, { exact: true }),
  35. visible: true
  36. })
  37. const noOverflow = async (scope, label) =>
  38. assert.ok(await scope.evaluate((el) => el.scrollWidth <= el.clientWidth + 1), `${label} 横向溢出`)
  39. const assertCenteredSheetActions = async (scope, label) => {
  40. const actions = scope.locator('.mp-customer-filter-vant__foot')
  41. const styles = await actions.evaluate((el) => {
  42. const buttons = [...el.querySelectorAll('button')].map((button) => ({
  43. height: button.getBoundingClientRect().height
  44. }))
  45. return { buttons }
  46. })
  47. assert.equal(styles.buttons.length, 2, `${label}底栏必须保留重置和确定`)
  48. for (const style of styles.buttons) {
  49. assert.ok(style.height >= 30, `${label}按钮须与报价单筛选底栏同高`)
  50. }
  51. }
  52. const waitPatch = (suffix, action) =>
  53. Promise.all([
  54. page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().endsWith(suffix)),
  55. action()
  56. ])
  57. const cleanScreenshot = async (name) => {
  58. await page.locator('.el-message').last().waitFor({ state: 'hidden' })
  59. await page.screenshot({ path: `${artifacts}/${name}.png`, animations: 'disabled' })
  60. }
  61. const latestList = () => fixture.calls.filter((c) => c.path === '/transport/plan/').at(-1)
  62. const quickDate = () => page.locator('.mp-customer-dd .van-dropdown-menu__item').nth(0)
  63. const quickReady = () => page.locator('.mp-customer-dd .van-dropdown-menu__item').nth(1)
  64. const quickMore = () => page.locator('.mp-customer-dd .van-dropdown-menu__item').nth(2)
  65. const quickOption = (label) =>
  66. page.locator('.van-dropdown-item__option').filter({ hasText: label }).first()
  67. const waitList = (action) =>
  68. Promise.all([
  69. page.waitForResponse((r) => new URL(r.url()).pathname === '/api/transport/plan/'),
  70. action()
  71. ])
  72. try {
  73. await page.goto(`${origin}/#/m/pl/plan-cert/plan-1?mpTicket=cert-fixture`)
  74. const directDetail = page.locator('.cert-detail-page')
  75. await directDetail.getByText('YS001', { exact: true }).waitFor()
  76. await page.reload()
  77. await directDetail.getByText('YS001', { exact: true }).waitFor()
  78. assert.equal(
  79. await directDetail.getByRole('heading', { name: '危化品证详情', exact: true }).count(),
  80. 1,
  81. '独立详情必须支持携带 mpTicket 直达和刷新'
  82. )
  83. await page.goto(`${origin}/#/m/pl/plan-cert?mpTicket=cert-fixture`)
  84. const tabs = ['未上传证书', '全部', '入库单', '出库单', '直销单']
  85. await page.getByRole('tab', { name: tabs[0], exact: true }).waitFor()
  86. const header = page.locator('.mp-wv-header')
  87. assert.equal(await header.count(), 1, '列表必须复用标准 mp-wv-header')
  88. assert.equal(await page.locator('.cert-header').count(), 0, '不得保留自定义 cert-header')
  89. assert.equal(await header.locator('.mp-wv-header__sub').count(), 0, '标准列表顶栏不得显示副标题')
  90. assert.equal(
  91. await header.locator('.mp-wv-search').count(),
  92. 1,
  93. '检索框必须放在绿顶栏内、状态 Tab 下方'
  94. )
  95. assert.equal(
  96. await header.locator('.mp-customer-view-tabs').count(),
  97. 1,
  98. '状态 Tab 必须放在绿顶栏内'
  99. )
  100. assert.equal(await page.locator('.cert-search-bar').count(), 0, '不得另做检索条')
  101. assert.equal(
  102. await header.locator('h1').evaluate((el) => getComputedStyle(el).fontSize),
  103. '17px',
  104. '页面标题字号必须与标准一致'
  105. )
  106. assert.deepEqual(await page.getByRole('tab').allTextContents(), tabs)
  107. assert.deepEqual(
  108. (await page.locator('.mp-customer-dd .van-dropdown-menu__title').allTextContents()).map((text) =>
  109. text.replace(/\s+/g, '')
  110. ),
  111. ['全部日期', '全部齐备状态', '更多'],
  112. '检索框下方必须紧跟日期、齐备状态、更多三栏筛选'
  113. )
  114. const headerBox = await header.boundingBox()
  115. const tabsBox = await header.locator('.mp-customer-view-tabs').boundingBox()
  116. const searchBox = await header.locator('.mp-wv-search').boundingBox()
  117. const filterBox = await header.locator('.mp-customer-dd').boundingBox()
  118. assert.ok(tabsBox.y >= headerBox.y, '状态 Tab 必须落在绿顶栏区域内')
  119. assert.ok(tabsBox.y + tabsBox.height <= headerBox.y + headerBox.height + 1, '状态 Tab 不得溢出绿顶栏')
  120. assert.ok(tabsBox.y + tabsBox.height <= searchBox.y + 1, '状态 Tab 必须在检索框上方')
  121. assert.ok(searchBox.y < filterBox.y, '检索框必须在三栏筛选上方')
  122. assert.ok(searchBox.y + searchBox.height <= filterBox.y + 1, '检索框与三栏筛选必须紧贴')
  123. assert.ok(filterBox.y + filterBox.height <= headerBox.y + headerBox.height + 1, '三栏筛选必须落在绿顶栏内')
  124. assert.equal(Math.round(headerBox.x), 0, '绿顶栏必须横向铺满视口')
  125. assert.equal(Math.round(headerBox.width), 375, '绿顶栏不得叠加 Shell 左右留白')
  126. for (const [label, box] of [
  127. ['检索框', searchBox],
  128. ['三栏筛选', filterBox]
  129. ]) {
  130. assert.ok(box.x >= headerBox.x, `${label}必须落在绿顶栏内`)
  131. assert.ok(box.x + box.width <= headerBox.x + headerBox.width + 1, `${label}不得溢出绿顶栏`)
  132. }
  133. assert.equal(await page.locator('.cert-shortcuts').count(), 0, '不得保留横向日期 Chip')
  134. assert.equal(await page.locator('.cert-tools').count(), 0, '不得保留筛选/刷新/重置工具条')
  135. await page.getByText('YS001', { exact: true }).first().waitFor()
  136. assert.ok(await card(1).evaluate((el) => el.classList.contains('expense-card')))
  137. const listCardBox = await card(1).boundingBox()
  138. assert.ok(listCardBox.x >= 10, '列表卡片左右必须留白')
  139. assert.ok(listCardBox.width <= 355, '列表卡片不得铺满视口')
  140. const summary = page.locator('.cert-total').filter({ hasText: /共 \d+ 条计划/ })
  141. assert.equal(await summary.count(), 1, '列表底部必须展示条数汇总')
  142. const summaryBox = await summary.boundingBox()
  143. assert.ok(summaryBox.y > listCardBox.y, '条数汇总必须在列表卡片之后')
  144. assert.equal(await card(1).locator('.ec-top').count(), 1)
  145. assert.equal(await card(1).locator('.list-card-kv').count(), 1)
  146. assert.equal(await card(1).locator('.ec-bottom').count(), 1)
  147. assert.equal(latestList().params.hasHazardProduct, '1')
  148. assert.equal(latestList().params.certUploadStatus, 'incomplete')
  149. assert.equal(latestList().params.hazardCertReady, '0')
  150. assert.ok(!latestList().params.followStatus.split(',').includes('cancelled'))
  151. const firstStatusTabBox = await page
  152. .getByRole('tab', { name: '未上传证书', exact: true })
  153. .boundingBox()
  154. assert.ok(firstStatusTabBox.height >= 44, '状态 Tab 命中高度必须至少为 44px')
  155. assert.ok(
  156. await page.getByRole('tab', { name: '未上传证书', exact: true }).evaluate((el) => {
  157. const text = el.querySelector('.van-tab__text') || el
  158. const line = el.closest('.van-tabs')?.querySelector('.van-tabs__line')
  159. const textBox = text.getBoundingClientRect()
  160. const lineBox = line?.getBoundingClientRect()
  161. return (
  162. getComputedStyle(text).whiteSpace === 'nowrap' &&
  163. textBox.height < 28 &&
  164. !!lineBox &&
  165. lineBox.top >= textBox.bottom
  166. )
  167. }),
  168. '未上传证书必须单行显示,下划线在文字下方'
  169. )
  170. await quickReady().click()
  171. await waitList(() => quickOption('已齐备').click())
  172. assert.equal(
  173. await page.getByRole('tab', { name: '全部', exact: true }).getAttribute('aria-selected'),
  174. 'true',
  175. '未上传 Tab 选择已齐备后必须切换到全部,避免展示条件与请求冲突'
  176. )
  177. assert.equal(latestList().params.hazardCertReady, '1')
  178. await waitList(() => page.getByRole('tab', { name: '未上传证书', exact: true }).click())
  179. assert.equal(await button('导出').count(), 0)
  180. for (const [label, type] of [
  181. ['全部', undefined],
  182. ['入库单', 'inbound'],
  183. ['出库单', 'outbound'],
  184. ['直销单', 'direct']
  185. ]) {
  186. await waitList(() => page.getByRole('tab', { name: label, exact: true }).click())
  187. assert.equal(latestList().params.planType, type)
  188. assert.equal(latestList().params.hasHazardProduct, '1')
  189. assert.equal(latestList().params.certUploadStatus, undefined)
  190. }
  191. await waitList(() => page.getByRole('tab', { name: '全部', exact: true }).click())
  192. await quickReady().click()
  193. await waitList(() => quickOption('未齐备').click())
  194. assert.equal(latestList().params.hazardCertReady, '0')
  195. await quickReady().click()
  196. await waitList(() => quickOption('全部齐备状态').click())
  197. assert.equal(latestList().params.hazardCertReady, undefined)
  198. for (const width of [375, 414]) {
  199. await page.setViewportSize({ width, height: 812 })
  200. assert.ok(
  201. await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
  202. `列表 ${width}px 横向溢出`
  203. )
  204. await page.screenshot({ path: `${artifacts}/list-${width}.png`, fullPage: true })
  205. }
  206. await page.setViewportSize({ width: 375, height: 812 })
  207. await page.getByRole('checkbox', { name: '选择 YS001', exact: true }).check()
  208. await quickDate().click()
  209. await waitList(() => quickOption('今天').click())
  210. assert.equal(latestList().params.planDateFrom, new Date().toLocaleDateString('sv-SE'))
  211. assert.equal(
  212. await page.getByRole('checkbox', { name: '选择 YS001', exact: true }).isChecked(),
  213. false
  214. )
  215. await page.getByRole('searchbox', { name: '搜索计划' }).fill('YS002')
  216. await waitList(() => page.getByRole('searchbox', { name: '搜索计划' }).press('Enter'))
  217. assert.equal(latestList().params.q, 'YS002')
  218. await card(2).waitFor()
  219. assert.equal(await page.locator('.cert-card').count(), 1)
  220. await page.getByRole('searchbox', { name: '搜索计划' }).fill('')
  221. await waitList(() => page.getByRole('searchbox', { name: '搜索计划' }).press('Enter'))
  222. // 同一筛选面板覆盖动态枚举、日期、数值、文本、合同侧别与排序。
  223. await quickMore().click()
  224. const filter = sheet('更多筛选')
  225. assert.ok(await filter.locator('.filter-chip').count(), '更多筛选中的静态枚举必须使用 Chip')
  226. const field = (label) =>
  227. filter.locator('.el-form-item').filter({ has: page.getByText(label, { exact: true }) })
  228. await field('车牌号').locator('.el-select').click()
  229. await page.getByRole('option', { name: '鄂E12345', exact: true }).waitFor()
  230. const facetCall = fixture.calls.filter((c) => c.path.endsWith('column-facet-values')).at(-1)
  231. assert.equal(facetCall.body.planDateFrom, new Date().toLocaleDateString('sv-SE'))
  232. await field('车牌号').locator('.el-select').click()
  233. await field('计划日期').locator('input').nth(0).fill('2026-10-20')
  234. await field('计划日期').locator('input').nth(1).fill('2026-10-01')
  235. await button('确定').click()
  236. await page.getByText('计划日期开始不能晚于结束', { exact: true }).waitFor()
  237. assert.equal(await filter.isVisible(), true)
  238. await field('计划日期').locator('input').nth(0).fill('2026-10-01')
  239. await field('计划日期').locator('input').nth(1).fill('2026-10-31')
  240. await field('人员').locator('input').fill('测试司机')
  241. await filter.getByRole('spinbutton', { name: '计划量最小值' }).fill('5')
  242. await filter.getByRole('spinbutton', { name: '计划量最大值' }).fill('20')
  243. await field('装货客户').locator('.el-select').click()
  244. await page.getByRole('option', { name: '测试供应商', exact: true }).click()
  245. await field('装货客户').locator('.el-select').click()
  246. await field('合同').locator('.el-select').click()
  247. await page.getByRole('option', { name: '供', exact: true }).click()
  248. await page.getByRole('option', { name: 'HT-001', exact: true }).click()
  249. await field('合同').locator('.el-select').click()
  250. await field('排序字段').locator('.el-select').click()
  251. await page.getByRole('option', { name: '计划日期', exact: true }).click()
  252. assert.equal(
  253. await field('排序字段')
  254. .locator('input')
  255. .evaluate((el) => getComputedStyle(el).backgroundColor),
  256. 'rgba(0, 0, 0, 0)',
  257. 'select内部输入不得用白底遮住选中标签'
  258. )
  259. for (const width of [375, 414]) {
  260. await page.setViewportSize({ width, height: 812 })
  261. await noOverflow(filter.locator('.mp-customer-filter-vant__body'), `筛选${width}`)
  262. await cleanScreenshot(`filters-${width}`)
  263. }
  264. await assertCenteredSheetActions(filter, '筛选Sheet')
  265. await waitList(() => button('确定').click())
  266. assert.equal(latestList().params.planDateFrom, '2026-10-01')
  267. assert.equal(latestList().params.personnelSummary, '测试司机')
  268. assert.equal(latestList().params.productPlanQtyMin, '5')
  269. assert.equal(latestList().params.productPlanQtyMax, '20')
  270. assert.equal(latestList().params.stopKindIn, 'load')
  271. assert.equal(latestList().params.contractNoIn, 'HT-001')
  272. assert.match(latestList().params.stopCustomers, /测试供应商/)
  273. assert.equal(latestList().params.sortField, 'planDate')
  274. await quickMore().click()
  275. await waitList(() => button('重置').click())
  276. await waitList(() => page.getByRole('tab', { name: '未上传证书', exact: true }).click())
  277. await page.setViewportSize({ width: 375, height: 812 })
  278. await card(24).waitFor()
  279. assert.ok(
  280. fixture.calls.some(
  281. (call) =>
  282. call.path === '/transport/plan/' &&
  283. call.params.page === '2' &&
  284. call.params.certUploadStatus === 'incomplete' &&
  285. call.params.q == null
  286. ),
  287. '滚到底必须沿用当前已提交的查询请求下一页'
  288. )
  289. assert.equal(await page.locator('.cert-card').count(), 22)
  290. assert.equal(await button('加载更多').count(), 0, '不得再使用加载更多按钮')
  291. // 请求乱序:慢入库响应不能覆盖后发的出库页。
  292. let releaseList
  293. const listGate = new Promise((resolve) => {
  294. releaseList = resolve
  295. })
  296. fixture.listDelay = (params) => (params.planType === 'inbound' ? listGate : undefined)
  297. await page.getByRole('tab', { name: '入库单', exact: true }).click()
  298. await waitList(() => page.getByRole('tab', { name: '出库单', exact: true }).click())
  299. await card(2).waitFor()
  300. await Promise.all([
  301. page.waitForResponse((r) => new URL(r.url()).searchParams.get('planType') === 'inbound'),
  302. Promise.resolve().then(() => releaseList())
  303. ])
  304. fixture.listDelay = null
  305. assert.equal(await card(1).count(), 0)
  306. await waitList(() => page.getByRole('tab', { name: '未上传证书', exact: true }).click())
  307. // 逐项读取最新状态,批量报告成功/失败/跳过。
  308. assert.equal(
  309. await card(4).getByRole('button', { name: '上传证书', exact: true }).isDisabled(),
  310. true
  311. )
  312. for (const id of [1, 2, 4])
  313. await page.getByRole('checkbox', { name: `选择 YS00${id}`, exact: true }).check()
  314. fixture.failReadyIds = ['plan-2']
  315. await page.locator('.cert-batch').getByRole('button', { name: '证书齐备', exact: true }).click()
  316. await button('确认齐备').waitFor()
  317. await waitList(() => button('确认齐备').click())
  318. await page.getByText('成功 1 条,失败 1 条,跳过 1 条', { exact: true }).waitFor()
  319. assert.equal(fixture.plans[0].hazardCertReady, 1)
  320. assert.equal(fixture.plans[1].hazardCertReady, 0)
  321. assert.equal(fixture.calls.filter((c) => c.path.endsWith('hazard-cert-ready')).length, 2)
  322. fixture.failReadyIds = []
  323. await waitList(() => page.getByRole('tab', { name: '全部', exact: true }).click())
  324. await card(1).getByRole('button', { name: '取消齐备', exact: true }).click()
  325. await waitList(() => button('确认取消').click())
  326. assert.equal(fixture.plans[0].hazardCertReady, 0)
  327. // 上传只提交证书白名单;阻止上传中保存/关闭,失败保留数据可重试。
  328. fixture.plans[0].products[0].purchaseCertUrl = '/fixtures/existing.pdf'
  329. await card(1).getByRole('button', { name: '上传证书', exact: true }).click()
  330. const upload = sheet('上传证书')
  331. await upload.locator('input[type=file]').first().waitFor({ state: 'attached' })
  332. assert.equal(await upload.locator('input[type=file]').count(), 3)
  333. await upload.locator('.attachment-item').first().waitFor()
  334. const firstAttachmentBox = await upload.locator('.attachment-item').first().boundingBox()
  335. const uploadCardBox = await upload.locator('.upload-card').first().boundingBox()
  336. assert.equal(Math.round(firstAttachmentBox.width), 72, '附件项宽度必须为 72px')
  337. assert.equal(Math.round(firstAttachmentBox.height), 72, '附件项高度必须为 72px')
  338. assert.equal(Math.round(uploadCardBox.width), 72, '上传入口宽度必须为 72px')
  339. assert.equal(Math.round(uploadCardBox.height), 72, '上传入口高度必须为 72px')
  340. const attachmentLayout = await upload
  341. .locator('.attachment-list')
  342. .first()
  343. .evaluate((list) => ({
  344. list: list.getBoundingClientRect().toJSON(),
  345. display: getComputedStyle(list).display,
  346. flexDirection: getComputedStyle(list).flexDirection,
  347. gap: getComputedStyle(list).gap,
  348. children: [...list.children].map((child) => child.getBoundingClientRect().toJSON())
  349. }))
  350. assert.equal(
  351. Math.round(attachmentLayout.children[0].y),
  352. Math.round(attachmentLayout.children[1].y),
  353. `上传入口必须作为宫格首项与已上传附件同行:${JSON.stringify(attachmentLayout)}`
  354. )
  355. assert.equal(await upload.locator('.attachment-preview__ext').first().textContent(), 'PDF')
  356. assert.equal(await upload.locator('.attachment-preview__ext').first().isVisible(), true)
  357. assert.equal(await upload.locator('.attachment-name').first().isVisible(), true)
  358. assert.equal(await upload.locator('.attachment-name').first().textContent(), 'existing.pdf')
  359. let releaseUpload
  360. fixture.uploadGate = new Promise((resolve) => {
  361. releaseUpload = resolve
  362. })
  363. await upload
  364. .locator('input[type=file]')
  365. .first()
  366. .setInputFiles({
  367. name: '测试证书.pdf',
  368. mimeType: 'application/pdf',
  369. buffer: Buffer.from('%PDF-1.4\n%%EOF')
  370. })
  371. await upload.locator('.is-uploading').waitFor()
  372. const certWrites = () => fixture.calls.filter((c) => c.path.endsWith('product-certs'))
  373. await upload.getByRole('button', { name: '保存证书', exact: true }).click()
  374. await page.getByText('附件正在上传,请稍候再保存', { exact: true }).waitFor()
  375. assert.equal(certWrites().length, 0)
  376. await upload.locator('.van-nav-bar__left').click()
  377. assert.equal(await upload.isVisible(), true)
  378. releaseUpload()
  379. fixture.uploadGate = null
  380. await upload.locator('.is-uploading').waitFor({ state: 'detached' })
  381. await noOverflow(upload.locator('.mp-customer-filter-vant__body'), '上传sheet')
  382. const saveBox = await upload.getByRole('button', { name: '保存证书', exact: true }).boundingBox()
  383. assert.ok(saveBox.height >= 28 && saveBox.height <= 32, '上传确认按钮上下内间距减半后高约 30px')
  384. assert.ok(saveBox.y + saveBox.height >= 760, '上传确认按钮必须贴在视口底部')
  385. const editAttachment = upload.locator('.attachment-item').first()
  386. const editDownload = editAttachment.locator('[title="下载"]')
  387. const editDelete = editAttachment.locator('[title="删除"]')
  388. const [editDownloadBox, editDeleteBox] = await Promise.all([
  389. editDownload.boundingBox(),
  390. editDelete.boundingBox()
  391. ])
  392. assert.ok(editDownloadBox.width >= 44 && editDownloadBox.height >= 44)
  393. assert.ok(editDeleteBox.width >= 44 && editDeleteBox.height >= 44)
  394. assert.ok(
  395. editDownloadBox.x + editDownloadBox.width <= editDeleteBox.x ||
  396. editDeleteBox.x + editDeleteBox.width <= editDownloadBox.x ||
  397. editDownloadBox.y + editDownloadBox.height <= editDeleteBox.y ||
  398. editDeleteBox.y + editDeleteBox.height <= editDownloadBox.y,
  399. `编辑态附件的下载与删除命中区不得重叠:${JSON.stringify({ editDownloadBox, editDeleteBox })}`
  400. )
  401. await cleanScreenshot('upload-375')
  402. // 预览、下载、删除卡片(删除后取消不写业务记录)。
  403. await upload
  404. .locator('.attachment-preview')
  405. .first()
  406. .click({ position: { x: 10, y: 10 } })
  407. const preview = page.getByRole('dialog', { name: 'existing.pdf', exact: true })
  408. await preview.waitFor()
  409. assert.ok((await preview.boundingBox()).width <= 375)
  410. await preview.screenshot({
  411. path: `${artifacts}/attachment-preview-375.png`,
  412. animations: 'disabled'
  413. })
  414. await preview.locator('.el-dialog__headerbtn').click()
  415. const download = page.waitForEvent('download')
  416. await upload.locator('[title="下载"]').first().click()
  417. assert.equal((await download).suggestedFilename(), 'existing.pdf')
  418. fixture.failSave = true
  419. await waitPatch('product-certs', () =>
  420. upload.getByRole('button', { name: '保存证书', exact: true }).click()
  421. )
  422. await upload.getByText('保存失败,已保留填写内容,请重试', { exact: true }).waitFor()
  423. assert.equal(await upload.locator('.attachment-item').count(), 2)
  424. await waitPatch('product-certs', () =>
  425. upload.getByRole('button', { name: '保存证书', exact: true }).click()
  426. )
  427. for (const p of certWrites().at(-1).body.products)
  428. assert.deepEqual(
  429. Object.keys(p).sort(),
  430. ['id', 'legalUseCertUrl', 'purchaseCertUrl', 'transportCertUrl'].sort()
  431. )
  432. assert.match(fixture.plans[0].products[0].purchaseCertUrl, /existing\.pdf/)
  433. assert.match(fixture.plans[0].products[0].purchaseCertUrl, /cert\.pdf/)
  434. await page.waitForURL(/#\/m\/pl\/plan-cert\/plan-1(?:\?|$)/)
  435. const detail = page.locator('.cert-detail-page')
  436. await detail.getByText('证书迁移测试备注', { exact: true }).waitFor()
  437. assert.equal(await sheet('计划详情').count(), 0, '详情不得继续使用 Bottom Sheet')
  438. assert.equal(
  439. await detail.locator('[class*="ticket-"]').count(),
  440. 0,
  441. '详情不得显示票根或顶部特殊区'
  442. )
  443. assert.equal(await detail.locator('.mp-wv-header__sub').count(), 0, '详情顶栏不得显示记录摘要')
  444. assert.equal(
  445. await detail.getByRole('heading', { name: '危化品证详情', exact: true }).count(),
  446. 1,
  447. '详情必须使用固定标准标题'
  448. )
  449. assert.deepEqual(await detail.getByRole('tab').allTextContents(), [
  450. '基础信息',
  451. '运输与段次',
  452. '证书与附件'
  453. ])
  454. await noOverflow(detail, '计划详情')
  455. await cleanScreenshot('detail-375')
  456. await page.setViewportSize({ width: 414, height: 812 })
  457. await noOverflow(detail, '计划详情414')
  458. await cleanScreenshot('detail-414')
  459. // 详情页独立编排上传、齐备与取消齐备,成功后原位刷新而不返回列表。
  460. const detailUrl = page.url()
  461. await detail.getByRole('button', { name: '上传证书', exact: true }).click()
  462. await upload.locator('input[type=file]').first().waitFor({ state: 'attached' })
  463. await waitPatch('product-certs', () =>
  464. upload.getByRole('button', { name: '保存证书', exact: true }).click()
  465. )
  466. assert.equal(page.url(), detailUrl, '详情上传成功后必须停留在原详情路由')
  467. await detail.getByRole('button', { name: '证书齐备', exact: true }).click()
  468. await button('确认齐备').waitFor()
  469. await waitPatch('hazard-cert-ready', () => button('确认齐备').click())
  470. assert.equal(page.url(), detailUrl, '详情标记齐备后必须停留在原详情路由')
  471. await detail.getByRole('button', { name: '取消齐备', exact: true }).click()
  472. await button('确认取消').waitFor()
  473. await waitPatch('hazard-cert-ready', () => button('确认取消').click())
  474. assert.equal(page.url(), detailUrl, '详情取消齐备后必须停留在原详情路由')
  475. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  476. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  477. await card(1).getByRole('button', { name: '上传证书', exact: true }).click()
  478. await upload.locator('.attachment-item').first().waitFor()
  479. await upload.locator('[title="删除"]').first().click()
  480. assert.equal(await upload.locator('.attachment-item').count(), 1)
  481. await cleanScreenshot('upload-414')
  482. const writesBeforeCancel = certWrites().length
  483. await upload.locator('.van-nav-bar__left').click()
  484. assert.equal(certWrites().length, writesBeforeCancel)
  485. assert.match(fixture.plans[0].products[0].purchaseCertUrl, /existing\.pdf/)
  486. // 合同仅查看;列表合同入口通过独立详情路由打开,关联计划仍留在详情页。
  487. await card(1).locator('summary').click()
  488. await card(1).getByRole('button', { name: 'HT-001', exact: true }).first().click()
  489. const contract = sheet('合同详情(只读)')
  490. await contract.getByText('测试账号001', { exact: true }).waitFor()
  491. await contract.getByText('测试关联商机', { exact: true }).waitFor()
  492. await contract.getByText('BJ-001', { exact: true }).waitFor()
  493. await contract.getByText(/别名 测试别名/).waitFor()
  494. assert.equal(await contract.getByText('金额统计', { exact: true }).count(), 0)
  495. assert.equal(await contract.locator('.attachment-item').count(), 2)
  496. await noOverflow(contract.locator('.mp-sheet-body'), '合同详情')
  497. await contract.getByRole('button', { name: 'YS002', exact: true }).click()
  498. await page.waitForURL(/#\/m\/pl\/plan-cert\/plan-2(?:\?|$)/)
  499. await detail.getByText('YS002', { exact: true }).waitFor()
  500. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  501. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  502. // 价格权限仅开启合同金额展示,不引入编辑或审批动作。
  503. fixture.codes.push('pl:contract:view-product-price')
  504. await page.reload()
  505. await card(1).waitFor()
  506. await card(1).locator('summary').click()
  507. await card(1).getByRole('button', { name: 'HT-001', exact: true }).first().click()
  508. await contract.getByRole('heading', { name: '金额统计', exact: true }).waitFor()
  509. assert.equal(await contract.getByRole('button', { name: /编辑|审批|作废/ }).count(), 0)
  510. await contract.getByRole('button', { name: '关闭', exact: true }).click()
  511. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  512. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  513. // 已安排计划的回传和复制动作;未安排计划不显示复制运输摘要。
  514. await card(7).getByRole('button', { name: '查看详情', exact: true }).click()
  515. await detail.getByRole('tab', { name: '运输与段次', exact: true }).click()
  516. await detail.getByText('司机回传测试备注', { exact: true }).first().waitFor()
  517. const feedbackCard = detail.locator('.pl-stop-feedback-ticket__attach .attachment-item').first()
  518. const feedbackPreview = feedbackCard.locator('.attachment-preview')
  519. const feedbackDownload = feedbackCard.locator('.attachment-compact-download')
  520. await feedbackPreview.scrollIntoViewIfNeeded()
  521. const previewBox = await feedbackPreview.boundingBox(),
  522. downloadBox = await feedbackDownload.boundingBox()
  523. assert.ok(previewBox.width >= 44 && previewBox.height >= 44)
  524. assert.ok(downloadBox.width >= 44 && downloadBox.height >= 44)
  525. assert.ok(downloadBox.y >= previewBox.y + previewBox.height, '反馈下载与预览触达区不得重叠')
  526. await feedbackPreview.click()
  527. const feedbackDialog = page.getByRole('dialog', { name: 'driver.png', exact: true })
  528. await feedbackDialog.locator('img').waitFor()
  529. await feedbackDialog.screenshot({
  530. path: `${artifacts}/feedback-preview-414.png`,
  531. animations: 'disabled'
  532. })
  533. await feedbackDialog.locator('.el-dialog__headerbtn').click()
  534. const feedbackFile = page.waitForEvent('download')
  535. await feedbackDownload.click()
  536. assert.equal((await feedbackFile).suggestedFilename(), 'driver.png')
  537. await detail.getByRole('button', { name: '复制司机链接', exact: true }).click()
  538. assert.equal(await page.evaluate(() => window.__copiedText), 'https://example.test/driver')
  539. await detail.getByRole('button', { name: '复制给司机', exact: true }).click()
  540. assert.match(await page.evaluate(() => window.__copiedText), /测试易制毒产品/)
  541. await detail.getByRole('button', { name: '复制给现场', exact: true }).click()
  542. assert.match(await page.evaluate(() => window.__copiedText), /https:\/\/example.test\/site/)
  543. await detail.getByRole('button', { name: '复制磅单二维码', exact: true }).click()
  544. await page.locator('.pound-qr-panel img').waitFor()
  545. await noOverflow(page.locator('.pound-qr-panel'), '磅单二维码')
  546. await page.locator('.pound-qr-panel').getByRole('button', { name: '关闭' }).click()
  547. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  548. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  549. // 切换合同时,旧合同未结束的关联查询不能阻止新合同查询。
  550. const secondPlan = fixture.plans[1]
  551. for (const stop of [secondPlan.load, ...secondPlan.deliveries]) {
  552. for (const c of stop.contracts) {
  553. c.contractId = 'contract-2'
  554. c.contractNo = 'HT-002'
  555. }
  556. for (const p of stop.products) {
  557. p.contractId = 'contract-2'
  558. p.contractNo = 'HT-002'
  559. }
  560. }
  561. await page.reload()
  562. await card(1).waitFor()
  563. fixture.linkedListGate = new Promise((resolve) => {
  564. releaseLinkedList = resolve
  565. })
  566. await card(1).locator('summary').click()
  567. await Promise.all([
  568. page.waitForRequest((r) => new URL(r.url()).searchParams.get('contractId') === 'contract-1'),
  569. card(1).getByRole('button', { name: 'HT-001', exact: true }).first().click()
  570. ])
  571. await contract.getByRole('button', { name: '关闭', exact: true }).click()
  572. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  573. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  574. await card(2).locator('summary').click()
  575. await card(2).getByRole('button', { name: 'HT-002', exact: true }).first().click()
  576. await contract.getByRole('heading', { name: 'HT-002', exact: true }).waitFor()
  577. assert.ok(
  578. fixture.calls.some(
  579. (c) => c.path === '/transport/plan/' && c.params.contractId === 'contract-2'
  580. ),
  581. '新合同必须发起自己的关联列表请求'
  582. )
  583. await contract.getByRole('button', { name: 'YS002', exact: true }).waitFor()
  584. releaseLinkedList()
  585. fixture.linkedListGate = null
  586. await contract.getByRole('button', { name: '关闭', exact: true }).click()
  587. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  588. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  589. fixture.failList = true
  590. await card(2).locator('summary').click()
  591. await card(2).getByRole('button', { name: 'HT-002', exact: true }).first().click()
  592. await contract.getByRole('button', { name: '重试关联计划', exact: true }).waitFor()
  593. await waitList(() => contract.getByRole('button', { name: '重试关联计划', exact: true }).click())
  594. assert.equal(latestList().params.contractId, 'contract-2')
  595. assert.equal(latestList().params.page, '1')
  596. await contract.getByRole('button', { name: 'YS002', exact: true }).waitFor()
  597. await contract.getByRole('button', { name: '关闭', exact: true }).click()
  598. await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click()
  599. await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/)
  600. // 独立详情在只读/权限失败时仍先展示 Tab,且不暴露写操作。
  601. fixture.codes = ['mini:pl:plan-cert:list']
  602. await page.goto(`${origin}/#/m/pl/plan-cert/plan-1`)
  603. const readonlyDetail = page.locator('.cert-detail-page')
  604. await readonlyDetail.getByText('当前仅有查看权限', { exact: true }).waitFor()
  605. assert.equal(await readonlyDetail.locator('.cert-detail-actions').count(), 0)
  606. assert.equal(
  607. await readonlyDetail.locator('.detail-tabs').evaluate(
  608. (tabs, hint) => {
  609. return Boolean(tabs.compareDocumentPosition(hint) & Node.DOCUMENT_POSITION_FOLLOWING)
  610. },
  611. await readonlyDetail.getByText('当前仅有查看权限', { exact: true }).elementHandle()
  612. ),
  613. true,
  614. '权限提示必须下沉到详情 Tab 之后'
  615. )
  616. fixture.failPermissions = true
  617. await page.reload()
  618. await readonlyDetail.getByRole('button', { name: '重试权限', exact: true }).waitFor()
  619. assert.equal(await readonlyDetail.locator('.cert-detail-actions').count(), 0)
  620. fixture.failPermissions = false
  621. await readonlyDetail.getByRole('button', { name: '重试权限', exact: true }).click()
  622. await readonlyDetail.getByText('当前仅有查看权限', { exact: true }).waitFor()
  623. // 列表同样在只读/权限失败时关闭上传与齐备入口。
  624. await page.goto(`${origin}/#/m/pl/plan-cert`)
  625. await page.getByText('当前仅有查看权限', { exact: true }).waitFor()
  626. assert.equal(await button('上传证书').count(), 0)
  627. assert.equal(await button('证书齐备').count(), 0)
  628. fixture.failPermissions = true
  629. await page.reload()
  630. await button('重试权限').waitFor()
  631. assert.equal(await button('上传证书').count(), 0)
  632. fixture.failPermissions = false
  633. await button('重试权限').click()
  634. await page.getByText('当前仅有查看权限', { exact: true }).waitFor()
  635. assert.deepEqual(fixture.unknown, [])
  636. assert.deepEqual(pageErrors, [])
  637. console.log(JSON.stringify({ result: 'passed', calls: fixture.calls.length, artifacts }))
  638. } catch (error) {
  639. console.error(error)
  640. await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true })
  641. console.error(
  642. JSON.stringify({ pageErrors, unknown: fixture.unknown, recent: fixture.calls.slice(-5) })
  643. )
  644. throw error
  645. } finally {
  646. releaseLinkedList?.()
  647. await context.close()
  648. await browser.close()
  649. }