diff --git a/internal/devicebridge/client/test/result_contract_test.go b/internal/devicebridge/client/test/result_contract_test.go new file mode 100644 index 00000000..38c601d5 --- /dev/null +++ b/internal/devicebridge/client/test/result_contract_test.go @@ -0,0 +1,36 @@ +package test + +// internal/devicebridge/client 的黑盒判据:BaseResult 输入 → map 输出。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/devicebridge/client" +) + +// TestBaseResultAlwaysCarriesIdentity:op/req_id/status 始终存在。 +func TestBaseResultAlwaysCarriesIdentity(t *testing.T) { + r := client.BaseResult("req-1", "ok", "", "") + if r["op"] != "cmd_result" || r["req_id"] != "req-1" || r["status"] != "ok" { + t.Fatalf("★ 身份字段缺失:%v", r) + } +} + +// TestBaseResultOmitsEmptyOptionalFields:空 output/error 不进结果。 +func TestBaseResultOmitsEmptyOptionalFields(t *testing.T) { + r := client.BaseResult("r", "ok", "", "") + if _, ok := r["output"]; ok { + t.Fatal("★ 空 output 被写进结果") + } + if _, ok := r["error"]; ok { + t.Fatal("★ 空 error 被写进结果") + } +} + +// TestBaseResultCarriesOutputAndError:非空时分别落到对应键。 +func TestBaseResultCarriesOutputAndError(t *testing.T) { + r := client.BaseResult("r", "error", "stdout-line", "boom") + if r["output"] != "stdout-line" || r["error"] != "boom" { + t.Fatalf("★ 可选字段不符:%v", r) + } +} diff --git a/internal/kernelboot/test/graph_contract_test.go b/internal/kernelboot/test/graph_contract_test.go new file mode 100644 index 00000000..64d9d544 --- /dev/null +++ b/internal/kernelboot/test/graph_contract_test.go @@ -0,0 +1,95 @@ +package test + +// internal/kernelboot 的黑盒判据:EncodeGraph → DecodeGraph / GraphDigest / +// CheckAgainstBinary 的输入 → 输出。不碰内部 payload 布局。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/kernelboot" +) + +var edges = []kernelboot.GraphEdge{ + {Name: "security", Provides: []string{"sec"}, Requires: nil, Layer: 1}, + {Name: "agent", Provides: []string{"agent"}, Requires: []string{"sec"}, Layer: 2}, +} + +// TestGraphEncodeDecodeRoundTrip:编码后能解回同样模块与层号。 +func TestGraphEncodeDecodeRoundTrip(t *testing.T) { + data := kernelboot.EncodeGraph(edges) + gf, err := kernelboot.DecodeGraph(data) + if err != nil { + t.Fatalf("DecodeGraph: %v", err) + } + if gf == nil || len(gf.Modules) != len(edges) { + t.Fatalf("★ 模块数不符:%+v", gf) + } + for i, m := range gf.Modules { + if m.Name != edges[i].Name || m.Layer != edges[i].Layer { + t.Fatalf("★ 第 %d 个模块不符:%+v", i, m) + } + } +} + +// TestGraphDigestIsDeterministic:同一份声明 digest 稳定;换声明则变。 +func TestGraphDigestIsDeterministic(t *testing.T) { + a := kernelboot.GraphDigest(edges) + b := kernelboot.GraphDigest(edges) + if a == "" || a != b { + t.Fatalf("★ digest 不稳定:%q vs %q", a, b) + } + other := []kernelboot.GraphEdge{{Name: "x", Provides: []string{"y"}}} + if c := kernelboot.GraphDigest(other); c == a { + t.Fatal("★ 不同声明得到同一 digest") + } +} + +// TestDecodeGraphRejectsCorruption:位翻转 / 截断必须报错。 +func TestDecodeGraphRejectsCorruption(t *testing.T) { + data := kernelboot.EncodeGraph(edges) + // 只翻转**数据区末尾**的字节(不动 magic / 长度), + // 这样唯一能抓到它的就是 hash 自校验。翻 magic 的话即使 + // 跳过 hash 也会因格式标识报错,判据会假绿。 + flipped := append([]byte(nil), data...) + pos := len(flipped) - 32 - 1 // 尾部 hash 之前最后一个 payload 字节 + flipped[pos] ^= 0x01 + if _, err := kernelboot.DecodeGraph(flipped); err == nil { + t.Fatal("★ 数据区位翻转的图被接受(hash 自校验失效)") + } + if _, err := kernelboot.DecodeGraph(data[:len(data)/2]); err == nil { + t.Fatal("★ 截断的图被接受") + } +} + +// TestCheckAgainstBinary:空内嵌 hash 放行;不匹配报错;匹配通过。 +func TestCheckAgainstBinary(t *testing.T) { + gf, err := kernelboot.DecodeGraph(kernelboot.EncodeGraph(edges)) + if err != nil { + t.Fatal(err) + } + if err := kernelboot.CheckAgainstBinary(gf, ""); err != nil { + t.Fatalf("★ dev 环境(空 hash)被拒:%v", err) + } + if err := kernelboot.CheckAgainstBinary(gf, "deadbeef"); err == nil { + t.Fatal("★ 不配套的 hash 被放行") + } + if err := kernelboot.CheckAgainstBinary(gf, gf.Digest); err != nil { + t.Fatalf("★ 配套图被拒:%v", err) + } +} + +// TestFuncModuleExposesSpec:FuncModule 对外暴露 Name/Provides/Requires。 +func TestFuncModuleExposesSpec(t *testing.T) { + m := kernelboot.FuncModule(kernelboot.FuncSpec{ + Name: "contract", Provides: []string{"a"}, Requires: []string{"b"}, + }) + if m.Name() != "contract" { + t.Fatalf("★ Name = %q", m.Name()) + } + if len(m.Provides()) != 1 || m.Provides()[0] != "a" { + t.Fatalf("★ Provides = %v", m.Provides()) + } + if len(m.Requires()) != 1 || m.Requires()[0] != "b" { + t.Fatalf("★ Requires = %v", m.Requires()) + } +} diff --git a/internal/update/test/semver_contract_test.go b/internal/update/test/semver_contract_test.go new file mode 100644 index 00000000..cd4067ea --- /dev/null +++ b/internal/update/test/semver_contract_test.go @@ -0,0 +1,119 @@ +package test + +// internal/update 的黑盒判据:纯函数的输入 → 输出。 +// 不读源码、不跑外部命令、不碰内部正则表。 + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/update" +) + +// TestParseSemver:合法三段式(可带 v)解析;非纯版本返回 nil。 +func TestParseSemver(t *testing.T) { + cases := []struct { + in string + wantNil bool + maj, min, pat int + }{ + {"1.4.0", false, 1, 4, 0}, + {"v2.10.3", false, 2, 10, 3}, + {"1.4.0dev", true, 0, 0, 0}, + {"1.4", true, 0, 0, 0}, + {"abc", true, 0, 0, 0}, + {"", true, 0, 0, 0}, + } + for _, c := range cases { + got := update.ParseSemver(c.in) + if c.wantNil { + if got != nil { + t.Errorf("★ ParseSemver(%q) = %+v,期望 nil", c.in, got) + } + continue + } + if got == nil || got.Major != c.maj || got.Minor != c.min || got.Patch != c.pat { + t.Errorf("★ ParseSemver(%q) = %+v", c.in, got) + } + } +} + +// TestCmpSemver:大小与相等;nil 返回 0(不可比)。 +func TestCmpSemver(t *testing.T) { + a := update.ParseSemver("1.4.0") + b := update.ParseSemver("1.4.1") + c := update.ParseSemver("1.4.0") + if update.CmpSemver(a, b) >= 0 || update.CmpSemver(b, a) <= 0 { + t.Fatal("★ 版本大小比较错误") + } + if update.CmpSemver(a, c) != 0 { + t.Fatal("★ 同版本未判等") + } + if update.CmpSemver(nil, a) != 0 || update.CmpSemver(a, nil) != 0 { + t.Fatal("★ nil 未返回 0(不可比)") + } +} + +// TestNewer:只认纯三段式;dev 后缀不误报。 +func TestNewer(t *testing.T) { + if !update.Newer("1.5.0", "1.4.0") { + t.Fatal("★ 新版本未判新") + } + if update.Newer("1.4.0", "1.4.0") { + t.Fatal("★ 相同版本判新") + } + if update.Newer("1.4.0dev", "1.4.0") { + t.Fatal("★ dev 后缀被误判为新版本") + } +} + +// TestCompareVersions:升级 / 降级 / 完全相同 / 同版本不同 commit。 +func TestCompareVersions(t *testing.T) { + cur := &update.BinVersion{Version: "1.4.0", Commit: "aaaaaaaa"} + cases := []struct { + nv *update.BinVersion + contains string + }{ + {&update.BinVersion{Version: "1.5.0", Commit: "bbbbbbbb"}, "可升级"}, + {&update.BinVersion{Version: "1.3.0", Commit: "bbbbbbbb"}, "降级"}, + {&update.BinVersion{Version: "1.4.0", Commit: "aaaaaaaa"}, "完全相同"}, + {&update.BinVersion{Version: "1.4.0", Commit: "bbbbbbbb"}, "commit 不同"}, + } + for _, c := range cases { + got := update.CompareVersions(cur, c.nv) + if !strings.Contains(got, c.contains) { + t.Errorf("★ CompareVersions = %q,期望含 %q", got, c.contains) + } + } + if got := update.CompareVersions(nil, cur); !strings.Contains(got, "无法读取") { + t.Fatalf("★ nil 当前版本的输出不可诊断:%q", got) + } +} + +// TestDeltaAssetName:产物名内联版本/平台/来源 commit。 +func TestDeltaAssetName(t *testing.T) { + got := update.DeltaAssetName("1.4.0", "linux", "amd64", "abcdef123456") + for _, want := range []string{"1.4.0", "linux", "amd64", "abcdef12", ".delta"} { + if !strings.Contains(got, want) { + t.Errorf("★ DeltaAssetName = %q,缺 %q", got, want) + } + } +} + +// TestShortCommit:长 commit 截 8 位;空/unknown 原样。 +func TestShortCommit(t *testing.T) { + if got := update.ShortCommit("abcdef123456"); got != "abcdef12" { + t.Fatalf("★ ShortCommit = %q", got) + } + if got := update.ShortCommit("unknown"); got != "unknown" { + t.Fatalf("★ unknown 未保留:%q", got) + } +} + +// TestBrokenDirPath:输出落在 dataDir 下且含时间戳。 +func TestBrokenDirPath(t *testing.T) { + got := update.BrokenDirPath("/data/home", "20261109-120000") + if !strings.HasPrefix(got, "/data/home") || !strings.Contains(got, "20261109-120000") { + t.Fatalf("★ BrokenDirPath = %q", got) + } +}