import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { chromium } from 'playwright' const menuSource = await readFile( new URL('../../miniprogram/config/workbench-menus.js', import.meta.url), 'utf8' ) const menuModule = await import( `data:text/javascript;base64,${Buffer.from(menuSource).toString('base64')}` ) const groups = Object.values(menuModule).find(Array.isArray) const entry = groups .find((group) => group.key === 'pl') .menus.find((menu) => menu.code === 'pl-dispatch-calendar-webview') assert.equal(entry.name, '派车日历') assert.equal(entry.perms, 'mini:pl:dispatch-calendar:list') assert.equal(entry.enabled, true) const config = await readFile(new URL('../../miniprogram/config/api.js', import.meta.url), 'utf8') assert.match( config, /'pl-dispatch-calendar-webview':\s*PAGE_PATHS.mpWebview\s*\+\s*'\?path='\s*\+\s*encodeURIComponent\('\/m\/pl\/dispatch-calendar'\)/ ) const browser = await chromium.launch({ headless: true, channel: 'chrome' }) const context = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true, permissions: ['clipboard-read', 'clipboard-write'] }) const page = await context.newPage() page.setDefaultTimeout(10000) await page.clock.setFixedTime(new Date('2026-10-25T04:00:00Z')) const errors = [] page.on('pageerror', (error) => errors.push(error.message)) const plan = (id, date, vehicle = 'v1') => ({ id, planNo: id, planDate: date, followStatus: 'pending', vehicleId: vehicle, licensePlate: vehicle === 'v1' ? '皖H21522' : '皖HA4855', load: { customerName: '安庆山峡' }, deliveries: [ { id: `${id}-a`, customerName: '安庆奇创', products: [{ productId: 'acid', productName: '98酸' }] }, { id: `${id}-b`, customerName: '安庆奇创', products: [{ productId: 'acid', productName: '98酸' }] }, { id: `${id}-c`, customerName: '安徽云栋', products: [{ productId: 'base', productName: '液碱' }] } ] }) const rows = [plan('p1', '2026-10-25'), plan('p2', '2026-10-26', 'v2')] let failNext = false let delayOctober = false let exchanges = 0 const queries = [] await context.addInitScript(() => { window.wx = { miniProgram: { reLaunch: ({ url }) => { window.__backTarget = url } } } window.__wxjs_environment = 'miniprogram' }) await context.route('**/api/**', async (route) => { const url = new URL(route.request().url()) if (!url.pathname.startsWith('/api/')) return route.continue() const path = url.pathname.slice(4) if (path === '/web/auth/mp-webview-exchange') { exchanges++ return route.fulfill({ json: { code: 200, data: { token: 'mobile-calendar-fixture', refreshToken: 'fixture-refresh', expiresIn: 3600, userInfo: { id: 'fixture', userId: 'fixture', userName: '日历测试', roles: ['admin'] } } } }) } if (path === '/transport/plan/') { assert.equal(exchanges, 1, 'business request after ticket exchange') queries.push(Object.fromEntries(url.searchParams)) if (failNext) { failNext = false return route.fulfill({ status: 500, json: { detail: 'expected fixture error' } }) } const october = url.searchParams.get('planDateFrom') === '2026-10-01' if (delayOctober && october) await new Promise((resolve) => setTimeout(resolve, 800)) return route.fulfill({ json: { list: october ? rows.map((item) => ({ calendarVehicleType: 'self', ...item })) : [], total: october ? rows.length : 0 } }) } return route.fulfill({ json: { code: 200, data: {} } }) }) async function chooseVehicle(label, cancel = false) { await page.getByLabel('车辆筛选', { exact: true }).click() const popup = page.locator('.mp-calendar-vehicle-picker:visible') await popup.waitFor() await popup.locator('.van-picker-column__item').getByText(label, { exact: true }).click() await popup.getByRole('button', { name: cancel ? '取消' : '确认', exact: true }).click() await popup.waitFor({ state: 'hidden' }) } async function selectMonth(value) { await page.getByLabel('查看月份', { exact: true }).click() const popup = page.locator('.mp-calendar-date-picker:visible') await popup.locator('.van-picker-column').nth(1).getByText(value, { exact: true }).click() await popup.getByRole('button', { name: '确认', exact: true }).click() await popup.waitFor({ state: 'hidden' }) } async function expand(day) { await day.waitFor() await day.locator('.van-collapse-item__title > .van-cell__right-icon').click() await day.locator('.mp-calendar-plate').first().waitFor() } try { await page.goto( `${process.env.CALENDAR_TEST_ORIGIN || 'http://localhost:3006'}/#/m/pl/dispatch-calendar?mpTicket=calendar-test` ) await page.getByRole('tab', { name: '实际派车信息', exact: true }).click() const day = page.locator('[data-date="2026-10-25"]') await day.waitFor() assert.equal(await page.getByLabel('定位日期', { exact: true }).count(), 0, '移除日期筛选') assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 0, '日期默认折叠') assert.equal( await page .locator( '.mp-calendar-controls select, .mp-calendar-controls input[type="date"], .mp-calendar-controls input[type="month"]' ) .count(), 0, '使用 Vant 选择器' ) await day.locator('.van-collapse-item__title > .van-cell__right-icon').click() await day.locator('.mp-calendar-plate').first().waitFor() assert.equal(await day.locator('.mp-calendar-task').count(), 2) assert.match(await day.textContent(), /1车次\s*\/\s*2段次/) assert.match(await day.textContent(), /山峡 → 云栋/) assert.match(await day.locator('.mp-calendar-day-count').textContent(), /1\s*车次/) assert.equal(await day.locator('.mp-calendar-day-count').evaluate((el) => el.tagName), 'BUTTON') assert.ok(!page.url().includes('mpTicket')) assert.equal(queries[0].includeCustomerShortNames, '1') assert.equal(queries[0].planDateTo, '2026-10-31') assert.equal(await page.locator('.calendar-grid').count(), 0) await day.locator('.mp-calendar-day-count').click() await page.locator('.mp-calendar-summary:visible').waitFor() assert.match(await page.locator('.mp-calendar-summary-total').textContent(), /合计\s*1\s*车次/) assert.equal(await page.locator('.mp-calendar-summary-row').count(), 2) await page.screenshot({ path: '/tmp/mobile-calendar-implemented-summary.png' }) await page.getByRole('button', { name: '完成', exact: true }).click() await day.locator('.van-collapse-item__title > .van-cell__right-icon').click() await day.locator('.mp-calendar-vehicle').first().waitFor({ state: 'hidden' }) assert.equal(await day.locator('.mp-calendar-total').count(), 0, '删除展开区域的合计按钮') await day.locator('.mp-calendar-day-count').click() await page.locator('.mp-calendar-summary:visible').waitFor() await page.getByRole('button', { name: '完成', exact: true }).click() await page.locator('.mp-calendar-summary').waitFor({ state: 'hidden' }) assert.equal( await day.locator('.mp-calendar-vehicle:visible').count(), 0, '折叠时点击车次不展开日期' ) await day.locator('.mp-calendar-day-count').press('Enter') await page.locator('.mp-calendar-summary:visible').waitFor() await page.getByRole('button', { name: '完成', exact: true }).click() await page.locator('.mp-calendar-summary').waitFor({ state: 'hidden' }) assert.equal( await day.locator('.mp-calendar-vehicle:visible').count(), 0, '键盘查看汇总不改变折叠状态' ) await page.screenshot({ path: '/tmp/mobile-calendar-collapsed.png' }) await page.getByLabel('车辆筛选', { exact: true }).click() const vehiclePopup = page.locator('.mp-calendar-vehicle-picker:visible') const search = vehiclePopup.getByPlaceholder('输入车牌关键词') await search.fill('h215') await vehiclePopup.getByText('皖H21522', { exact: true }).waitFor() assert.equal(await vehiclePopup.getByText('皖HA4855', { exact: true }).count(), 0) await search.fill('不存在') await vehiclePopup.getByText('没有匹配的车辆').waitFor() await vehiclePopup.getByRole('button', { name: '确认', exact: true }).click() assert.ok(await vehiclePopup.isVisible(), '无匹配结果不能误提交全部车辆') await vehiclePopup.getByRole('button', { name: '取消', exact: true }).click() await vehiclePopup.waitFor({ state: 'hidden' }) await chooseVehicle('皖H21522', true) assert.equal( await page.getByLabel('车辆筛选', { exact: true }).locator('input').inputValue(), '全部车辆' ) await page.getByLabel('车辆筛选', { exact: true }).click() await vehiclePopup.getByPlaceholder('输入车牌关键词').fill(' h215 ') await vehiclePopup.getByRole('button', { name: '确认', exact: true }).click() await vehiclePopup.waitFor({ state: 'hidden' }) assert.equal( await page.getByLabel('车辆筛选', { exact: true }).locator('input').inputValue(), '皖H21522' ) assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 0, '筛选后默认折叠') await expand(day) assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 1) await chooseVehicle('全部车辆') await page.getByLabel('查看月份', { exact: true }).click() const monthPicker = page.locator('.mp-calendar-date-picker:visible') assert.equal(await monthPicker.locator('.van-picker-column').count(), 2) await monthPicker.locator('.van-picker-column').nth(1).getByText('11', { exact: true }).click() await monthPicker.getByRole('button', { name: '确认', exact: true }).click() await page.getByText('本月暂无派车计划', { exact: true }).waitFor() failNext = true await selectMonth('10') await page.getByRole('button', { name: '重试加载' }).waitFor() await page.getByRole('button', { name: '重试加载' }).click() await expand(day) await page.getByRole('button', { name: '今天', exact: true }).click() assert.ok(await day.evaluate((el) => el.classList.contains('is-selected'))) // 密集内容只增加文档高度,不引入日内滚动或多列。 for (let i = 0; i < 12; i++) rows.push(plan(`dense-${i}`, '2026-10-25', `v${i + 3}`)) await selectMonth('11') await page.getByText('本月暂无派车计划', { exact: true }).waitFor() await selectMonth('10') assert.equal(await page.locator('.mp-calendar-vehicle:visible').count(), 0) await expand(day) for (const width of [375, 320, 414]) { await page.setViewportSize({ width, height: 812 }) assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)) assert.ok( await day.evaluate((el) => !['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) ) const cards = await day.locator('.mp-calendar-vehicle').evaluateAll((els) => els.map((el) => { const r = el.getBoundingClientRect() return { x: r.x, y: r.y, bottom: r.bottom } }) ) assert.equal(cards.length, 13) assert.ok( cards.every( (card, index) => !index || (card.x === cards[0].x && card.y >= cards[index - 1].bottom) ) ) } await page.setViewportSize({ width: 375, height: 812 }) await page.screenshot({ path: '/tmp/mobile-calendar-implemented.png' }) // 快速来回切月,旧月份响应不得覆盖新月份。 await selectMonth('11') await page.getByText('本月暂无派车计划', { exact: true }).waitFor() delayOctober = true await selectMonth('10') await selectMonth('11') await page.getByText('本月暂无派车计划', { exact: true }).waitFor() await page.waitForTimeout(1000) assert.equal(await page.locator('.mp-calendar-vehicle').count(), 0) delayOctober = false rows.splice( 0, rows.length, { ...plan('own', '2026-10-25'), calendarVehicleType: 'self' }, { ...plan('carrier', '2026-10-25', 'v2'), calendarVehicleType: 'carrier' }, { ...plan('unknown', '2026-10-25', 'v3'), calendarVehicleType: 'unknown' }, { ...plan('other-day', '2026-10-26', 'v2'), calendarVehicleType: 'carrier' } ) await selectMonth('10') await expand(day) assert.equal(await day.locator('.mp-calendar-vehicle').count(), 1) const nextDay = page.locator('[data-date="2026-10-26"]') await nextDay.locator('.van-collapse-item__title > .van-cell__right-icon').click() await nextDay.getByText('当天暂无山峡自有车辆').waitFor() await day.getByRole('button', { name: '全部', exact: true }).click() await day.getByRole('button', { name: '复制', exact: true }).click() let copied = await page.evaluate(() => navigator.clipboard.readText()) assert.match(copied, /^运输计划 10\.25/) assert.equal(copied.split('\n').length, 4, '全部范围复制三个车辆组') assert.equal(await day.locator('.mp-calendar-vehicle').count(), 3) assert.match(await day.locator('.mp-calendar-day-count').textContent(), /3\s*车次/) assert.equal(await nextDay.locator('.mp-calendar-vehicle').count(), 0) assert.ok(await nextDay.getByRole('button', { name: '复制', exact: true }).isDisabled()) await day.locator('.mp-calendar-day-count').click() assert.match(await page.locator('.mp-calendar-summary-total').textContent(), /合计\s*3\s*车次/) await page.getByRole('button', { name: '完成', exact: true }).click() await page.locator('.mp-calendar-summary').waitFor({ state: 'hidden' }) await nextDay.getByRole('button', { name: '全部', exact: true }).click() await day.getByRole('button', { name: '山峡车辆', exact: true }).click() assert.equal(await day.locator('.mp-calendar-vehicle').count(), 1) assert.equal(await nextDay.locator('.mp-calendar-vehicle').count(), 1) await day.getByRole('button', { name: '复制', exact: true }).click() copied = await page.evaluate(() => navigator.clipboard.readText()) assert.equal(copied.split('\n').length, 2, '山峡车辆范围仅复制自有车辆') assert.match(copied, /皖H21522/) assert.ok(!copied.includes('皖HA4855')) await nextDay.getByRole('button', { name: '复制', exact: true }).click() assert.match(await page.evaluate(() => navigator.clipboard.readText()), /^明日计划 10\.26/) await page.evaluate(() => { window.__copyWrite = navigator.clipboard.writeText.bind(navigator.clipboard) window.__copyExec = document.execCommand navigator.clipboard.writeText = async () => { throw new Error('fixture clipboard denied') } document.execCommand = () => false }) await day.getByRole('button', { name: '复制', exact: true }).click() await page.getByText('复制失败,请重试', { exact: true }).waitFor() await page.evaluate(() => { navigator.clipboard.writeText = window.__copyWrite document.execCommand = window.__copyExec }) for (const width of [320, 375, 414]) { await page.setViewportSize({ width, height: 812 }) assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)) const controls = await page.locator('.mp-calendar-month-row > *').evaluateAll((els) => els.map((el) => { const r = el.getBoundingClientRect() return { top: r.top, bottom: r.bottom, width: r.width } }) ) assert.equal(controls.length, 3) assert.ok( controls.every( (r) => Math.abs(r.top - controls[0].top) < 1 && r.bottom - r.top >= 44 && r.width >= 44 ), '月份、今天、车辆同一行且触达尺寸足够' ) assert.ok( await day.locator('.mp-calendar-day-count').evaluate((button) => { const rect = button.getBoundingClientRect() const arrow = button .closest('.van-cell') .querySelector('.van-cell__right-icon') .getBoundingClientRect() return rect.width >= 44 && rect.height >= 44 && arrow.left - rect.right >= 8 }), '车次按钮触达至少44px,且与展开箭头留出间隔' ) const centered = await page.locator('.mp-calendar-today').evaluate((button) => { const outer = button.getBoundingClientRect(), text = button.querySelector('.van-button__text').getBoundingClientRect() return Math.abs((outer.left + outer.right - text.left - text.right) / 2) < 1 }) assert.ok(centered, '今天文字水平居中') } await page.setViewportSize({ width: 375, height: 812 }) await page.getByRole('button', { name: '今天', exact: true }).click() await page.screenshot({ path: '/tmp/calendar-daily-ownership-mini.png' }) await page.getByRole('button', { name: '返回工作台', exact: true }).click() assert.equal(await page.evaluate(() => window.__backTarget), '/pages/workbench/workbench') assert.deepEqual(errors, []) console.log( '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' ) } catch (error) { await page.screenshot({ path: '/tmp/mobile-calendar-vant-failure.png' }) console.error('Test failure:', error) console.error('Page errors:', errors) console.error('Picker DOM:', await page.locator('.mp-calendar-picker').allTextContents()) throw error } finally { await context.close() await browser.close() }