test: 按模块导出接口重写黑盒判据(批次 4:coldcontext / tracker / network)

3 个文件全部在 `<module>/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
This commit is contained in:
JianFeeeee
2026-11-09 12:56:13 +08:00
parent 443110506c
commit aac11f6661
3 changed files with 267 additions and 0 deletions

View File

@ -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
}

View File

@ -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() // 输出必须可调用
}

View File

@ -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 后仍有变更")
}
}