From a25e0c8870e7e3469036b25411dd912ae428e515 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 19:49:58 +0800 Subject: [PATCH] =?UTF-8?q?test(channelbridge):=20=E9=94=81=E4=BD=8F?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E8=A7=A3=E6=9E=90=E7=9A=84=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=EF=BC=88BOM=20=E9=9F=A7=E6=80=A7=E5=BF=85=E9=A1=BB=E5=9C=A8?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E4=BE=A7=E7=94=9F=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TestParseKeepIndices_ToleratesBOMWiring:喂对端**原样字节**(\xef\xbb\xbf + JSON 数组) 给生产路径的 parseKeepIndices,必须解析成功且升序;同时断言夹具真的以 BOM 开头、且裸 json.Unmarshal 在它上面仍然失败 —— 把委派改回裸 json.Unmarshal 即红。 - 新增 TestParseKeepIndices_StillFiltersAfterDelegation:委派之后过滤口径不变(越界/负数/ 重复编号丢弃、结果升序)。 - 新增 TestNewLLMReviewer_ToleratesBOMOutput:走整条生产路径(NewLLMReviewer 产出的 Reviewer),回带 BOM 时给出保留编号而不是退回 FIFO。 - 注释里写明 M24 已确认的语义代价:审查移出准入路径后 review_pruned 不再从门口丢消息 (41–74 条/日 会进流水线),而 rate_limited 门口丢与 FIFO 上限修剪一字不改(由既有用例钉住)。 只加用例与必要注释(含一个为断言所需的 encoding/json import),未改动既有用例的任何断言。 --- .../services/channelbridge/flowgate_test.go | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/internal/services/channelbridge/flowgate_test.go b/internal/services/channelbridge/flowgate_test.go index bc5f94d..2fe93a1 100644 --- a/internal/services/channelbridge/flowgate_test.go +++ b/internal/services/channelbridge/flowgate_test.go @@ -3,6 +3,7 @@ package channelbridge import ( "bytes" "context" + "encoding/json" "log/slog" "strings" "testing" @@ -329,3 +330,88 @@ func TestParseKeepIndices(t *testing.T) { t.Fatal("expected error for non-JSON") } } + +// ── M24:审查解析的**接线**(BOM 韧性只有在这里才算真的生效)──────────────── +// +// parseKeepIndices 现在是 adapter.ParseReviewIndices 的一行委派;adapter 侧有自己的 +// 用例,但「委派还在不在」只有本文件的用例能锁住 —— 谁把它改回裸 json.Unmarshal, +// 下面这几条就会红。生产症状(`~/cy.log`,9 天):1,909 次审查里 1,301 次解析失败, +// 其中 1,288 次错误串逐字是 `invalid character 'ï' looking for beginning of value` +// (对端在数组前回带了 UTF-8 BOM),每一次都白烧一次模型调用。 +// +// 另一条已确认接受的语义代价(M24,塔裁决):审查移出准入路径后 review_pruned 不再 +// 从门口丢消息(实测 41–74 条/日 现在会进流水线)—— 这是「不阻塞读循环」的必然代价。 +// rate_limited 的门口丢与 FIFO 上限修剪**一字不改**:前者由本文件既有的 +// TestFlowGate_RateLimitDropsNonOwnerAndAudits 钉住,后者由 adapter 侧的 +// TestFlowGate_CapStillEvictsOnAdmission 钉住。 + +// bomReviewOutput 是审查模型网关的**原样**回包:UTF-8 BOM 紧接 JSON 数组。写死字节 +// (而不是 \ufeff 转义或运行时拼装),使「夹具被谁悄悄去掉 BOM」本身就会红。 +const bomReviewOutput = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]" + +// TestParseKeepIndices_ToleratesBOMWiring 锁住接线:生产路径的 parseKeepIndices 必须 +// 吃下带 BOM 的对端回包。 +func TestParseKeepIndices_ToleratesBOMWiring(t *testing.T) { + t.Parallel() + + // 夹具真实性:它必须以 BOM 字节开头,且裸 json.Unmarshal 在它上面必须失败 —— + // 否则这条用例就不再模拟任何生产故障。 + if !strings.HasPrefix(bomReviewOutput, "\xef\xbb\xbf") { + t.Fatal("fixture lost its BOM bytes: this case no longer models the production failure") + } + var raw []int + if err := json.Unmarshal([]byte(bomReviewOutput), &raw); err == nil { + t.Fatal("strict json.Unmarshal accepted the BOM fixture; the case is meaningless now") + } + + got, err := parseKeepIndices(bomReviewOutput, 6) + if err != nil { + t.Fatalf("parseKeepIndices(BOM) error = %v, want nil (delegation to adapter.ParseReviewIndices lost?)", err) + } + if len(got) != 6 { + t.Fatalf("keep = %v, want [0 1 2 3 4 5]", got) + } + for i := range got { + if got[i] != i { + t.Fatalf("keep = %v, want ascending [0 1 2 3 4 5]", got) + } + } +} + +// TestParseKeepIndices_StillFiltersAfterDelegation 委派之后过滤口径不变:越界、负数、 +// 重复的编号仍被丢弃,结果按升序(模型幻觉出的数字绝不能淘汰真实消息)。 +func TestParseKeepIndices_StillFiltersAfterDelegation(t *testing.T) { + t.Parallel() + + got, err := parseKeepIndices("\xef\xbb\xbf[3, -1, 3, 1, 99]", 4) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got) != 2 || got[0] != 1 || got[1] != 3 { + t.Fatalf("keep = %v, want [1 3] (out-of-range/negative/dup dropped, ascending)", got) + } +} + +// TestNewLLMReviewer_ToleratesBOMOutput 走**整条生产路径**:审查模型回带 BOM ⇒ +// NewLLMReviewer 产出的 Reviewer 必须给出保留编号,而不是报 unparsable 退回 FIFO。 +// (它证明的是密封在函数里的 wiring,而不只是 parseKeepIndices 这一个符号。) +func TestNewLLMReviewer_ToleratesBOMOutput(t *testing.T) { + t.Parallel() + + review := NewLLMReviewer(&mockLLM{content: bomReviewOutput}, nil) + msgs := make([]adapter.UnifiedMessage, 0, 6) + for i := 0; i < 6; i++ { + msgs = append(msgs, adapter.UnifiedMessage{ + SenderID: "u", + Content: "m", + ContentType: adapter.ContentText, + }) + } + keep, err := review(context.Background(), msgs) + if err != nil { + t.Fatalf("review with BOM output: %v, want nil error", err) + } + if len(keep) != 6 { + t.Fatalf("keep = %v, want all six indices", keep) + } +}