test: 按模块导出接口重写黑盒判据(批次 5:vector / systemd / recovery)

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

View File

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

View File

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

View File

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