import assert from 'node:assert/strict' import { chromium } from 'playwright' const browser = await chromium.launch({ headless: true, channel: 'chrome' }) const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, permissions: ['clipboard-read', 'clipboard-write'] }) const page = await context.newPage() await page.clock.setFixedTime(new Date('2026-10-25T04:00:00Z')) page.setDefaultTimeout(60000) const errors = [] page.on('pageerror', (error) => errors.push(error.message)) page.on('console', (msg) => { if (msg.type() === 'error') console.error('CONSOLE', msg.text()) }) page.on('requestfailed', (request) => console.error('FAILED', request.url(), request.failure())) page.on('response', (response) => { if (response.status() >= 400) console.error('HTTP', response.status(), response.url()) }) const rows = Array.from({ length: 7 }, (_, i) => ({ id: `plan-${i}`, planNo: `PL${i}`, planDate: '2026-10-24', followStatus: 'pending', vehicleId: `v${Math.floor(i / 2)}`, licensePlate: ['皖HA3690', '皖H10291', '皖H57837', '皖LB1283'][Math.floor(i / 2)], driverId: i < 2 ? 'driver-1' : undefined, driverName: i < 2 ? '测试司机' : undefined, products: [{ productId: i % 2 ? 'p2' : 'p1', productName: i % 2 ? '98酸' : 'cp酸' }], calendarRoute: '山峡库 → 永恒', load: { customerName: '山峡化工有限公司', calendarCustomerName: '山峡库' }, deliveries: [{ customerName: '永恒有限公司', calendarCustomerName: '永恒' }] })) let failNext = false await context.addInitScript(() => { localStorage.setItem( 'sys-v3.0.2-user', JSON.stringify({ isLogin: true, accessToken: 'calendar-fixture', info: { id: 'fixture', userId: 'fixture', userName: '测试用户', role: 'admin', roles: ['admin'] } }) ) localStorage.setItem('sys-version', '3.0.2') }) 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.replace(/^\/api/, '') if (path === '/transport/plan/') { if (failNext) { failNext = false return route.fulfill({ status: 500, json: { detail: 'fixture failure' } }) } const list = url.searchParams.get('planDateFrom')?.slice(0, 7) === '2026-10' ? rows : [] return route.fulfill({ json: { list: list.map((item) => ({ calendarVehicleType: 'self', ...item })), total: list.length } }) } let data = {} if (path === '/web/auth/current-user') data = { id: 'fixture', userId: 'fixture', userName: '测试用户', role: 'admin', roles: ['admin'] } else if (path === '/web/auth/menu-codes') data = { all: false, menuCodes: [ 'pl:dispatch-calendar:list', 'pl:plan:list', 'pl:inbound:list', 'pl:outbound:list' ] } else if (path.includes('favorites') || path.includes('upgrade-logs')) data = [] return route.fulfill({ json: { code: path.startsWith('/transport/') ? 0 : 200, msg: 'ok', data } }) }) try { await page.goto( `${process.env.CALENDAR_TEST_ORIGIN || 'http://localhost:3006'}/#/pl/calendar-module/dispatch-calendar` ) await page.getByText('派车日历', { exact: true }).first().click() await page.getByRole('tab', { name: '实际派车信息', exact: true }).click() const panel = page.locator('.dispatch-calendar-page') await panel.locator('.plate-number').first().waitFor({ timeout: 15000 }) assert.equal(await panel.locator('.trip-card').count(), 7) assert.equal(await panel.locator('.vehicle-card').count(), 4) assert.equal(await panel.locator('.calendar-controls label').count(), 0) assert.equal(await panel.locator('.calendar-controls .el-select').count(), 1) assert.equal(await panel.getByLabel('司机筛选').count(), 0) assert.equal(await panel.locator('.trip-route').first().textContent(), '山峡库 → 永恒') await page.waitForFunction(() => { const button = document.querySelector('.dispatch-calendar-page .el-button--primary') return button && getComputedStyle(button).backgroundColor === 'rgb(31, 78, 121)' }) for (const selector of ['.calendar-weekdays', '.plate-number', '.el-button--primary']) { assert.equal( await panel .locator(selector) .first() .evaluate((el) => getComputedStyle(el).backgroundColor), 'rgb(31, 78, 121)' ) } assert.equal(await panel.locator('.calendar-day').count(), 35) const daily = panel.locator('[data-date="2026-10-24"]') assert.match(await daily.locator('.day-total').textContent(), /合计\s*7\s*车次/) assert.equal(await daily.getByText('未填写司机', { exact: true }).count(), 0) assert.equal(await daily.locator('.day-summary:visible').count(), 0) const copyButton = daily.getByRole('button', { name: '复制2026-10-24派车计划', exact: true }) assert.ok(await panel.locator('[data-date="2026-10-01"] .day-copy-button').isDisabled()) await copyButton.click() let copiedText = await page.evaluate(() => navigator.clipboard.readText()) assert.match(copiedText, /^运输计划 10\.24\n• 皖HA3690 \/ cp酸 山峡库 - 永恒/) assert.equal(copiedText.split('\n').length, 5) await page.clock.setFixedTime(new Date('2026-10-23T04:00:00Z')) await copyButton.click() assert.match(await page.evaluate(() => navigator.clipboard.readText()), /^明日计划 10\.24/) await page.clock.setFixedTime(new Date('2026-10-22T04:00:00Z')) await copyButton.click() assert.match(await page.evaluate(() => navigator.clipboard.readText()), /^后日计划 10\.24/) await page.clock.setFixedTime(new Date('2026-10-25T04:00:00Z')) await daily.getByRole('button', { name: '查看2026-10-24货品汇总' }).click({ timeout: 2000 }) const summaryPopup = page.locator('.calendar-summary-popper:visible') await summaryPopup.waitFor() assert.match(await summaryPopup.textContent(), /2026-10-24.*货品汇总/) assert.match(await summaryPopup.locator('.day-total').textContent(), /合计\s*7\s*车次/) assert.equal(await summaryPopup.locator('.product-total').count(), 2) await page.waitForFunction(() => { const popup = Array.from(document.querySelectorAll('.calendar-summary-popper')).find( (el) => el.getBoundingClientRect().width > 0 ) return popup && getComputedStyle(popup).opacity === '1' }) await summaryPopup.screenshot({ path: '/tmp/shanxia-calendar-summary-popover.png' }) await panel.locator('.title-bar-heading').click() await summaryPopup.waitFor({ state: 'hidden' }) assert.equal( await daily.locator('.day-summary').evaluate((el) => getComputedStyle(el).backgroundColor), 'rgba(0, 0, 0, 0)' ) await panel.locator('.el-select').click() await page .locator('.el-select-dropdown__item:visible') .getByText('皖HA3690', { exact: true }) .click() assert.equal(await panel.locator('.trip-card').count(), 2) await panel.getByRole('button', { name: '重置筛选', exact: true }).click() assert.equal(await panel.locator('.trip-card').count(), 7) await panel.getByRole('button', { name: '下月', exact: true }).click() await panel.getByText('本月暂无派车计划', { exact: true }).waitFor() assert.equal(await panel.locator('.calendar-day').count(), 42) await panel.getByRole('button', { name: '上月', exact: true }).click() await daily.locator('.plate-number').first().waitFor() await daily.locator('.day-number').click() assert.ok((await daily.getAttribute('class')).includes('selected')) await page.setViewportSize({ width: 2560, height: 1300 }) assert.equal( await daily .locator('.vehicle-grid') .evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length), 2 ) await daily.screenshot({ path: '/tmp/shanxia-dispatch-calendar-day.png' }) await page.screenshot({ path: '/tmp/shanxia-dispatch-calendar-web.png' }) // 捕获浏览器打印调用,验证独立打印文档使用当前月历而非整个后台。 await page.evaluate(() => { const append = document.body.appendChild document.body.appendChild = function (node) { const result = append.call(this, node) if (node.tagName === 'IFRAME') { node.contentWindow.print = () => { window.calendarPrintCalled = true } document.body.appendChild = append } return result } }) await panel.getByRole('button', { name: '打印日历', exact: true }).click() await page.waitForFunction(() => window.calendarPrintCalled === true) const printFrame = page.frameLocator('iframe[aria-hidden="true"]') assert.equal(await printFrame.locator('.trip-card').count(), 7) assert.match( await printFrame.locator('.day-print-summary .day-total').textContent(), /合计\s*7\s*车次/ ) assert.equal(await printFrame.locator('.calendar-controls').count(), 0) assert.equal(await printFrame.locator('h1').textContent(), '2026年10月派车日历') await page.emulateMedia({ media: 'print' }) assert.equal( await printFrame .locator('.day-content') .first() .evaluate((el) => getComputedStyle(el).maxHeight), 'none' ) assert.equal( await printFrame .locator('.trip-route') .first() .evaluate((el) => getComputedStyle(el).overflow), 'visible' ) await page.emulateMedia({ media: 'screen' }) await page.evaluate(() => document .querySelector('iframe[aria-hidden="true"]') .contentWindow.dispatchEvent(new Event('afterprint')) ) assert.equal(await page.locator('iframe[aria-hidden="true"]').count(), 0) failNext = true await panel.getByRole('button', { name: '刷新', exact: true }).click() await panel.getByRole('alert').waitFor() assert.equal(await panel.locator('.trip-card').count(), 0) await panel.getByRole('button', { name: '重试', exact: true }).click() await daily.locator('.plate-number').first().waitFor() assert.equal(await panel.locator('.trip-card').count(), 7) // 繁忙日保持七天等宽、无横向滚动,车辆卡片按车次数倒序。 rows.splice( 0, rows.length, ...Array.from({ length: 11 }, (_, index) => ({ id: `dense-${index}`, planNo: `DENSE${index}`, planDate: '2026-10-24', followStatus: 'pending', vehicleId: `dense-v${index % 6}`, licensePlate: ['皖H57837', '皖M7A663', '皖H31656', '一票到', '皖AM8516', '皖HA6900'][ index % 6 ], calendarRoute: '安庆山峡 → 厦门圣益', products: [ { productId: `product-${index % 7}`, productName: [ '50% 工业硫酸', '30%液碱', '氨水', '30% 工业硫酸', '32%液碱', '精制硫酸', '98% 工业硫酸' ][index % 7] } ] })) ) await page.setViewportSize({ width: 1440, height: 1000 }) await panel.getByRole('button', { name: '刷新', exact: true }).click() await daily.getByText('皖M7A663', { exact: true }).first().waitFor() assert.ok( await panel.locator('.calendar-scroll').evaluate((el) => el.scrollWidth <= el.clientWidth) ) const widths = await panel .locator('.calendar-weekdays span') .evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().width)) assert.equal(widths.length, 7) assert.ok(Math.max(...widths) - Math.min(...widths) < 1) const actionRow = await daily.locator('.day-vehicle-scope > .el-button').evaluateAll((buttons) => buttons.map((button) => { const r = button.getBoundingClientRect() return { top: r.top, bottom: r.bottom, right: r.right } }) ) assert.equal(actionRow.length, 3, '车辆范围、复制在同一行') assert.ok( await panel.locator('.day-number').evaluateAll((buttons) => buttons.every((button) => { const range = document.createRange() range.selectNodeContents(button) return range.getClientRects().length === 1 }) ), '两位数日期不换行' ) assert.equal(await daily.locator('.day-heading .day-summary-button').count(), 1) assert.ok( await daily.locator('.day-heading').evaluate((heading) => { const row = heading.getBoundingClientRect() const summary = heading.querySelector('.day-summary-button').getBoundingClientRect() const date = heading.querySelector('.day-number').getBoundingClientRect() const weekday = heading.querySelector('.day-weekday').getBoundingClientRect() return ( Math.abs((row.left + row.right - summary.left - summary.right) / 2) < 1 && date.right <= summary.left && summary.right <= weekday.left ) }), '合计在日期标题栏正中间,且不与日期、星期重叠' ) assert.ok( actionRow.every( (r) => Math.abs(r.top - actionRow[0].top) < 1 && Math.abs(r.bottom - actionRow[0].bottom) < 1 ) ) assert.ok( await daily.locator('.day-vehicle-scope').evaluate((el) => el.scrollWidth <= el.clientWidth), '操作行不能横向溢出' ) const tripCounts = await daily .locator('.vehicle-card') .evaluateAll((cards) => cards.map((card) => card.querySelectorAll('.trip-card').length)) assert.equal(tripCounts.length, 6) assert.deepEqual( tripCounts, [...tripCounts].sort((a, b) => b - a) ) assert.ok( await daily .locator('.day-content') .evaluate( (el) => el.scrollHeight <= el.clientHeight && getComputedStyle(el).overflowY === 'visible' ) ) assert.equal(await daily.locator('.trip-card').count(), 11) const weekdayWidths = await panel .locator('.calendar-weekdays') .evaluate((el) => getComputedStyle(el).gridTemplateColumns) assert.equal( await panel .locator('.calendar-grid') .evaluate((el) => getComputedStyle(el).gridTemplateColumns), weekdayWidths ) assert.ok( await daily .locator('.vehicle-card') .evaluateAll((cards) => cards.every((card) => card.scrollWidth <= card.clientWidth)) ) await page.setViewportSize({ width: 2560, height: 1300 }) const rowCards = await daily.locator('.vehicle-card').evaluateAll((cards) => cards.map((card) => { const rect = card.getBoundingClientRect() return { top: rect.top, bottom: rect.bottom } }) ) for (const card of rowCards) { for (const sibling of rowCards.filter((item) => Math.abs(item.top - card.top) < 1)) assert.ok(Math.abs(sibling.bottom - card.bottom) < 1) } await daily.screenshot({ path: '/tmp/shanxia-dispatch-calendar-dense.png' }) await page.screenshot({ path: '/tmp/shanxia-dispatch-calendar-dense-web.png' }) await page.setViewportSize({ width: 600, height: 1000 }) assert.ok( await panel.locator('.calendar-grid').evaluate((el) => el.scrollWidth <= el.clientWidth) ) const availableWidth = await daily.locator('.day-content').evaluate((el) => el.clientWidth) assert.equal( await daily .locator('.vehicle-grid') .evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length), availableWidth >= 420 ? 3 : availableWidth >= 280 ? 2 : 1 ) rows.push({ ...rows[0], id: 'dense-duplicate', planNo: 'DENSE-DUP' }) await page.setViewportSize({ width: 1440, height: 1000 }) await panel.getByRole('button', { name: '刷新', exact: true }).click() const mergedTask = daily.locator('.trip-card').filter({ hasText: '· 2车次' }) await mergedTask.waitFor() assert.equal(await daily.locator('.trip-card').count(), 11) assert.match(await mergedTask.getAttribute('title'), /DENSE0/) assert.match(await mergedTask.getAttribute('title'), /DENSE-DUP/) assert.match(await daily.locator('.day-summary-button').textContent(), /合计\s*12\s*车次/) await daily.getByRole('button', { name: '查看2026-10-24货品汇总' }).click() const mergedSummary = page.locator('.calendar-summary-popper:visible') await mergedSummary.waitFor() assert.match(await mergedSummary.locator('.day-total').textContent(), /合计\s*12\s*车次/) const productCounts = await mergedSummary.locator('.product-total dd strong').allTextContents() assert.equal( productCounts.reduce((sum, value) => sum + Number(value), 0), 12 ) await panel.locator('.title-bar-heading').click() await mergedSummary.waitFor({ state: 'hidden' }) await daily .locator('.vehicle-card') .first() .screenshot({ path: '/tmp/shanxia-calendar-merged-trip.png' }) // 一计划三送货段,货品和收货方分别对应;相同段再合并,计划车次不膨胀。 rows.splice(0, rows.length, { id: 'multi-stop', planNo: 'MULTI', planDate: '2026-10-24', followStatus: 'pending', vehicleId: 'v-multi', licensePlate: '皖H21522', load: { customerId: 'load', customerName: '安庆山峡化工有限公司', calendarCustomerName: '安庆山峡' }, products: [ { id: 'pa', productId: 'a', productName: '氯化钠' }, { id: 'pb', productId: 'b', productName: '次氯酸钠' } ], deliveries: [ { id: 's1', seq: 1, customerId: '甲', customerName: '甲有限公司', calendarCustomerName: '甲', products: [{ planProductId: 'pa', productId: 'a', productName: '氯化钠' }] }, { id: 's2', seq: 2, customerId: '乙', customerName: '乙有限公司', calendarCustomerName: '乙', products: [{ planProductId: 'pb', productId: 'b', productName: '次氯酸钠' }] }, { id: 's3', seq: 3, customerId: '丙', customerName: '丙有限公司', calendarCustomerName: '丙', products: [{ planProductId: 'pa', productId: 'a', productName: '氯化钠' }] } ] }) await panel.getByRole('button', { name: '刷新', exact: true }).click() await daily.getByText('皖H21522', { exact: true }).waitFor() assert.equal(await daily.locator('.trip-card').count(), 3) assert.deepEqual(await daily.locator('.trip-route').allTextContents(), [ '山峡 → 甲', '山峡 → 乙', '山峡 → 丙' ]) assert.match(await daily.locator('.day-summary-button').textContent(), /合计\s*1\s*车次/) rows[0].deliveries[2].customerId = '甲' rows[0].deliveries[2].calendarCustomerName = '甲' rows[0].deliveries[2].customerName = '甲有限公司' await panel.getByRole('button', { name: '刷新', exact: true }).click() await daily.getByText(/2段次/).waitFor() assert.equal(await daily.locator('.trip-card').count(), 2) assert.match(await daily.locator('.trip-card').first().textContent(), /氯化钠.*1车次.*2段次/) assert.match(await daily.locator('.day-summary-button').textContent(), /合计\s*1\s*车次/) await daily.getByRole('button', { name: '查看2026-10-24货品汇总' }).click() const stopSummary = page.locator('.calendar-summary-popper:visible') await stopSummary.waitFor() assert.match(await stopSummary.locator('.day-total').textContent(), /合计\s*1\s*车次/) assert.deepEqual(await stopSummary.locator('.product-total dd strong').allTextContents(), [ '1', '1' ]) await panel.locator('.title-bar-heading').click() await stopSummary.waitFor({ state: 'hidden' }) await daily.locator('.vehicle-card').screenshot({ path: '/tmp/shanxia-calendar-multi-stop.png' }) // 每天独立默认自有;全部包含承运商与未知车辆,汇总随当天筛选变化。 const base = rows[0] rows.splice( 0, rows.length, { ...base, id: 'own', vehicleId: 'own', licensePlate: '皖OWN', calendarVehicleType: 'self' }, { ...base, id: 'carrier', vehicleId: 'carrier', licensePlate: '皖OUT', calendarVehicleType: 'carrier' }, { ...base, id: 'unknown', vehicleId: 'unknown', licensePlate: '皖UNKNOWN', calendarVehicleType: 'unknown' }, { ...base, id: 'other-day', planDate: '2026-10-25', vehicleId: 'carrier-next', licensePlate: '皖NEXT', calendarVehicleType: 'carrier' } ) await panel.getByRole('button', { name: '刷新', exact: true }).click() await daily.getByText('皖OWN', { exact: true }).waitFor() assert.equal(await daily.locator('.vehicle-card').count(), 1) const nextDay = panel.locator('[data-date="2026-10-25"]') assert.equal(await nextDay.locator('.vehicle-card').count(), 0) await daily.getByRole('button', { name: '全部', exact: true }).click() assert.equal(await daily.locator('.vehicle-card').count(), 3) await copyButton.click() copiedText = await page.evaluate(() => navigator.clipboard.readText()) assert.match(copiedText, /皖OUT/) assert.match(copiedText, /皖UNKNOWN/) assert.ok(!copiedText.includes('皖NEXT'), '只复制点击的日期') assert.match(await daily.locator('.day-summary-button').textContent(), /合计\s*3\s*车次/) assert.equal(await nextDay.locator('.vehicle-card').count(), 0) await daily.getByRole('button', { name: '查看2026-10-24货品汇总' }).click() const ownershipSummary = page.locator('.calendar-summary-popper:visible') assert.match(await ownershipSummary.locator('.day-total').textContent(), /合计\s*3\s*车次/) await panel.locator('.title-bar-heading').click() await nextDay.getByRole('button', { name: '全部', exact: true }).click() assert.equal(await nextDay.locator('.vehicle-card').count(), 1) await daily.getByRole('button', { name: '山峡车辆', exact: true }).click() assert.equal(await daily.locator('.vehicle-card').count(), 1) assert.equal(await nextDay.locator('.vehicle-card').count(), 1) await page.locator('.calendar-weekdays').click() await page.locator('.calendar-summary-popper:visible').waitFor({ state: 'hidden' }) await copyButton.click() copiedText = await page.evaluate(() => navigator.clipboard.readText()) assert.match(copiedText, /皖OWN/) assert.ok( !copiedText.includes('皖OUT') && !copiedText.includes('皖UNKNOWN'), '复制跟随当天自有车辆筛选' ) 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 copyButton.click() await page.getByText('复制失败,请检查浏览器剪贴板权限后重试', { exact: true }).waitFor() await page.evaluate(() => { navigator.clipboard.writeText = window.__copyWrite document.execCommand = window.__copyExec }) await page.screenshot({ path: '/tmp/calendar-copy-web.png' }) await page.screenshot({ path: '/tmp/calendar-daily-ownership-web.png' }) assert.deepEqual(errors, []) console.log( 'PASS: 日历加载、权限路由、相同任务合并且总车次不变、货品统计弹出、日格无独立滚动、车辆筛选、客户简称和蓝色配色、重置、切月、日期定位、七天无横向溢出、车辆车次排序、同排对齐和窄屏布局、隔离打印和错误重试。' ) } catch (error) { console.error('PAGE:', await page.locator('body').innerText()) console.error('ERRORS:', errors) console.error( 'ROUTES:', await page.evaluate(() => document .querySelector('#app') ?.__vue_app__?.config.globalProperties.$router?.getRoutes() .filter((r) => r.path.startsWith('/pl')) .map((r) => ({ name: r.name, path: r.path })) ) ) await page.screenshot({ path: '/tmp/shanxia-dispatch-calendar-failure.png' }) throw error } finally { await browser.close() }