From aac11f6661dff43f123c4c3ef51cfc1ef078dcca Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 9 Nov 2026 12:56:13 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=8C=89=E6=A8=A1=E5=9D=97=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8E=A5=E5=8F=A3=E9=87=8D=E5=86=99=E9=BB=91=E7=9B=92?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=88=E6=89=B9=E6=AC=A1=204=EF=BC=9Acoldc?= =?UTF-8?q?ontext=20/=20tracker=20/=20network=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 个文件全部在 `/test/`,源码目录仍零 `_test.go`。 共 15 条判据,只调导出接口,输入 → 可观察输出。 ## internal/memory/coldcontext —— NewStore/Insert/Get/Query/Consume/Blocks TestInsertThenGet 空 ID 自动补齐;Get 拿回内容;时间戳填好 TestInsertExplicitIDKeepsIt 显式 ID 不被覆盖 TestQueryReturnsRelevantDoc 按分词器词形召回相关文档 TestConsumeIsOneShot 消费过的文档不再返回 TestBlocksReflectsInsertedDoc Blocks 输出可调用 **判据写错过一次并修正**:分词器按空白切词,文档里「发票重试」是一个 token,所以「发票 重试 账单」查不到而「发票重试」能查到 —— 实测探针 确认后按真实输入契约写。判据的输入必须能走到被测路径。 ## internal/tracker —— NewTracker/Init/PreAction/PostAction/Rollback TestFreshTrackerHasNoChanges 新 tracker 无 changeset TestNewChangeSetCarriesAction Action/ID/时间 TestPostActionWithoutFileChangeIsNoop 无文件变更不凭空造 changeset TestRollbackLatestWithoutChangesReportsError TestRollbackClearsChangeSets Rollback 后视图清空 不碰 t.changeSets、不假设 upper/merge 目录结构。 ## internal/network —— NewMonitor/CheckOnce/Status/AggregateResult TestCheckOnceReachableServer 2xx → Reachable=true + URL/时间 TestCheckOnceServerError 5xx → Reachable=false TestCheckOnceUnreachableEndpoint 连不上 → Error 非空,不 panic TestCheckOnceInvalidURL 非法 URL → 失败状态 TestFreshMonitorStatusIsEmpty 初始状态为空 用 httptest 提供真实 HTTP 输入,不 mock client、不碰 m.status。 ## 变异验证(改回旧实现必须红) coldcontext Insert 不补 ID → ★ Insert 没有给空 ID 的文档补 ID network 5xx 判可达 → ★ 5xx 端点判可达 tracker 无变更也造 changeset → ★ 无文件变更却 HasChanges=true 全部还原后复跑全绿。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 python3 .github/scripts/check-ci-gates.py PASS go test ./... -count=1 17 个测试包 / 100 包无测试 / 0 FAIL 3 个新包 -race 全绿 源码目录 _test.go 0 --- .../coldcontext/test/store_contract_test.go | 109 ++++++++++++++++++ .../network/test/monitor_contract_test.go | 80 +++++++++++++ .../tracker/test/tracker_contract_test.go | 78 +++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 internal/memory/coldcontext/test/store_contract_test.go create mode 100644 internal/network/test/monitor_contract_test.go create mode 100644 internal/tracker/test/tracker_contract_test.go diff --git a/internal/memory/coldcontext/test/store_contract_test.go b/internal/memory/coldcontext/test/store_contract_test.go new file mode 100644 index 00000000..bafce370 --- /dev/null +++ b/internal/memory/coldcontext/test/store_contract_test.go @@ -0,0 +1,109 @@ +package test + +// internal/memory/coldcontext 的黑盒判据:NewStore → Insert → 可观察输出。 +// 不读盘上文件、不碰 s.docs。 + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" +) + +func tok(text string) []string { return strings.Fields(text) } + +func newStore(t *testing.T) *coldcontext.Store { + t.Helper() + return coldcontext.NewStore(t.TempDir(), tok) +} + +// TestInsertThenGet:Insert 后 Get 拿回同一份内容,且自动补 ID/时间戳。 +func TestInsertThenGet(t *testing.T) { + s := newStore(t) + doc := &coldcontext.ColdDoc{ + Summary: "账单服务发票重试", + Content: "发票重试策略与对账流程", + Source: "contract", + } + if err := s.Insert(doc); err != nil { + t.Fatalf("Insert: %v", err) + } + if doc.ID == "" { + t.Fatal("★ Insert 没有给空 ID 的文档补 ID") + } + got := s.Get(doc.ID) + if got == nil { + t.Fatal("★ Get 返回 nil") + } + if got.Summary != "账单服务发票重试" || got.Content != "发票重试策略与对账流程" { + t.Fatalf("★ Get 内容不符:%+v", got) + } + if doc.CreatedAt.IsZero() || doc.LastAccess.IsZero() { + t.Fatal("★ Insert 没填时间戳") + } +} + +// TestInsertExplicitIDKeepsIt:显式 ID 不被覆盖。 +func TestInsertExplicitIDKeepsIt(t *testing.T) { + s := newStore(t) + doc := &coldcontext.ColdDoc{ID: "my-doc", Summary: "s", Content: "c"} + if err := s.Insert(doc); err != nil { + t.Fatal(err) + } + if s.Get("my-doc") == nil { + t.Fatal("★ 显式 ID 被覆盖") + } +} + +// TestQueryReturnsRelevantDoc:Query 至少能召回与查询相关的文档。 +func TestQueryReturnsRelevantDoc(t *testing.T) { + s := newStore(t) + if err := s.Insert(&coldcontext.ColdDoc{ + ID: "billing", Summary: "发票重试", Content: "账单服务的对账流程", + }); err != nil { + t.Fatal(err) + } + if err := s.Insert(&coldcontext.ColdDoc{ + ID: "auth", Summary: "登录会话", Content: "刷新令牌过期时间", + }); err != nil { + t.Fatal(err) + } + // 分词器按空白切词:文档里的 "发票重试" 是一个 token, + // 所以查询必须用同一词形,而不是拆成「发票 重试 账单」。 + hits := s.Query("发票重试", 2) + if len(hits) == 0 { + t.Fatal("★ Query 无结果") + } + if hits[0].ID != "billing" { + t.Fatalf("★ Query 排第一的是 %q,期望 billing", hits[0].ID) + } +} + +// TestConsumeIsOneShot:Consume 取过的文档再次取应为空/减少。 +func TestConsumeIsOneShot(t *testing.T) { + s := newStore(t) + if err := s.Insert(&coldcontext.ColdDoc{ + ID: "one", Summary: "一次性内容", Content: "一次性内容", + }); err != nil { + t.Fatal(err) + } + first := s.Consume("一次性内容", 1) + if len(first) == 0 { + t.Fatal("★ 首次 Consume 无结果") + } + second := s.Consume("一次性内容", 1) + if len(second) != 0 { + t.Fatalf("★ Consume 非一次性:第二次仍返回 %d 条", len(second)) + } +} + +// TestBlocksReflectsInsertedDoc:Blocks 输出反映已插入文档的块。 +func TestBlocksReflectsInsertedDoc(t *testing.T) { + s := newStore(t) + if err := s.Insert(&coldcontext.ColdDoc{ + ID: "with-blocks", Summary: "s", Content: "c", + }); err != nil { + t.Fatal(err) + } + _ = s.Blocks() // 输出必须可调用且不 panic +} diff --git a/internal/network/test/monitor_contract_test.go b/internal/network/test/monitor_contract_test.go new file mode 100644 index 00000000..15090233 --- /dev/null +++ b/internal/network/test/monitor_contract_test.go @@ -0,0 +1,80 @@ +package test + +// internal/network 的黑盒判据:NewMonitor → CheckOnce → EndpointStatus。 +// 用 httptest 提供真实输入,不断言内部结构。 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/network" +) + +func newMonitor() *network.Monitor { return network.NewMonitor(time.Second) } + +// TestCheckOnceReachableServer:2xx 端点判可达,且带回 URL/延迟/时间。 +func TestCheckOnceReachableServer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + st := newMonitor().CheckOnce(context.Background(), srv.URL) + if !st.Reachable { + t.Fatalf("★ 2xx 端点判不可达:%+v", st) + } + if st.URL != srv.URL { + t.Fatalf("★ URL = %q,期望 %q", st.URL, srv.URL) + } + if st.LastCheck.IsZero() { + t.Fatal("★ LastCheck 未填") + } +} + +// TestCheckOnceServerError:5xx 判不可达。 +func TestCheckOnceServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + st := newMonitor().CheckOnce(context.Background(), srv.URL) + if st.Reachable { + t.Fatalf("★ 5xx 端点判可达:%+v", st) + } +} + +// TestCheckOnceUnreachableEndpoint:连不上的端点必须带 Error,不 panic。 +func TestCheckOnceUnreachableEndpoint(t *testing.T) { + // 127.0.0.1:1 上没有服务,连接会立刻失败 + st := newMonitor().CheckOnce(context.Background(), "http://127.0.0.1:1/health") + if st.Reachable { + t.Fatal("★ 连不上的端点判可达") + } + if st.Error == "" { + t.Fatal("★ 失败时没给出 Error") + } + if st.URL != "http://127.0.0.1:1/health" { + t.Fatalf("★ URL = %q", st.URL) + } +} + +// TestCheckOnceInvalidURL:非法 URL 必须报错而不是 panic。 +func TestCheckOnceInvalidURL(t *testing.T) { + st := newMonitor().CheckOnce(context.Background(), "://not-a-url") + if st.Reachable || st.Error == "" { + t.Fatalf("★ 非法 URL 没有得到失败状态:%+v", st) + } +} + +// TestFreshMonitorStatusIsEmpty:未跑过 Start 时状态为空、聚合结果可用。 +func TestFreshMonitorStatusIsEmpty(t *testing.T) { + m := newMonitor() + if got := m.Status(); len(got) != 0 { + t.Fatalf("★ 新 Monitor 的 Status = %v,期望空", got) + } + _ = m.AggregateResult() // 输出必须可调用 +} diff --git a/internal/tracker/test/tracker_contract_test.go b/internal/tracker/test/tracker_contract_test.go new file mode 100644 index 00000000..770c393f --- /dev/null +++ b/internal/tracker/test/tracker_contract_test.go @@ -0,0 +1,78 @@ +package test + +// internal/tracker 的黑盒判据:NewTracker/PreAction/PostAction → 可观察输出。 +// 不碰 t.changeSets、不读内部目录结构(只用公开方法返回的结果)。 + +import ( + "path/filepath" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/tracker" +) + +func newTracker(t *testing.T) *tracker.Tracker { + t.Helper() + dir := t.TempDir() + tr := tracker.NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + if err := tr.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + return tr +} + +// TestFreshTrackerHasNoChanges:新 tracker 无 changeset。 +func TestFreshTrackerHasNoChanges(t *testing.T) { + tr := newTracker(t) + if tr.HasChanges() { + t.Fatal("★ 新 tracker 报告有变更") + } + if got := tr.ChangeSets(); len(got) != 0 { + t.Fatalf("★ ChangeSets() = %v,期望空", got) + } +} + +// TestNewChangeSetCarriesAction:构造出的 changeset 带 action/ID/时间。 +func TestNewChangeSetCarriesAction(t *testing.T) { + cs := tracker.NewChangeSet("write_file") + if cs.Action != "write_file" { + t.Fatalf("★ Action = %q", cs.Action) + } + if cs.ID == "" || cs.Timestamp.IsZero() { + t.Fatalf("★ ID/时间未填:%+v", cs) + } +} + +// TestPostActionWithoutFileChangeIsNoop:没有文件变更时 PostAction +// 不得凭空造出 changeset。 +func TestPostActionWithoutFileChangeIsNoop(t *testing.T) { + tr := newTracker(t) + _ = tr.PreAction("write_file") + got := tr.PostAction("write_file") + if len(got.Files) != 0 { + t.Fatalf("★ 无文件变更却记录了 %d 条", len(got.Files)) + } + if tr.HasChanges() { + t.Fatal("★ 无文件变更却 HasChanges=true") + } +} + +// TestRollbackLatestWithoutChangesReportsError:无 changeset 时定向回滚报错。 +func TestRollbackLatestWithoutChangesReportsError(t *testing.T) { + tr := newTracker(t) + if err := tr.RollbackLatest(); err == nil { + t.Fatal("★ 无 changeset 时 RollbackLatest 应报错") + } +} + +// TestRollbackClearsChangeSets:Rollback 后视图清空(无需挂载 overlay)。 +func TestRollbackClearsChangeSets(t *testing.T) { + tr := newTracker(t) + _ = tr.PreAction("a") + _ = tr.PostAction("a") + if err := tr.Rollback(); err != nil { + t.Fatalf("Rollback: %v", err) + } + if tr.HasChanges() { + t.Fatal("★ Rollback 后仍有变更") + } +}