This commit is contained in:
2026-08-27 11:42:05 +08:00
parent c80307a838
commit 086a0f9c89
9 changed files with 275 additions and 18 deletions
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import { readdirSync, readFileSync } from 'node:fs'
import test from 'node:test'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const firstlegServiceDirectory = new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/', import.meta.url)
const serviceSources = readdirSync(firstlegServiceDirectory)
.filter((name) => name.endsWith('.java'))
.map((name) => read(`../../firstleg/src/main/java/com/baiyue/firstleg/service/${name}`)).join('\n')
const smartLoading = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const smartLoadingTest = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
const loadingDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java')
const loadingRepository = read('../../firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java')
const openapi = read('../../firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
const adminApi = read('../../../apps/admin-web/src/modules/loading-lists/api/platformLoadingLists.ts')
const adminDrawer = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue')
const adminTests = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListsView.test.ts')
test('reference projection removes only pure STATUS_UPDATE no-ops', () => {
assert.equal(isPureStatusNoop({ actionType: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'EDITING',
beforeSnapshot: '{"status":"EDITING","version":1}', afterSnapshot: '{"version":2,"status":"EDITING"}' }), true)
assert.equal(isPureStatusNoop({ actionType: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'LOADING' }), false)
assert.equal(isPureStatusNoop({ actionType: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'EDITING',
beforeSnapshot: '{"containerNo":null}', afterSnapshot: '{"containerNo":"MSCU1"}' }), false)
assert.equal(isPureStatusNoop({ actionType: 'PUSH_PRELOAD', fromStatus: 'EDITING', toStatus: 'EDITING' }), false)
})
test('deduplication uses stable event identity and never identical display text', () => {
const rows = visibleLoadingHistories([
{ historyId: '1', operationId: 'push-1', actionType: 'PUSH_PRELOAD', operationReason: '推送预装' },
{ historyId: '2', operationId: 'push-1', actionType: 'PUSH_PRELOAD', operationReason: '推送预装' },
{ historyId: '3', operationId: 'push-2', actionType: 'PUSH_PRELOAD', operationReason: '推送预装' },
{ historyId: '4', actionType: 'ADJUST_ITEM', operationReason: '相同文案' },
{ historyId: '5', actionType: 'ADJUST_ITEM', operationReason: '相同文案' },
])
assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5'])
})
test('backend never appends a generic STATUS_UPDATE and push replay is read-only', () => {
assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s)
assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/)
assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/)
assert.match(smartLoadingTest, /exactPushReplayIsReadOnly[\s\S]{0,1000}never\(\)\.insert/)
assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/)
})
test('operationId is projected end-to-end for identity-based frontend deduplication', () => {
assert.match(loadingDtos, /record History\([\s\S]{0,180}String operationId/)
assert.match(loadingRepository, /new History\([\s\S]{0,180}getOperationId\(\)/)
assert.match(openapi, /OperationHistory:[\s\S]{0,500}operationId:/)
assert.match(adminApi, /interface LoadingHistory[\s\S]{0,260}operationId:\s*string/)
assert.match(adminApi, /normalizeHistory[\s\S]{0,260}operationId:/)
})
test('Admin counts and renders the filtered history projection', () => {
assert.match(adminDrawer, /(?:visible|effective|filtered)Histories/)
assert.doesNotMatch(adminDrawer, /detail\.histories\.length\s*\}\}\s*条/)
assert.doesNotMatch(adminDrawer, /v-for="history in detail\.histories"/)
assert.match(adminDrawer, /STATUS_UPDATE/)
assert.match(adminDrawer, /operationId|eventId/)
})
test('Admin behavior contract covers no-op, identity duplicate, count and same-text distinct events', () => {
for (const marker of ['STATUS_UPDATE', 'operationId', 'PUSH_PRELOAD']) assert.match(adminTests, new RegExp(marker))
assert.match(adminTests, /(?:same text|same wording|相同文案|同文案)/i)
assert.match(adminTests, /(?:count|条)/i)
})
@@ -0,0 +1,46 @@
const VOLATILE_SNAPSHOT_FIELDS = new Set([
'requestHash', 'version', 'updatedAt', 'operatorId', 'operatorName', 'occurredAt',
])
function stableValue(value) {
if (Array.isArray(value)) return value.map(stableValue)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !VOLATILE_SNAPSHOT_FIELDS.has(key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, stableValue(nested)]))
}
function snapshot(value) {
if (value == null || value === '') return null
try { return stableValue(typeof value === 'string' ? JSON.parse(value) : value) } catch { return value }
}
function sameSnapshot(left, right) {
return JSON.stringify(snapshot(left)) === JSON.stringify(snapshot(right))
}
export function isPureStatusNoop(history) {
if (history?.actionType !== 'STATUS_UPDATE') return false
if ((history?.fromStatus ?? null) !== (history?.toStatus ?? null)) return false
return sameSnapshot(history?.beforeSnapshot, history?.afterSnapshot)
}
function stableEventKey(history) {
if (history?.operationId) return `operation:${history.operationId}`
if (history?.eventId) return `event:${history.eventId}`
if (history?.historyId) return `history:${history.historyId}`
return null
}
export function visibleLoadingHistories(histories) {
const seen = new Set()
return (Array.isArray(histories) ? histories : []).filter((history) => {
if (isPureStatusNoop(history)) return false
const key = stableEventKey(history)
if (!key) return true
if (seen.has(key)) return false
seen.add(key)
return true
})
}
@@ -1,4 +1,5 @@
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
import { createHash } from 'node:crypto'
const MODULE = 'phase3-loading'
@@ -319,6 +320,12 @@ export async function run(context) {
const pushedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS)
const pushedHistories = visibleLoadingHistories(pushedDetail?.histories)
context.assert(MODULE, '首次 push 只保留一条 PUSH_PRELOAD 且无纯状态 no-op', pushedDetailResponse,
pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1
&& !(pushedDetail?.histories || []).some(isPureStatusNoop),
`raw=${pushedDetail?.histories?.length ?? 'invalid'}, effective=${pushedHistories.length}, push=${pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`,
OPTIONS)
const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS)
const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null
@@ -353,6 +360,12 @@ export async function run(context) {
const replayDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayHistories = visibleLoadingHistories(replayDetail?.histories)
context.assert(MODULE, 'push 同幂等请求重放历史零新增', replayDetailResponse,
replayHistories.length === pushedHistories.length
&& replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1,
`before=${pushedHistories.length}, after=${replayHistories.length}, push=${replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`,
OPTIONS)
const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
@@ -380,6 +393,7 @@ export async function run(context) {
const rejectedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedHistories = visibleLoadingHistories(rejectedDetail?.histories)
const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
@@ -391,7 +405,9 @@ export async function run(context) {
const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot)
context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse,
failure(sameKeyDifferentBody) && failure(newKeyDuplicate)
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404',
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404'
&& rejectedHistories.length === pushedHistories.length
&& rejectedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1,
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return