diff --git a/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.test.ts b/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.test.ts index b0eeba50..b55369be 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.test.ts @@ -12,18 +12,42 @@ test("local replay classification reports transport_failed below server delivere assert.deepEqual(result.counts, { delivered: 3, clientReceived: 2, decoded: 2, applied: 2 }); }); +test("local replay classification reports transport_failed above server delivered", () => { + const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(3), { clientReceived: 4, decoded: 4, applied: 4 }); + assert.equal(result.ok, false); + assert.equal(result.code, "transport_failed"); +}); + +test("local replay classification reports contract_invalid when server delivered is missing", () => { + const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(), { clientReceived: 0, decoded: 0, applied: 0 }); + assert.equal(result.ok, false); + assert.equal(result.code, "contract_invalid"); +}); + test("local replay classification reports decoder_rejected below client received", () => { const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(3), { clientReceived: 3, decoded: 2, applied: 2 }); assert.equal(result.ok, false); assert.equal(result.code, "decoder_rejected"); }); +test("local replay classification reports decoder_rejected above client received", () => { + const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(3), { clientReceived: 3, decoded: 4, applied: 4 }); + assert.equal(result.ok, false); + assert.equal(result.code, "decoder_rejected"); +}); + test("local replay classification reports reducer_rejected below decoded", () => { const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(3), { clientReceived: 3, decoded: 3, applied: 2 }); assert.equal(result.ok, false); assert.equal(result.code, "reducer_rejected"); }); +test("local replay classification reports reducer_rejected above decoded", () => { + const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(3), { clientReceived: 3, decoded: 3, applied: 4 }); + assert.equal(result.ok, false); + assert.equal(result.code, "reducer_rejected"); +}); + test("local replay classification preserves terminal_complete when every client stage agrees", () => { const result = classifyWorkbenchKafkaDebugLocalResult(terminalResult(3), { clientReceived: 3, decoded: 3, applied: 3 }); assert.equal(result.ok, true); @@ -32,13 +56,13 @@ test("local replay classification preserves terminal_complete when every client assert.equal(result.localOverlay, true); }); -function terminalResult(delivered: number): WorkbenchKafkaDebugReplayResult { +function terminalResult(delivered?: number): WorkbenchKafkaDebugReplayResult { return { contractVersion: "workbench-kafka-debug-replay-v2", ok: true, phase: "terminal", code: "terminal_complete", terminalObserved: true, - counts: { delivered, clientReceived: null, decoded: null, applied: null } + counts: { ...(delivered === undefined ? {} : { delivered }), clientReceived: null, decoded: null, applied: null } }; } diff --git a/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.ts b/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.ts index 7e3c0372..9067c47c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-kafka-debug-local-result.ts @@ -8,7 +8,7 @@ export interface WorkbenchKafkaDebugClientCounts { applied: number; } -export type WorkbenchKafkaDebugClientFailureCode = "transport_failed" | "decoder_rejected" | "reducer_rejected"; +export type WorkbenchKafkaDebugClientFailureCode = "contract_invalid" | "transport_failed" | "decoder_rejected" | "reducer_rejected"; export function classifyWorkbenchKafkaDebugLocalResult( serverResult: WorkbenchKafkaDebugReplayResult, @@ -19,14 +19,17 @@ export function classifyWorkbenchKafkaDebugLocalResult( if (serverResult.code !== "terminal_complete") return overlaid; const delivered = nonNegativeInteger(serverResult.counts?.delivered); - if (delivered !== null && clientCounts.clientReceived < delivered) { - return clientFailure(overlaid, "transport_failed", `浏览器收到 ${clientCounts.clientReceived} 条,少于服务端已投递的 ${delivered} 条。`); + if (delivered === null) { + return clientFailure(overlaid, "contract_invalid", "服务端 terminal_complete 缺少有效的 delivered 计数。"); } - if (clientCounts.decoded < clientCounts.clientReceived) { - return clientFailure(overlaid, "decoder_rejected", `浏览器收到 ${clientCounts.clientReceived} 条,但只解码 ${clientCounts.decoded} 条。`); + if (clientCounts.clientReceived !== delivered) { + return clientFailure(overlaid, "transport_failed", `浏览器收到 ${clientCounts.clientReceived} 条,与服务端已投递的 ${delivered} 条不一致。`); } - if (clientCounts.applied < clientCounts.decoded) { - return clientFailure(overlaid, "reducer_rejected", `浏览器解码 ${clientCounts.decoded} 条,但生产 reducer 只应用 ${clientCounts.applied} 条。`); + if (clientCounts.decoded !== clientCounts.clientReceived) { + return clientFailure(overlaid, "decoder_rejected", `浏览器收到 ${clientCounts.clientReceived} 条,但解码计数为 ${clientCounts.decoded} 条。`); + } + if (clientCounts.applied !== clientCounts.decoded) { + return clientFailure(overlaid, "reducer_rejected", `浏览器解码 ${clientCounts.decoded} 条,但生产 reducer 应用计数为 ${clientCounts.applied} 条。`); } return { ...overlaid, ok: true, phase: "terminal", code: "terminal_complete" }; }