From 5a0ff10d8d2058bbc8fba40870f7c19375fbde33 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 9 Nov 2026 13:08:16 +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=205=EF=BC=9Avecto?= =?UTF-8?q?r=20/=20systemd=20/=20recovery=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 个文件全部在 `/test/`,源码目录仍零 `_test.go`。 共 17 条判据,只调导出接口,输入 → 可观察输出。 ## internal/memory/vector —— 纯数学 + Store TestCosineSimilarity 稀疏向量:同向/正交/零向量 TestDenseCosine 稠密向量:三种基本情形 + 非单位向量 TestStoreInsertSearchRemove Insert → Search 命中 → Remove → 不再命中 **变异第一版没红**:`DenseCosine` 去掉归一化后,我给的 `{1,0}` 用例点积恰好还是 1 ⇒ 空变异。补 `{4,4} / {2,2}`(不归一化得 16, 归一化得 1)后才真红。**变异必须真的改变被测行为**,这条按纪律记下。 ## internal/systemd —— State / NewMigratePlan TestStateInSync 相同/等价/空/不同路径 TestStateDescribe 一致→空;不一致→含两路径与后果 TestSyncCommand 不一致时给出可执行命令 TestNewMigratePlanNormalizesPaths 路径 Clean;脚本内联新旧路径 + shebang TestRestartCommand 命令含给定地址 不读单元文件、不执行脚本、不碰系统状态。 ## internal/recovery —— 文件往返 TestTaskRoundTrip SaveTask → LoadTask 全字段 + Plugins() TestTaskPathsAreStable TaskPath/ResultPath 稳定且互异 TestResultRoundTrip SaveResult → LoadResult 结论字段 TestLoadTaskMissingFileReportsError TestSortSteps 输出可用 ## 变异验证(改回旧实现必须红) vector 不归一化 → ★ 非单位同向 = 16,期望 1 systemd InSync 恒 true → ★ 真不一致: InSync=true recovery 丢 PluginList → ★ PluginList 丢失:[] 全部还原后复跑全绿。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 python3 .github/scripts/check-ci-gates.py PASS go test ./... -count=1 20 个测试包 / 100 包无测试 / 0 FAIL 3 个新包 -race 全绿 源码目录 _test.go 0 --- .../vector/test/vector_contract_test.go | 74 ++++++++++++++++ internal/recovery/test/task_contract_test.go | 87 +++++++++++++++++++ internal/systemd/test/sync_contract_test.go | 86 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 internal/memory/vector/test/vector_contract_test.go create mode 100644 internal/recovery/test/task_contract_test.go create mode 100644 internal/systemd/test/sync_contract_test.go diff --git a/internal/memory/vector/test/vector_contract_test.go b/internal/memory/vector/test/vector_contract_test.go new file mode 100644 index 00000000..fdc5a95c --- /dev/null +++ b/internal/memory/vector/test/vector_contract_test.go @@ -0,0 +1,74 @@ +package test + +// internal/memory/vector 的黑盒判据:纯数学函数 + Store 公开方法,输入 → 输出。 + +import ( + "math" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/memory/vector" +) + +// TestCosineSimilarity:同向=1、正交=0、零向量=0。 +func TestCosineSimilarity(t *testing.T) { + cases := []struct { + name string + a, b vector.Vector + want float64 + }{ + {"同向", vector.Vector{"x": 2, "y": 3}, vector.Vector{"x": 4, "y": 6}, 1}, + {"正交", vector.Vector{"x": 1}, vector.Vector{"y": 1}, 0}, + {"零向量", vector.Vector{}, vector.Vector{"x": 1}, 0}, + } + for _, c := range cases { + got := vector.CosineSimilarity(c.a, c.b) + if math.Abs(got-c.want) > 1e-9 { + t.Errorf("★ %s: CosineSimilarity=%v,期望 %v", c.name, got, c.want) + } + } +} + +// TestDenseCosine:稠密向量同样的三种基本情形。 +func TestDenseCosine(t *testing.T) { + if got := vector.DenseCosine([]float64{1, 0}, []float64{1, 0}); math.Abs(got-1) > 1e-9 { + t.Fatalf("★ 同向 = %v,期望 1", got) + } + if got := vector.DenseCosine([]float64{1, 0}, []float64{0, 1}); got != 0 { + t.Fatalf("★ 正交 = %v,期望 0", got) + } + if got := vector.DenseCosine([]float64{0, 0}, []float64{1, 1}); got != 0 { + t.Fatalf("★ 零向量 = %v,期望 0", got) + } + // 非单位向量:不归一化会得到点积 32 而不是余弦 1。 + if got := vector.DenseCosine([]float64{4, 4}, []float64{2, 2}); math.Abs(got-1) > 1e-9 { + t.Fatalf("★ 非单位同向 = %v,期望 1(未归一化会得 32)", got) + } +} + +// TestStoreInsertSearchRemove:Insert → Search 命中 → Remove → 不再命中。 +func TestStoreInsertSearchRemove(t *testing.T) { + s := vector.NewStore() + s.SetMinScore(0) + if got := s.Size(); got != 0 { + t.Fatalf("★ 新 Store Size=%d,期望 0", got) + } + s.Insert("doc-a", "账单服务", vector.Vector{"账单": 1, "发票": 1}, nil) + s.Insert("doc-b", "登录会话", vector.Vector{"登录": 1}, nil) + if got := s.Size(); got != 2 { + t.Fatalf("★ Insert 后 Size=%d,期望 2", got) + } + hits := s.Search(vector.Vector{"账单": 1}, 2) + if len(hits) == 0 || hits[0].ID != "doc-a" { + t.Fatalf("★ Search 排第一的是 %+v,期望 doc-a", hits) + } + s.Remove("doc-a") + if got := s.Size(); got != 1 { + t.Fatalf("★ Remove 后 Size=%d,期望 1", got) + } + hits = s.Search(vector.Vector{"账单": 1}, 2) + for _, h := range hits { + if h.ID == "doc-a" { + t.Fatal("★ Remove 后仍能检索到 doc-a") + } + } +} diff --git a/internal/recovery/test/task_contract_test.go b/internal/recovery/test/task_contract_test.go new file mode 100644 index 00000000..79ace984 --- /dev/null +++ b/internal/recovery/test/task_contract_test.go @@ -0,0 +1,87 @@ +package test + +// internal/recovery 的黑盒判据:SaveTask/LoadTask/SaveResult/LoadResult, +// 输入 → 输出(文件往返)。不读实现、不碰内部字段。 + +import ( + "path/filepath" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/recovery" +) + +// TestTaskRoundTrip:SaveTask → LoadTask 得回全部字段。 +func TestTaskRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "task.json") + in := &recovery.Task{ + Attempt: 3, MaxAttempts: 5, AttemptTimeout: "120s", + TriggerPrompt: "检查网络", PluginList: []string{"cmd", "files"}, + } + if err := recovery.SaveTask(path, in); err != nil { + t.Fatalf("SaveTask: %v", err) + } + out, err := recovery.LoadTask(path) + if err != nil { + t.Fatalf("LoadTask: %v", err) + } + if out.Attempt != 3 || out.MaxAttempts != 5 || out.AttemptTimeout != "120s" { + t.Fatalf("★ 往返后字段丢失:%+v", out) + } + if len(out.PluginList) != 2 || out.PluginList[0] != "cmd" { + t.Fatalf("★ PluginList 丢失:%v", out.PluginList) + } + if got := out.Plugins(); len(got) != 2 { + t.Fatalf("★ Plugins() = %v", got) + } +} + +// TestTaskPathsAreStable:TaskPath/ResultPath 由 dataDir 决定且互不相同。 +func TestTaskPathsAreStable(t *testing.T) { + dir := "/data/contract" + a := recovery.TaskPath(dir) + b := recovery.TaskPath(dir) + if a != b || a == "" { + t.Fatalf("★ TaskPath 不稳定:%q vs %q", a, b) + } + if recovery.ResultPath(dir) == a { + t.Fatal("★ TaskPath 与 ResultPath 相同") + } +} + +// TestResultRoundTrip:SaveResult → LoadResult 得回结论字段。 +func TestResultRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "result.json") + in := &recovery.Result{ + Attempt: 2, Success: true, Cause: "network", NetworkOK: true, + RestoredNet: []string{"/etc/resolv.conf"}, ConfigRestored: true, + QuickChatOK: true, Steps: []string{"check", "restore"}, + Message: "已恢复", Timestamp: time.Now(), + } + if err := recovery.SaveResult(path, in); err != nil { + t.Fatalf("SaveResult: %v", err) + } + out, err := recovery.LoadResult(path) + if err != nil { + t.Fatalf("LoadResult: %v", err) + } + if !out.Success || out.Cause != "network" || !out.ConfigRestored || !out.QuickChatOK { + t.Fatalf("★ 往返后结论丢失:%+v", out) + } +} + +// TestLoadTaskMissingFileReportsError:读不存在的文件必须报错,不静默给空值。 +func TestLoadTaskMissingFileReportsError(t *testing.T) { + if _, err := recovery.LoadTask(filepath.Join(t.TempDir(), "nope.json")); err == nil { + t.Fatal("★ 读不存在的任务文件却返回成功") + } +} + +// TestSortSteps:步骤排序输出可用(不钉死具体顺序以外的内部结构)。 +func TestSortSteps(t *testing.T) { + in := []string{"c", "a", "b"} + out := recovery.SortSteps(in) + if len(out) != 3 { + t.Fatalf("★ SortSteps = %v", out) + } +} diff --git a/internal/systemd/test/sync_contract_test.go b/internal/systemd/test/sync_contract_test.go new file mode 100644 index 00000000..7bbdcbf3 --- /dev/null +++ b/internal/systemd/test/sync_contract_test.go @@ -0,0 +1,86 @@ +package test + +// internal/systemd 的黑盒判据:State/NewMigratePlan 的导出方法,输入 → 输出。 +// 不读单元文件、不执行脚本、不碰系统状态。 + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/systemd" +) + +// TestStateInSync:相同路径(含 ./ 与尾部斜杠)判一致;不同判不一致。 +func TestStateInSync(t *testing.T) { + cases := []struct { + name string + r, c string + want bool + }{ + {"完全相同", "/data/home", "/data/home", true}, + {"路径等价", "/data/home/", "/data/home/./", true}, + {"空运行期", "", "/data/home", true}, + {"空配置", "/data/home", "", true}, + {"真不一致", "/data/a", "/data/b", false}, + } + for _, c := range cases { + s := systemd.State{RuntimeDataDir: c.r, ConfigDataDir: c.c} + if got := s.InSync(); got != c.want { + t.Errorf("★ %s: InSync=%v,期望 %v", c.name, got, c.want) + } + } +} + +// TestStateDescribe:一致时诊断为空;不一致时给出两个路径与后果。 +func TestStateDescribe(t *testing.T) { + ok := systemd.State{RuntimeDataDir: "/d", ConfigDataDir: "/d"} + if d := ok.Describe(); d != "" { + t.Fatalf("★ 一致时 Describe 应为空,实际 %q", d) + } + bad := systemd.State{RuntimeDataDir: "/data/a", ConfigDataDir: "/data/b"} + d := bad.Describe() + if !strings.Contains(d, "/data/a") || !strings.Contains(d, "/data/b") { + t.Fatalf("★ Describe 缺两个路径:%q", d) + } + if !strings.Contains(d, "数据分裂") { + t.Fatalf("★ Describe 没说明后果:%q", d) + } +} + +// TestSyncCommand:不一致时给出可执行的同步命令。 +func TestSyncCommand(t *testing.T) { + s := systemd.State{RuntimeDataDir: "/data/a", ConfigDataDir: "/data/b"} + cmd := s.SyncCommand("/data/a") + if cmd == "" { + t.Fatal("★ 不一致时 SyncCommand 为空") + } + if !strings.Contains(cmd, "/data/a") { + t.Fatalf("★ SyncCommand 不含目标路径:%q", cmd) + } +} + +// TestNewMigratePlanNormalizesPaths:构造计划时路径被 Clean,脚本含新旧路径。 +func TestNewMigratePlanNormalizesPaths(t *testing.T) { + old := "/data/old/./" + newDir := "/data/new//" + p := systemd.NewMigratePlan(old, newDir) + if p.OldDir != filepath.Clean(old) || p.NewDir != filepath.Clean(newDir) { + t.Fatalf("★ 路径未 Clean:old=%q new=%q", p.OldDir, p.NewDir) + } + script := p.Script() + if !strings.Contains(script, p.OldDir) || !strings.Contains(script, p.NewDir) { + t.Fatal("★ 迁移脚本没内联新旧路径") + } + if !strings.HasPrefix(script, "#!/usr/bin/env bash") { + t.Fatal("★ 脚本缺 shebang —— 不能直接执行") + } +} + +// TestRestartCommand:重启命令必须包含给定地址。 +func TestRestartCommand(t *testing.T) { + cmd := systemd.RestartCommand("127.0.0.1:8080", "test-key") + if !strings.Contains(cmd, "127.0.0.1:8080") { + t.Fatalf("★ RestartCommand 不含地址:%q", cmd) + } +}