test: 按模块导出接口重写黑盒判据(批次 7:agent-io / system / ipc / supervisor)

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

## internal/agent/io —— OutputCapability

    TestSupportsSingleAndCombinedFlags  位标志的「任一位命中」语义
    TestCapabilityStringListsFlags      String 列出已置位能力名
    TestZeroCapabilityString            零能力不 panic

**判据写错过一次**:第一版按「全部命中」写 Supports,实测红。
查实现(`c&cap != 0`)确认是任一位命中,按真实契约修正。

## internal/system —— 路径判定 / 写前留档

    TestIsProtectedPathExplicit         显式前缀集内/外的判定
    TestArchiveBeforeWrite              受保护路径覆盖前留档(走导出的
                                        SetProtectedPaths 配置入口)
    TestNetworkBaselinePathIsStable     路径输出稳定
    TestLoadBaselineMissingReportsError 读不存在的基线报错

## internal/ipc —— 真实 unix socket 往返

    TestPingReturnsServerStatus         Server 状态原样到达 Client
    TestPingWithoutServerReportsError   无服务端时报错

用临时目录下真 socket,不 mock 传输层。

## internal/supervisor —— Daemon 登记

    TestFreshDaemonHasNoAgents          新 daemon 无 agent
    TestRegisterThenList                RegisterAgent → List/Get 反映该 agent
    TestGetUnknownAgentReportsError     未注册 agent 报错
    TestSDKAdapterSeesSameAgents        适配层看到同一份登记

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

    agent/io   Supports 恒 false      → ★ 已置位的能力报告不支持
    system     路径判定恒 false       → ★ 前缀集内的路径没判受保护
    ipc        Ping 丢 Tools 字段     → ★ Ping 拿到的状态不符
    supervisor ListAgents 恒空        → ★ ListAgents = [],期望 1 个

system / ipc 第一版变异改错了位置(空变异),重做后确认判据有效。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    28 个测试包 / 100 包无测试 / 0 FAIL
    4 个新包 -race                            全绿
    源码目录 _test.go                         0
This commit is contained in:
JianFeeeee
2026-11-09 13:38:31 +08:00
parent 8814ccdef6
commit 379300d89c
4 changed files with 226 additions and 0 deletions

View File

@ -0,0 +1,46 @@
package test
// internal/agent/io 的黑盒判据:OutputCapability 的导出方法,输入 → 输出。
// 不构造设备、不碰内部注册表。
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// TestSupportsSingleAndCombinedFlags:位标志的 Supports 语义。
func TestSupportsSingleAndCombinedFlags(t *testing.T) {
c := io.CapText | io.CapAudio
if !c.Supports(io.CapText) || !c.Supports(io.CapAudio) {
t.Fatal("★ 已置位的能力报告不支持")
}
if c.Supports(io.CapImage) {
t.Fatal("★ 未置位的能力报告支持")
}
// Supports 的语义是「任一位命中」,不是「全部命中」。
if !io.CapText.Supports(io.CapText | io.CapImage) {
t.Fatal("★ 任一位命中应报告支持")
}
if io.CapText.Supports(io.CapImage) {
t.Fatal("★ 完全不相交却报告支持")
}
}
// TestCapabilityStringListsFlags:String 输出列出已置位的能力名。
func TestCapabilityStringListsFlags(t *testing.T) {
c := io.CapText | io.CapImage
s := c.String()
if !strings.Contains(s, "text") || !strings.Contains(s, "image") {
t.Fatalf("★ String() = %q,缺已置位能力名", s)
}
if strings.Contains(s, "audio") {
t.Fatalf("★ String() = %q,含未置位能力", s)
}
}
// TestZeroCapabilityString:零能力字符串可用(不 panic)。
func TestZeroCapabilityString(t *testing.T) {
_ = io.OutputCapability(0).String()
}

View File

@ -0,0 +1,43 @@
package test
// internal/ipc 的黑盒判据:NewServer/Start → NewClient/Ping → Status 输出。
// 用真实临时目录下的 socket,不 mock 传输层。
import (
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/ipc"
)
// TestPingReturnsServerStatus:Server 提供的状态必须原样到达 Client。
func TestPingReturnsServerStatus(t *testing.T) {
dataDir := t.TempDir()
pid := 4242
srv := ipc.NewServer(dataDir, func() *ipc.Status {
return &ipc.Status{PID: pid, Boot: "normal", UptimeSec: 7, Tools: 12}
})
if err := srv.Start(); err != nil {
t.Fatalf("Server.Start: %v", err)
}
defer srv.Stop()
c := ipc.NewClient(dataDir)
defer c.Close()
st, err := c.Ping(3 * time.Second)
if err != nil {
t.Fatalf("Ping: %v", err)
}
if st.PID != pid || st.Boot != "normal" || st.Tools != 12 {
t.Fatalf("★ Ping 拿到的状态不符:%+v", st)
}
}
// TestPingWithoutServerReportsError:没有服务端时 Ping 必须报错,不静默给空状态。
func TestPingWithoutServerReportsError(t *testing.T) {
c := ipc.NewClient(t.TempDir())
defer c.Close()
if _, err := c.Ping(300 * time.Millisecond); err == nil {
t.Fatal("★ 没有服务端却 Ping 成功")
}
}

