chore: initial commit

This commit is contained in:
2026-07-30 18:04:37 +08:00
commit 2ffacfd668
356 changed files with 43448 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test'
import assert from 'node:assert/strict'
test('shared audio feedback reuses one player and switches success and error sounds', async () => {
const calls = []
const player = {
src: '',
stop() { calls.push('stop') },
play() { calls.push(`play:${this.src}`) },
destroy() { calls.push('destroy') }
}
let created = 0
globalThis.uni = {
createInnerAudioContext() { created += 1; return player }
}
const audio = await import('../utils/audio-feedback.js')
assert.equal(audio.playSuccessSound(), true)
assert.equal(audio.playErrorSound(), true)
assert.equal(created, 1)
assert.deepEqual(calls, [
'stop', 'play:/static/mp3/success.mp3',
'stop', 'play:/static/mp3/fail.mp3'
])
audio.destroyFeedbackSound()
assert.equal(calls.at(-1), 'destroy')
delete globalThis.uni
})
test('shared audio feedback is a safe no-op outside uni-app', async () => {
const audio = await import('../utils/audio-feedback.js')
assert.equal(audio.playFeedbackSound('unknown'), false)
assert.equal(audio.playSuccessSound(), false)
})
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { navigateBackOr, navigateToUnique } from '../utils/navigation.js'
test('navigateBackOr returns through the stack and falls back when the stack is empty', () => {
const calls = []
globalThis.uni = {
navigateBack: (options) => calls.push(['back', options]),
reLaunch: (options) => calls.push(['reLaunch', options])
}
globalThis.getCurrentPages = () => [{ route: 'pages/home/home' }, { route: 'pages/profile/profile' }]
navigateBackOr('/pages/home/home')
assert.equal(calls[0][0], 'back')
calls.length = 0
globalThis.getCurrentPages = () => [{ route: 'pages/profile/profile' }]
navigateBackOr('/pages/home/home')
assert.deepEqual(calls, [['reLaunch', { url: '/pages/home/home' }]])
calls.length = 0
globalThis.getCurrentPages = () => [{ route: 'pages/home/home' }, { route: 'pages/profile/profile' }]
globalThis.uni.navigateBack = ({ fail }) => fail()
navigateBackOr('/pages/home/home')
assert.deepEqual(calls, [['reLaunch', { url: '/pages/home/home' }]])
})
test('navigateToUnique does not stack the current utility page twice', () => {
const calls = []
globalThis.uni = { navigateTo: (options) => calls.push(options) }
globalThis.getCurrentPages = () => [{ route: 'pages/profile/profile' }]
navigateToUnique('/pages/profile/profile')
assert.deepEqual(calls, [])
navigateToUnique('/pages/history/history')
assert.deepEqual(calls, [{ url: '/pages/history/history' }])
})
+225
View File
@@ -0,0 +1,225 @@
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/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 is isolated and relaunches to the modular workbench', async () => {
const login = await read('../pages/login/login.vue')
for (const text of ['柏悦物流 · PDA作业端', '快捷登录(演示账号)', '工号 / 用户名', '作业仓库']) assert.match(login, new RegExp(text))
assert.match(login, /signIn\(account, this\.form\.warehouse\)/)
assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/)
})
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="sku"[\s\S]*?:active="activeScanField === 'sku'"[\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'\)/)
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\.pickItem\(result\.item\.id, 'scan'\)/)
})
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: 'sku'/)
assert.match(picking, /handleHardwareScan\(value\)[\s\S]*?activeScanField !== 'sku'[\s\S]*?processScannedValue\(value, 'broadcast'\)/)
})
test('picking session is memory-only, survives navigation 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\('/)
})
+94
View File
@@ -0,0 +1,94 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../utils/picking-state.js', import.meta.url), 'utf8')
const state = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
const items = [{ id: 1 }, { id: 2 }, { id: 3 }]
test('createItemStates initializes every item as pending', () => {
assert.deepEqual(state.createItemStates(items), {
1: state.ITEM_STATUS.PENDING,
2: state.ITEM_STATUS.PENDING,
3: state.ITEM_STATUS.PENDING,
})
})
test('getPickSummary disables submit before any effective pick', () => {
const summary = state.getPickSummary(items, {
1: state.ITEM_STATUS.SKIPPED,
2: state.ITEM_STATUS.PENDING,
3: state.ITEM_STATUS.PENDING,
})
assert.equal(summary.canSubmit, false)
assert.equal(summary.buttonText, '提交拣货完成')
assert.equal(summary.progress, 33)
})
test('getPickSummary enables early submit after a picked item', () => {
const summary = state.getPickSummary(items, {
1: state.ITEM_STATUS.PICKED,
2: state.ITEM_STATUS.PENDING,
3: state.ITEM_STATUS.PENDING,
})
assert.equal(summary.canSubmit, true)
assert.equal(summary.buttonText, '提交拣货完成')
assert.deepEqual(
{ picked: summary.picked, partial: summary.partial, skipped: summary.skipped, pending: summary.pending },
{ picked: 1, partial: 0, skipped: 0, pending: 2 },
)
})
test('getPickSummary enables submit when every item is processed', () => {
const summary = state.getPickSummary(items, {
1: state.ITEM_STATUS.SKIPPED,
2: state.ITEM_STATUS.SKIPPED,
3: state.ITEM_STATUS.SKIPPED,
})
assert.equal(summary.canSubmit, true)
assert.equal(summary.buttonText, '提交拣货完成')
assert.equal(summary.progress, 100)
})
test('submitStates turns only pending items into skipped', () => {
assert.deepEqual(state.submitStates(items, {
1: state.ITEM_STATUS.PICKED,
2: state.ITEM_STATUS.PARTIAL,
3: state.ITEM_STATUS.PENDING,
}), {
states: {
1: state.ITEM_STATUS.PICKED,
2: state.ITEM_STATUS.PARTIAL,
3: state.ITEM_STATUS.SKIPPED,
},
autoSkippedIds: [3],
})
})
test('classifyScan distinguishes pending, repeated, and unknown SKU codes', () => {
const scanItems = [{ id: 1, sku: 'EL-38201' }, { id: 2, sku: 'EL-38202' }]
assert.deepEqual(state.classifyScan(scanItems, {
1: state.ITEM_STATUS.PICKED,
2: state.ITEM_STATUS.PENDING,
}, 'EL-38202'), {
type: 'pending',
item: scanItems[1],
})
assert.deepEqual(state.classifyScan(scanItems, {
1: state.ITEM_STATUS.PARTIAL,
2: state.ITEM_STATUS.PENDING,
}, 'EL-38201'), {
type: 'processed',
item: scanItems[0],
status: state.ITEM_STATUS.PARTIAL,
})
assert.deepEqual(state.classifyScan(scanItems, {}, 'NO-SUCH-SKU'), {
type: 'unknown',
item: null,
})
})
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
SCAN_BROADCAST_ACTION,
SCAN_BROADCAST_EXTRA,
isDuplicateScan,
normalizeScanValue,
resetScanDeduplication
} from '../utils/scan-broadcast.js'
test('scanner uses the confirmed PDA broadcast contract', () => {
assert.equal(SCAN_BROADCAST_ACTION, 'android.intent.action.SCANRESULT')
assert.equal(SCAN_BROADCAST_EXTRA, 'value')
assert.equal(normalizeScanValue(' EL-38201\r\n'), 'EL-38201')
assert.equal(normalizeScanValue(null), '')
})
test('scanner drops only immediate duplicate broadcasts', () => {
resetScanDeduplication()
assert.equal(isDuplicateScan('EL-38201', 1000), false)
assert.equal(isDuplicateScan('EL-38201', 1050), true)
assert.equal(isDuplicateScan('EL-38201', 1100), false)
assert.equal(isDuplicateScan('EL-38202', 1110), false)
})