test: add pallet location http matrix

This commit is contained in:
2026-09-04 15:24:45 +08:00
parent 4aed53a8af
commit c55f2aed11
3 changed files with 335 additions and 0 deletions
+13
View File
@@ -63,6 +63,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
| 分类 | 入口 | 用途与副作用 |
|---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
| 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 |
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 |
@@ -123,6 +124,18 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 订单测量与连续库位扩展矩阵
部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `WAIT_SPLIT`。默认固定“义乌仓”并执行 5 个确定性场景:单图单槽、单图两预留槽、实测体积跨档、两图逐次扩容、三图连续扩容。每票使用独立订单、入库单、库位块和 reservation,最后额外验证跨票不串数据。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/pallet-location-test-env-acceptance.mjs
```
只回归部分场景时使用 `--scenarios single-image-single-slot,measured-volume-growth`;仓库可用 `--origin-warehouse-name <名称>` 覆盖。汇总默认写入 `reports/pallet-location-http-matrix-*.json`,每票完整证据写入相邻的 `*-details/` 目录。任一场景失败后仍继续执行剩余场景,最终以非零退出码和汇总报告统一暴露失败。
该入口只复用正式 HTTP 验收模块,不执行 SQL、不部署服务,也不会为了制造容量不足而批量停用普通库位;普通库位自然不足时记录是否落到共享 `A0`。共享 Warehouse 故障不做停服注入,其“不回滚测量主流程”语义由服务单元测试覆盖。真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 固定客户待审核订单
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import {
PALLET_LOCATION_SCENARIOS,
inspectCrossScenarioIsolation,
inspectScenarioReport,
parseMatrixArguments,
} from './pallet-location-test-env-acceptance.mjs'
const source = await readFile(new URL('./pallet-location-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('pallet location matrix declares deterministic one, two, and three-image scenarios', () => {
assert.equal(PALLET_LOCATION_SCENARIOS.length, 5)
assert.deepEqual(PALLET_LOCATION_SCENARIOS.map((item) => item.expectedPalletCount), [1, 1, 1, 2, 3])
assert.match(source, /--modules', 'orders,inbound-measurement'/)
assert.match(source, /--verify-pallet-location-guide', 'true'/)
assert.match(source, /--wait-split-package-count', '18'/)
assert.match(source, /--wait-split-package-count', '12'/)
assert.match(source, /--wait-split-package-count', '61'/)
assert.match(source, /第3张确认后扩容只追加且既有槽身份不漂移/)
})
test('matrix options select known scenarios without duplicate or unknown names', () => {
const parsed = parseMatrixArguments([
'--origin-warehouse-name', '测试仓',
'--scenarios', 'single-image-single-slot,two-image-incremental-growth',
'--output=reports/custom.json',
])
assert.equal(parsed.originWarehouseName, '测试仓')
assert.deepEqual(parsed.scenarioNames, ['single-image-single-slot', 'two-image-incremental-growth'])
assert.equal(parsed.output, 'reports/custom.json')
assert.throws(() => parseMatrixArguments(['--scenarios', 'unknown']), /未知场景/)
assert.throws(() => parseMatrixArguments(['--scenarios', 'measured-volume-growth,measured-volume-growth']), /重复场景/)
})
test('scenario inspection requires deletion release, refill reuse, WAIT_SPLIT, and guide identity', () => {
const scenario = PALLET_LOCATION_SCENARIOS[0]
const requiredNames = [
'删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY',
'替代图片按新 resultId 复用原空槽',
'固定客户第 1 单停在待分柜',
'报告敏感信息扫描',
]
const report = {
success: true,
marker: 'TEST',
summary: { total: 10, failed: 0 },
createdResources: {
inboundMeasurement: { inboundId: '11', measurementId: '12', palletCount: 1, processStatus: 'WAIT_SPLIT' },
inboundMeasurementLocationGuide: {
inboundId: '11', blockId: '13', reservationId: '14', locationId: '15',
deleteSucceeded: true, slotReused: true,
},
},
steps: [
...requiredNames.map((name) => ({ name, passed: true })),
{ name: '第1张确认后整票库位块计数与槽身份完整', passed: true,
evidence: 'blockId=13, reserved=1, actual=1, empty=0, slots=1' },
],
}
const inspected = inspectScenarioReport(scenario, report, 0)
assert.equal(inspected.passed, true)
report.steps.find((step) => step.name.startsWith('删除只解绑')).passed = false
assert.equal(inspectScenarioReport(scenario, report, 0).passed, false)
})
test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => {
const results = [
{ resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } },
{ resources: { inboundId: '2', blockId: '12', reservationId: '22', locationId: 'A0' } },
]
assert.equal(inspectCrossScenarioIsolation(results).passed, true)
results[1].resources.reservationId = '21'
assert.equal(inspectCrossScenarioIsolation(results).passed, false)
})
test('matrix does not disable locations, execute SQL, deploy, or use a shell child process', () => {
assert.doesNotMatch(source, /location-status|batch-disable|UPDATE\s+|DELETE\s+FROM|INSERT\s+INTO/i)
assert.doesNotMatch(source, /jenkins|docker\s+compose|kubectl/i)
assert.match(source, /shell: false/)
assert.match(source, /A0 仅在普通库位自然不足时观测/)
})
@@ -0,0 +1,238 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
export const PALLET_LOCATION_SCENARIOS = Object.freeze([
{
name: 'single-image-single-slot',
description: '1 箱、1 张称重图,验证最小库位块与删除补传复用',
expectedPalletCount: 1,
args: ['--wait-split-package-count', '1', '--wait-split-random-value', '4'],
expectedGuideEvidence: /reserved=1, actual=1, empty=0, slots=1/,
},
{
name: 'single-image-two-reserved-slots',
description: '18 箱、1 张称重图,验证体积预留 2 个连续槽且空槽不阻塞',
expectedPalletCount: 1,
args: ['--wait-split-package-count', '18', '--wait-split-random-value', '74'],
expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/,
},
{
name: 'measured-volume-growth',
description: '12 箱、1 张称重图,验证实测体积跨档后扩为 2 个槽',
expectedPalletCount: 1,
args: ['--wait-split-package-count', '12', '--wait-split-random-value', '87'],
expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/,
},
{
name: 'two-image-incremental-growth',
description: '2 张称重图,验证第二张只追加槽且原槽身份不漂移',
expectedPalletCount: 2,
args: ['--route-mode', 'random', '--wait-split-random-value', '1'],
requiredSteps: ['第2张确认后扩容只追加且既有槽身份不漂移'],
},
{
name: 'three-image-incremental-growth',
description: '61 箱、3 张称重图,验证连续两次增托与既有槽身份稳定',
expectedPalletCount: 3,
args: ['--wait-split-package-count', '61', '--wait-split-random-value', '1'],
requiredSteps: [
'第2张确认后扩容只追加且既有槽身份不漂移',
'第3张确认后扩容只追加且既有槽身份不漂移',
],
},
])
const COMMON_REQUIRED_STEPS = Object.freeze([
'删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY',
'替代图片按新 resultId 复用原空槽',
'固定客户第 1 单停在待分柜',
'报告敏感信息扫描',
])
export function parseMatrixArguments(argv) {
const result = {
help: false,
originWarehouseName: '义乌仓',
scenarioNames: PALLET_LOCATION_SCENARIOS.map((item) => item.name),
output: '',
}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--help') { result.help = true; continue }
if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`)
const separator = argument.indexOf('=')
const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2)
const inline = separator >= 0 ? argument.slice(separator + 1) : undefined
const value = inline === undefined ? argv[++index] : inline
if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`)
if (name === 'origin-warehouse-name') result.originWarehouseName = value.trim()
else if (name === 'scenarios') result.scenarioNames = value.split(',').map((item) => item.trim()).filter(Boolean)
else if (name === 'output') result.output = value
else throw new Error(`无法识别的参数: --${name}`)
}
if (!result.originWarehouseName) throw new Error('origin-warehouse-name不能为空')
if (result.scenarioNames.length === 0) throw new Error('scenarios至少包含一个场景')
const available = new Set(PALLET_LOCATION_SCENARIOS.map((item) => item.name))
const unknown = result.scenarioNames.filter((name) => !available.has(name))
if (unknown.length > 0) throw new Error(`未知场景: ${unknown.join(',')}`)
if (new Set(result.scenarioNames).size !== result.scenarioNames.length) {
throw new Error('scenarios不能包含重复场景')
}
return result
}
export function inspectScenarioReport(scenario, report, exitCode = 0) {
const steps = Array.isArray(report?.steps) ? report.steps : []
const stepByName = new Map(steps.map((step) => [step.name, step]))
const requiredSteps = [...COMMON_REQUIRED_STEPS, ...(scenario.requiredSteps || [])]
const missingOrFailedSteps = requiredSteps.filter((name) => stepByName.get(name)?.passed !== true)
const measurement = report?.createdResources?.inboundMeasurement || {}
const guide = report?.createdResources?.inboundMeasurementLocationGuide || {}
const guideEvidence = steps
.filter((step) => /整票库位块计数与槽身份完整$/.test(String(step.name || '')))
.map((step) => String(step.evidence || ''))
const guideEvidenceMatched = !scenario.expectedGuideEvidence
|| guideEvidence.some((evidence) => scenario.expectedGuideEvidence.test(evidence))
const assertions = {
childExitCode: exitCode === 0,
childReportSuccess: report?.success === true,
requiredSteps: missingOrFailedSteps.length === 0,
expectedPalletCount: Number(measurement.palletCount) === scenario.expectedPalletCount,
waitSplitReached: measurement.processStatus === 'WAIT_SPLIT',
locationIdentityRecorded: Boolean(guide.blockId && guide.reservationId && guide.locationId),
deletedSlotReused: guide.deleteSucceeded === true && guide.slotReused === true,
expectedGuideShape: guideEvidenceMatched,
}
const passed = Object.values(assertions).every(Boolean)
const overflowStep = steps.find((step) => String(step.name || '').includes('A0共享溢出位状态可见'))
return {
name: scenario.name,
description: scenario.description,
passed,
assertions,
missingOrFailedSteps,
summary: report?.summary || null,
resources: {
marker: report?.marker || null,
inboundId: measurement.inboundId || guide.inboundId || null,
measurementId: measurement.measurementId || null,
blockId: guide.blockId || null,
reservationId: guide.reservationId || null,
locationId: guide.locationId || null,
palletCount: measurement.palletCount ?? null,
processStatus: measurement.processStatus || null,
sharedOverflowObserved: /sharedOverflow=true/.test(String(overflowStep?.evidence || '')),
},
}
}
export function inspectCrossScenarioIsolation(results) {
const fields = ['inboundId', 'blockId', 'reservationId']
const assertions = Object.fromEntries(fields.map((field) => {
const values = results.map((result) => result.resources?.[field]).filter(Boolean).map(String)
return [`unique${field[0].toUpperCase()}${field.slice(1)}`, values.length === results.length
&& new Set(values).size === values.length]
}))
return { passed: Object.values(assertions).every(Boolean), assertions }
}
function usage() {
console.log(`用法:
node scripts/pallet-location-test-env-acceptance.mjs [选项]
选项:
--origin-warehouse-name <名称> 固定国内收货仓,默认“义乌仓”
--scenarios <逗号列表> 只执行指定场景,默认执行全部
--output <报告路径> 汇总报告路径;明细写入同名 -details 目录
--help 显示帮助
可选场景:
${PALLET_LOCATION_SCENARIOS.map((item) => `${item.name}: ${item.description}`).join('\n ')}`)
}
async function runChild(scriptPath, args) {
return await new Promise((resolvePromise) => {
const child = spawn(process.execPath, [scriptPath, ...args], {
cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: false,
})
child.once('error', (error) => resolvePromise({ exitCode: 1, error: error.message }))
child.once('exit', (code, signal) => resolvePromise({
exitCode: Number.isInteger(code) ? code : 1,
error: signal ? `子进程被信号 ${signal} 终止` : null,
}))
})
}
async function main() {
const options = parseMatrixArguments(process.argv.slice(2))
if (options.help) { usage(); return }
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
const outputPath = resolve(options.output || `reports/pallet-location-http-matrix-${timestamp}.json`)
const extension = extname(outputPath)
const detailsName = `${basename(outputPath, extension)}-details`
const detailsDir = resolve(dirname(outputPath), detailsName)
const runnerPath = resolve('scripts/admin-test-env-acceptance.mjs')
await mkdir(detailsDir, { recursive: true })
const selected = options.scenarioNames.map((name) =>
PALLET_LOCATION_SCENARIOS.find((scenario) => scenario.name === name))
const startedAt = new Date().toISOString()
const results = []
for (const scenario of selected) {
const scenarioReportPath = resolve(detailsDir, `${scenario.name}.json`)
console.log(`\n[PALLET-LOCATION] START ${scenario.name}: ${scenario.description}`)
const child = await runChild(runnerPath, [
'--modules', 'orders,inbound-measurement',
'--origin-warehouse-name', options.originWarehouseName,
'--verify-pallet-location-guide', 'true',
'--output', scenarioReportPath,
...scenario.args,
])
let report = null
let reportReadError = child.error
try { report = JSON.parse(await readFile(scenarioReportPath, 'utf8')) }
catch (error) { reportReadError ||= error instanceof Error ? error.message : String(error) }
const inspected = inspectScenarioReport(scenario, report, child.exitCode)
results.push({ ...inspected, reportPath: scenarioReportPath, reportReadError })
console.log(`[PALLET-LOCATION] ${inspected.passed ? 'PASS' : 'FAIL'} ${scenario.name}`)
}
const isolation = inspectCrossScenarioIsolation(results)
const success = results.every((result) => result.passed) && isolation.passed
const aggregate = {
reportName: '订单测量与连续库位真实 HTTP 扩展矩阵',
generatedAt: new Date().toISOString(), startedAt, success,
originWarehouseName: options.originWarehouseName,
summary: {
totalScenarios: results.length,
passedScenarios: results.filter((result) => result.passed).length,
failedScenarios: results.filter((result) => !result.passed).length,
sharedOverflowObserved: results.some((result) => result.resources.sharedOverflowObserved),
},
isolation,
results,
coverageNotes: [
'每个场景使用独立订单、入库单和库位块,并验证跨场景 reservation 不串票。',
'A0 仅在普通库位自然不足时观测;脚本不会批量停用普通库位制造容量不足。',
'Warehouse 调用异常时不回滚订单测量主流程由服务单元测试覆盖;真实 HTTP 矩阵不关闭共享服务做故障注入。',
'脚本不执行 SQL、不部署服务、不直接操作数据库。',
],
}
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify(aggregate, null, 2)}\n`, 'utf8')
console.log(`\nREPORT=${outputPath}`)
console.log(`RESULT=${success ? 'PASS' : 'FAIL'} scenarios=${results.length} passed=${aggregate.summary.passedScenarios} failed=${aggregate.summary.failedScenarios}`)
process.exitCode = success ? 0 : 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
}