Files
pda-uniapp/tests/page-contract.test.mjs
T
2026-08-12 09:26:00 +08:00

249 lines
16 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('page configuration starts at login and registers independent PDA routes', async () => {
const pages = JSON.parse(await read('../pages.json'))
assert.equal(pages.pages[0].path, 'pages/login/login')
assert.equal(pages.pages[0].style.navigationStyle, 'custom')
assert.deepEqual(pages.pages.map((page) => page.path), [
'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation',
'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking'
])
})
test('application uses the Vue 3 uni-app entry and no unused Android permissions', async () => {
const [manifest, main] = await Promise.all([read('../manifest.json'), read('../main.js')])
assert.match(manifest, /"vueVersion"\s*:\s*"3"/)
assert.match(main, /createSSRApp/)
assert.doesNotMatch(main, /new\s+Vue/)
assert.doesNotMatch(manifest, /android\.permission\.(VIBRATE|ACCESS_NETWORK_STATE)/)
})
test('login uses the real auth endpoint and relaunches to the modular workbench', async () => {
const [login, auth, session, runtime] = await Promise.all([
read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js')
])
for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text))
assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/)
assert.match(auth, /\/auth\/login/)
assert.match(login, /loginRequest\(userId, this\.form\.password\)/)
assert.match(login, /signInWithTokens\(tokens\)/)
assert.match(session, /pda-auth-session/)
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
assert.doesNotMatch(runtime, /typeof\s+import\.meta/)
assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/)
})
test('picking task list uses remote loading orders and never falls back to demo tasks', async () => {
const [tasks, home, modulePage] = await Promise.all([read('../pages/tasks/tasks.vue'), read('../pages/home/home.vue'), read('../pages/module/module.vue')])
assert.match(tasks, /listLoadingOrders/)
assert.match(tasks, /当前网络不可用,请联网后刷新/)
assert.match(tasks, /进入并开始拣货/)
assert.doesNotMatch(tasks, /demo-data|DEMO_/)
assert.match(home, /pages\/tasks\/tasks/)
assert.match(modulePage, /pages\/tasks\/tasks/)
})
test('workbench, module, history and profile are independent sources', async () => {
const [home, module, history, profile] = await Promise.all([
read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages/history/history.vue'), read('../pages/profile/profile.vue')
])
for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', 'getPickingOverview', 'uni-icons']) assert.match(home, new RegExp(text))
assert.doesNotMatch(home, /BottomNav|status-strip|task-card/)
for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text))
assert.match(home, /:disabled="!operation\.available"/)
assert.match(home, /home-operation-disabled/)
assert.match(home, /openOperation\(operation\) \{ if \(operation\.available\) uni\.navigateTo/)
assert.doesNotMatch(home, /pages\/operation\/operation\?module=/)
assert.match(module, /:disabled="!operation\.available"/)
assert.match(module, /operation-item-disabled/)
assert.doesNotMatch(module, /pages\/operation\/operation\?module=/)
assert.match(history, /作业记录/)
assert.match(profile, /个人中心/)
assert.match(profile, /signOut\(\)/)
assert.match(profile, /class="logout-dialog-card"/)
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
})
test('demo model contains all four modules and picking belongs to outbound', async () => {
const data = await read('../data/demo-data.js')
for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货']) assert.match(data, new RegExp(text))
assert.match(data, /key: 'outbound'[\s\S]*?name: '集装箱拣货'[\s\S]*?available: true/)
for (const boxNo of ['CBX-20260709-001', 'CBX-20260709-002', 'CBX-20260709-003']) assert.match(data, new RegExp(boxNo))
})
test('first picking box provides the M001 through M010 scanner demo', async () => {
const { DEMO_PICKING_ITEMS, createBoxes } = await import('../data/demo-data.js')
assert.equal(DEMO_PICKING_ITEMS.length, 10)
assert.deepEqual(DEMO_PICKING_ITEMS.map((item) => item.sku),
Array.from({ length: 10 }, (_, index) => `M${String(index + 1).padStart(3, '0')}`))
assert.equal(new Set(DEMO_PICKING_ITEMS.map((item) => item.id)).size, 10)
assert.ok(DEMO_PICKING_ITEMS.every((item) => item.qty === 1 && item.unit === '件'))
assert.deepEqual(createBoxes()[0].items.map((item) => item.sku), DEMO_PICKING_ITEMS.map((item) => item.sku))
})
test('non-picking operations remain complete static task pages', async () => {
const [data, page] = await Promise.all([read('../data/demo-data.js'), read('../pages/operation/operation.vue')])
for (const text of ['到货收货', '商品上架', '入库质检', '出库复核', '装箱交接', '库存盘点', '商品移库', '库存调整', '商品查询', '库位查询', '任务查询']) assert.match(data, new RegExp(text))
for (const text of ['静态原型', '支持硬件扫描头连续扫描', '任务列表', '待处理', '进行中', '已完成']) assert.match(page, new RegExp(text))
})
test('picking page keeps scan, draft and risky transition contracts', async () => {
const [page, scanner] = await Promise.all([read('../pages/picking/picking.vue'), read('../utils/scan-broadcast.js')])
for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '下一箱', '最近扫码']) assert.match(page, new RegExp(text))
assert.match(page, /saveCurrentBoxDraft\(\)[\s\S]*?this\.boxDrafts =/)
assert.match(page, /loadBoxState\(index\)[\s\S]*?const draft = this\.boxDrafts\[index\]/)
assert.match(page, /requestBoxSwitch\(index\)[\s\S]*?this\.openOverlay\('switchBoxConfirm'\)/)
assert.match(page, /submitPicking\(\)[\s\S]*?this\.openOverlay\('submitConfirm'\)/)
assert.match(page, /submitStatusHint\(\)[\s\S]*?提交时需选择原因/)
assert.match(page, /onBackPress\(\)/)
assert.doesNotMatch(page, /scanFocused|restoreScanFocus|hideKeyboard|@confirm="handleScan"/)
assert.match(page, /<ScanTargetInput v-model="scanValue" name="boxCode"[\s\S]*?:active="activeScanField === 'boxCode'"[\s\S]*?@activate="setScanTarget"[\s\S]*?@submit="handleManualSubmit"/)
assert.match(page, /beforeUnmount\(\)/)
assert.match(page, /startScanBroadcast/)
assert.match(page, /onHide\(\) \{ this\.stopHardwareScanner\(\) \}/)
assert.match(page, /handleHardwareScan\(value\)[\s\S]*?this\.scanValue = value[\s\S]*?this\.processScannedValue\(value, 'broadcast'\)/)
assert.match(page, /handleManualSubmit\(value\)[\s\S]*?\.trim\(\)\.toUpperCase\(\)[\s\S]*?this\.processScannedValue\(normalizedValue, 'manual'\)/)
assert.match(page, /recordScanResult\(type, title, boxCode, detail\)/)
assert.match(page, /lastScanResult\.boxCode/)
assert.match(page, /boxCode: entry\.boxCode \?\? entry\.sku \?\? ''/)
assert.doesNotMatch(page, /lastScanResult\.sku|entry\.sku\s*}}/)
const processScanMethod = page.match(/processScannedValue\(scanValue, source = 'broadcast'\) \{[\s\S]*?\n\t\t\t\},\n\t\t\tpickItem/)
assert.ok(processScanMethod)
assert.doesNotMatch(processScanMethod[0], /this\.scanValue = ''/)
assert.match(scanner, /android\.intent\.action\.SCANRESULT/)
assert.match(scanner, /SCAN_BROADCAST_EXTRA = 'value'/)
assert.match(scanner, /registerReceiver\(receiver, filter\)/)
assert.match(scanner, /Context\.RECEIVER_EXPORTED/)
assert.match(scanner, /unregisterReceiver\(receiver\)/)
assert.match(page, /<view v-if="currentUser" class="page-shell">/)
assert.match(page, /leavePicking\(\)[\s\S]*?navigateBackOr\('\/pages\/home\/home'\)/)
})
test('navigation returns to the real source and prevents duplicate utility pages', async () => {
const [navigation, header, modulePage, operation, picking] = await Promise.all([
read('../utils/navigation.js'), read('../components/PortalHeader.vue'), read('../pages/module/module.vue'),
read('../pages/operation/operation.vue'), read('../pages/picking/picking.vue')
])
assert.match(navigation, /pages\.length > 1[\s\S]*?uni\.navigateBack/)
assert.match(navigation, /uni\.reLaunch\(\{ url: fallbackUrl \}\)/)
assert.match(navigation, /getCurrentRoute\(\) === route/)
assert.match(header, /aria-label="返回上一页"/)
assert.match(header, /navigateBackOr\(this\.fallbackUrl\)/)
assert.match(header, /navigateToUnique\('\/pages\/profile\/profile'\)/)
assert.match(header, /@change="handleMenuChange"/)
assert.match(header, /this\.\$emit\('menu-change', Boolean\(event\.show\)\)/)
assert.doesNotMatch(header, /goHome\(\)/)
assert.doesNotMatch([modulePage, operation].join('\n'), /class="inline-back"/)
assert.match(operation, /:fallback-url="`\/pages\/module\/module\?key=\$\{moduleKey\}`"/)
assert.match(picking, /navigateBackOr\('\/pages\/home\/home'\)/)
})
test('home consumes Android Back while its utility menu is open', async () => {
const [home, profile, header] = await Promise.all([
read('../pages/home/home.vue'), read('../pages/profile/profile.vue'), read('../components/PortalHeader.vue')
])
assert.match(home, /@menu-change="menuOpen = \$event"/)
assert.match(home, /onBackPress\(\)[\s\S]*?if \(!this\.menuOpen\) return false[\s\S]*?this\.\$refs\.portalHeader\.closeMenu\(\)[\s\S]*?return true/)
assert.match(profile, /:show-profile="false"/)
assert.match(profile, /openLogout\(\) \{ this\.logoutOpen = true; this\.\$refs\.logoutPopup\.open\(\) \}/)
assert.match(profile, /closeLogout\(\) \{ this\.logoutOpen = false; this\.\$refs\.logoutPopup\.close\(\) \}/)
assert.match(header, /v-if="showProfile" class="avatar-button"/)
assert.match(header, /class="portal-header-spacer"/)
})
test('business dialogs use uni-ui popup components consistently', async () => {
const [popupHeader, header, profile, picking] = await Promise.all([
read('../components/PopupHeader.vue'), read('../components/PortalHeader.vue'),
read('../pages/profile/profile.vue'), read('../pages/picking/picking.vue')
])
assert.match(popupHeader, /type="closeempty"/)
assert.match(popupHeader, /aria-label="`关闭\$\{title\}`"/)
assert.match(popupHeader, /@tap="\$emit\('close'\)"/)
assert.match(header, /<uni-popup ref="menuPopup"[\s\S]*?:is-mask-click="true"/)
assert.match(header, /<PopupHeader title="功能菜单" @close="closeMenu"/)
assert.doesNotMatch(header, /uni\.showActionSheet/)
assert.match(profile, /<uni-popup ref="logoutPopup" type="center"[\s\S]*?:is-mask-click="true"[\s\S]*?@change="handleLogoutChange"/)
assert.match(profile, /<PopupHeader title="确认退出登录?"[\s\S]*?@close="closeLogout"/)
assert.match(profile, /class="logout-dialog-confirm"/)
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete', 'allComplete']) {
assert.match(picking, new RegExp(`<uni-popup ref="${name}Popup"[^>]*:is-mask-click="true"[^>]*@change="handleOverlayChange\\('${name}', \\$event\\)"`))
}
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 7)
assert.match(picking, /openOverlay\(name\)[\s\S]*?this\.\$refs\[`\$\{name\}Popup`\]/)
assert.match(picking, /closeOverlay\(\)[\s\S]*?popup\.close\(\)/)
assert.match(picking, /handleOverlayChange\(name, event\)[\s\S]*?!event\.show[\s\S]*?this\.resetOverlayState\(\)/)
assert.match(picking, /resetOverlayState\(\)[\s\S]*?this\.activeOverlay = ''/)
assert.doesNotMatch([header, profile, picking].join('\n'), /modal-overlay/)
})
test('scanner diagnostic page exposes the confirmed broadcast and cleans up its receiver', async () => {
const [header, page] = await Promise.all([read('../components/PortalHeader.vue'), read('../pages/scanner-test/scanner-test.vue')])
assert.doesNotMatch(header, /广播扫码测试|goScannerTest/)
for (const text of ['接收器已启动', '最近收到', '等待扫描', 'Action', 'Extra 标签', '接收记录']) assert.match(page, new RegExp(text))
assert.match(page, /startScanBroadcast/)
assert.match(page, /onHide\(\) \{ this\.stopScanner\(\) \}/)
assert.match(page, /onUnload\(\) \{ this\.stopScanner\(\) \}/)
assert.match(page, /this\.history = \[\{ id:[\s\S]*?\.slice\(0, 20\)/)
})
test('picking scan results use the shared success and error audio feedback', async () => {
const [audio, picking] = await Promise.all([
read('../utils/audio-feedback.js'), read('../pages/picking/picking.vue')
])
assert.match(audio, /success: '\/static\/mp3\/success\.mp3'/)
assert.match(audio, /error: '\/static\/mp3\/fail\.mp3'/)
assert.match(audio, /uni\.createInnerAudioContext\(\)/)
assert.match(audio, /export function playSuccessSound\(\)/)
assert.match(audio, /export function playErrorSound\(\)/)
assert.match(picking, /import \{ playErrorSound, playSuccessSound \} from '@\/utils\/audio-feedback\.js'/)
assert.match(picking, /result\.type === 'unknown'[\s\S]*?playErrorSound\(\)/)
assert.match(picking, /playSuccessSound\(\)[\s\S]*?this\.pickScannedBox\(result\.item, value\)/)
})
test('reusable scan target input separates field selection from page-level broadcast handling', async () => {
const [component, picking] = await Promise.all([read('../components/ScanTargetInput.vue'), read('../pages/picking/picking.vue')])
assert.match(component, /emits: \['activate', 'update:modelValue', 'submit'\]/)
assert.match(component, /\$emit\('activate', name\)/)
assert.match(component, /scan-target-input-active/)
assert.match(component, /<input[\s\S]*?:value="modelValue"[\s\S]*?@input="handleInput"[\s\S]*?@confirm="handleConfirm"/)
assert.doesNotMatch(component, /<input[\s\S]*?disabled/)
assert.doesNotMatch(component, /startScanBroadcast|plus\.android/)
assert.match(picking, /activeScanField: 'boxCode'/)
assert.match(picking, /handleHardwareScan\(value\)[\s\S]*?activeScanField !== 'boxCode'[\s\S]*?processScannedValue\(value, 'broadcast'\)/)
})
test('picking session persists per task identity and clears on logout', async () => {
const [store, session, page] = await Promise.all([read('../stores/picking.js'), read('../stores/session.js'), read('../pages/picking/picking.vue')])
assert.match(store, /getPickingSession/)
assert.match(store, /savePickingSession/)
assert.match(store, /resetPickingSession/)
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
assert.match(page, /onUnload\(\) \{ this\.stopHardwareScanner\(\); this\.persistSession\(\)/)
})
test('shared visual contracts preserve accessible PDA targets', async () => {
const [app, styles, picking] = await Promise.all([read('../App.vue'), read('../styles/pda.scss'), read('../pages/picking/picking.vue')])
assert.match(app, /@import '@\/styles\/pda\.scss'/)
assert.doesNotMatch(styles, /<\/?style\b/)
for (const className of ['pick-btn', 'partial-quick-btn']) assert.match(styles, new RegExp(`\\.${className}\\s*\\{[\\s\\S]*?height:\\s*48px;`))
assert.doesNotMatch(styles, /\.scan-btn\s*\{/)
assert.match(styles, /\.modal-cancel-btn,[\s\S]*?height:\s*48px;/)
assert.match(styles, /\.modal-sheet\s*\{[\s\S]*?padding:\s*20px 18px 18px;/)
assert.doesNotMatch(styles, /\.modal-sheet\s*\{[\s\S]*?padding:[^;]*env\(/)
assert.match(styles, /\.submit-decision-modal\s*\{[^}]*padding:\s*18px 16px 16px;/)
assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/)
assert.match(styles, /\.submit-section\s*\{[\s\S]*?bottom:\s*8px;[\s\S]*?width:\s*calc\(100% - 20px\);/)
assert.match(styles, /\.submit-btn\s*\{[\s\S]*?flex:\s*1;[\s\S]*?height:\s*56px;/)
assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*10px;/)
assert.match(styles, /\.header-menu-item\s*\{[^}]*width:\s*calc\(100% - 32px\);[^}]*margin:\s*0 16px;/)
assert.match(styles, /\.header-menu-close\s*\{[^}]*width:\s*calc\(100% - 32px\);/)
assert.match(picking, /aria-modal="true"/)
assert.match(picking, /role="status"/)
assert.match(picking, /transform:\s*'scaleX\('/)
})