View File

@ -0,0 +1,63 @@
package test
// internal/supervisor 的黑盒判据:New(cfg) → RegisterAgent → 状态输出。
// 不启动后台循环、不碰 d.agents。
import (
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/supervisor"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
func newDaemon() *supervisor.Daemon {
return supervisor.New(&types.Config{Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
}})
}
// TestFreshDaemonHasNoAgents:新 daemon 的 ListAgents 为空。
func TestFreshDaemonHasNoAgents(t *testing.T) {
d := newDaemon()
if got := d.ListAgents(); len(got) != 0 {
t.Fatalf("★ 新 daemon 的 ListAgents = %v,期望空", got)
}
}
// TestRegisterThenList:注册后 ListAgents 与 GetAgentStatus 反映该 agent。
func TestRegisterThenList(t *testing.T) {
d := newDaemon()
id := types.AgentID("contract-agent")
d.RegisterAgent(id)
list := d.ListAgents()
if len(list) != 1 {
t.Fatalf("★ ListAgents = %v,期望 1 个", list)
}
st, err := d.GetAgentStatus(id)
if err != nil {
t.Fatalf("GetAgentStatus: %v", err)
}
if st == nil {
t.Fatal("★ 已注册 agent 的状态为 nil")
}
}
// TestGetUnknownAgentReportsError:查未注册 agent 必须报错。
func TestGetUnknownAgentReportsError(t *testing.T) {
d := newDaemon()
if _, err := d.GetAgentStatus(types.AgentID("ghost")); err == nil {
t.Fatal("★ 查未注册 agent 却返回成功")
}
}
// TestSDKAdapterSeesSameAgents:适配层与 Daemon 看到同一份登记。
func TestSDKAdapterSeesSameAgents(t *testing.T) {
d := newDaemon()
d.RegisterAgent(types.AgentID("a1"))
ad := supervisor.NewSDKAdapter(d)
if got := ad.ListAgents(); len(got) != 1 {
t.Fatalf("★ SDKAdapter.ListAgents = %v,期望 1 个", got)
}
}

View File

@ -0,0 +1,74 @@
package test
// internal/system 的黑盒判据:导出路径判定函数,输入 → 输出。
// 不改全局状态、不碰系统文件。
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/system"
)
// TestIsProtectedPathExplicit:显式前缀集内的路径判受保护,集外的不判。
func TestIsProtectedPathExplicit(t *testing.T) {
dir := t.TempDir()
inside := filepath.Join(dir, "protected", "file.txt")
outside := filepath.Join(dir, "other", "file.txt")
prefixes := []string{filepath.Join(dir, "protected")}
if !system.IsProtectedPathExplicit(prefixes, inside) {
t.Fatal("★ 前缀集内的路径没判受保护")
}
if system.IsProtectedPathExplicit(prefixes, outside) {
t.Fatal("★ 前缀集外的路径被判受保护")
}
if system.IsProtectedPathExplicit(nil, inside) {
t.Fatal("★ 空前缀集却判受保护")
}
}
// TestArchiveBeforeWrite:覆盖前留档,返回 archived=true 且原文可读。
func TestArchiveBeforeWrite(t *testing.T) {
base := t.TempDir()
// 用导出的配置入口把该目录设为受保护范围(不改全局默认值的语义之外)
protected := filepath.Join(base, "etc")
system.SetProtectedPaths([]string{protected + string(os.PathSeparator)})
t.Cleanup(func() { system.SetProtectedPaths(nil) })
if err := os.MkdirAll(protected, 0755); err != nil {
t.Fatal(err)
}
target := filepath.Join(protected, "config.txt")
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
t.Fatal(err)
}
archived, err := system.ArchiveBeforeWrite(base, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite: %v", err)
}
if !archived {
t.Fatal("★ 已存在的文件却没留档")
}
// 留档目录是导出的 FileBaselineDir
baseline := system.FileBaselineDir(base)
if baseline == "" {
t.Fatal("★ FileBaselineDir 为空")
}
}
// TestNetworkBaselinePathIsStable:路径输出稳定且落在 dataDir 下。
func TestNetworkBaselinePathIsStable(t *testing.T) {
dir := t.TempDir()
a := system.NetworkBaselinePath(dir)
b := system.NetworkBaselinePath(dir)
if a == "" || a != b {
t.Fatalf("★ NetworkBaselinePath 不稳定:%q vs %q", a, b)
}
}
// TestLoadBaselineMissingReportsError:读不存在的基线必须报错。
func TestLoadBaselineMissingReportsError(t *testing.T) {
if _, err := system.LoadNetworkBaseline(t.TempDir()); err == nil {
t.Fatal("★ 读不存在的基线却返回成功")
}
}