test: add pallet location http matrix
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user