diff --git a/internal/agent/io/test/channel_contract_test.go b/internal/agent/io/test/channel_contract_test.go new file mode 100644 index 00000000..47eab5e5 --- /dev/null +++ b/internal/agent/io/test/channel_contract_test.go @@ -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() +} diff --git a/internal/ipc/test/ping_contract_test.go b/internal/ipc/test/ping_contract_test.go new file mode 100644 index 00000000..1a094324 --- /dev/null +++ b/internal/ipc/test/ping_contract_test.go @@ -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 成功") + } +} diff --git a/internal/supervisor/test/daemon_contract_test.go b/internal/supervisor/test/daemon_contract_test.go new file mode 100644 index 00000000..06513662 --- /dev/null +++ b/internal/supervisor/test/daemon_contract_test.go @@ -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) + } +} diff --git a/internal/system/test/guard_contract_test.go b/internal/system/test/guard_contract_test.go new file mode 100644 index 00000000..25b77390 --- /dev/null +++ b/internal/system/test/guard_contract_test.go @@ -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("★ 读不存在的基线却返回成功") + } +}