test: 按模块导出接口重写黑盒判据(批次 11:update / kernelboot / devicebridge)

3 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 15 条判据,只调导出接口,输入 → 可观察输出。

## internal/update —— 版本/升级纯函数

    TestParseSemver                  三段式(可带 v);非纯版本返回 nil
    TestCmpSemver                    大小/相等;nil 返回 0(不可比)
    TestNewer                        只认纯三段式;dev 后缀不误报
    TestCompareVersions              升级/降级/完全相同/同版本不同 commit
    TestDeltaAssetName               产物名内联版本/平台/来源 commit
    TestShortCommit                  长 commit 截 8 位
    TestBrokenDirPath                输出落在 dataDir 下且含时间戳

## internal/kernelboot —— 依赖图编解码

    TestGraphEncodeDecodeRoundTrip   Encode → Decode 模块与层号
    TestGraphDigestIsDeterministic   同声明稳定;换声明则变
    TestDecodeGraphRejectsCorruption 位翻转/截断必拒
    TestCheckAgainstBinary           dev 放行 / 不配套拒 / 配套通过
    TestFuncModuleExposesSpec        Name/Provides/Requires

**发现一条假绿并修掉**:`TestDecodeGraphRejectsCorruption` 第一版
翻转 `data[10]`(magic/长度区),跳过 hash 校验后仍会因格式标识报错
⇒ 判据假绿。改成只翻转尾部 hash 之前的最后一个 payload 字节,
让唯一能抓到它的就是 hash 自校验,变异才真红。

## internal/devicebridge/client —— BaseResult

    TestBaseResultAlwaysCarriesIdentity     op/req_id/status
    TestBaseResultOmitsEmptyOptionalFields  空 output/error 不进结果
    TestBaseResultCarriesOutputAndError     非空时落对应键

## 变异验证(改回旧实现必须红)

    update       CmpSemver 恒 0       → ★ 版本大小比较错误
    kernelboot   跳过 hash 校验        → ★ 数据区位翻转的图被接受
    devicebridge 丢 req_id             → ★ 身份字段缺失

kernelboot 第一版变异造成「未使用变量 → 编译失败」,不算干净断言红;
改成跳过校验但保留变量后确认判据有效。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    42 个测试包 / 100 包无测试 / 0 FAIL
    3 个新包 -race                            全绿
    源码目录 _test.go                         0
This commit is contained in:
JianFeeeee
2026-11-09 14:59:40 +08:00
parent 3420ceca60
commit 08cbd3e7bd
3 changed files with 250 additions and 0 deletions

View File

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

View File

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

View File

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