Files
HomeAgent/internal/kstate/test/syncbus_contract_test.go
JianFeeeee 8dff678640 test: 按模块导出接口重写黑盒判据(批次 2:security/events/safeclose/kstate)
延续批次 1 的形态:只调模块**本来就有**的导出接口,输入 → 可观察输出。
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 24 条判据,每模块 1 个文件。

## 各模块的接口清单与判据

### internal/security —— NewCodec / Seal / Open / Domain / Enabled

    TestCodecSealOpenRoundTrip           Seal → Open 得回原文;Seal 不返回原文
    TestCodecDomainSeparation            不同 domain 的密文互不通用
    TestCodecWrongKeyCannotOpen          换钥后旧密文不可读
    TestCodecEnabledAndDomain            元信息输出;nil Codec 不报 Enabled
    TestCodecOldKeysStillDecrypt         NewCodecWithOldKeys 轮换后旧密文可读
    TestCodecOpenLegacyPlaintextPassthrough  旧明文原样透传
    TestCodecOpenRejectsCorruptedCiphertext  带密文前缀但内容坏掉必须报错

**判据写错过一次并修正**:第一版把 `Open("not-a-cipher")` 也要求报错,
实测红。查实现(codec.go:138)确认那是**明确的兼容契约** —— 加密是后来加的,
库里存在未加密的历史值,判成错误会让整库读不出来。改成两条:
旧明文透传 / 损坏密文报错。把差异显式写下来。

### internal/events —— NewBus / Subscribe / Publish / SetGuard

    TestPublishDeliversPayload          订阅后发布,handler 收到原 Payload
    TestUnsubscribeStopsDelivery        退订后不再收到
    TestOnlyMatchingTypeReceives        不同事件类型不串台
    TestPublisherIdentity               空 Name = 内核,非空 = 插件
    TestSetGuardRoundTrip               SetGuard → Guard 拿回同一守卫

`Guard` stub 按真实接口签名实现(AllowPublish 返回 `(bool, string)`、
Scrub* 返回 map),不是猜的。

### internal/safeclose —— Safe / SafeFunc

    TestSafeFuncIsIdempotent            连调 5 次不 panic,channel 确已关闭
    TestSafeReturnsFiredOnce            同一把 Once 只有第一次返回 true
    TestSafeRejectsNilInputs            nil once / nil chan 安全返回 false
    TestConcurrentCloseIsSafe           8 goroutine 并发关同一 channel

不用任何 `NewOnceForTest` 之类的测试钩子 —— `sync.Once` 由调用方自己造,
这正是模块本来的公开用法。

### internal/kstate —— NewSyncBus / Mount / Get / State

    TestSyncBusReadyLifecycle           MarkDeclared/MarkReady → IsReady/AllReady/Missing
    TestSyncBusReadyInOrderIsStable     就绪顺序可复现,重复标记不重复记录
    TestSyncBusKnownVsReady             声明过 ≠ 已就绪
    TestSyncBusKernelComplete           内核完成标记可观察
    TestSyncBusSnapshotIsCopy           外部改快照不影响总线
    TestMountGet                        Mount → Get 拿到同一份状态
    TestStateCloseAllInvokesClosers     AddCloser 注册的清理被调用

不用 `MountForTest`(上一轮已删除):直接用 `Mount` + `Get` 断言输出。

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

    security  Seal 直接返回原文        → ★ Seal 返回了原文 —— 没有加密
    events    退订函数变空操作         → ★ 退订后仍收到 1 次
    kstate    MarkReady 不记 order    → ★ ReadyInOrder = [],期望 [a b]
    safeclose Safe 不用 sync.Once     → panic: close of closed channel

前两条第一版变异因文本形态不匹配**没有变红**,重做后确认判据有效 ——
空变异不算变异验证,这一点按纪律写清楚。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    13 个测试包 / 100 包无测试 / 0 FAIL
    4 个新包 -race                            全绿
    源码目录 _test.go                         0
2026-11-09 12:21:13 +08:00

111 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
// internal/kstate 的黑盒判据:只用 NewSyncBus / Mount / Get / State 的导出方法,
// 输入 → 可观察输出。不读源码、不用 MountForTest。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
)
// TestSyncBusReadyLifecycle:声明 → 就绪 → IsReady/Snapshot/AllReady/Missing。
func TestSyncBusReadyLifecycle(t *testing.T) {
b := kstate.NewSyncBus()
b.MarkDeclared([]string{"db", "http", "memory"})
b.MarkReady("db")
b.MarkReady("http")
if !b.IsReady("db") || !b.IsReady("http") {
t.Fatal("★ 已 MarkReady 的设施报告未就绪")
}
if b.IsReady("memory") {
t.Fatal("★ 未就绪的设施报告就绪")
}
if b.AllReady([]string{"db", "http"}) != true {
t.Fatal("★ AllReady 对全部就绪的集合应返回 true")
}
if b.AllReady([]string{"db", "memory"}) {
t.Fatal("★ AllReady 对含未就绪项的集合返回了 true")
}
if missing := b.Missing([]string{"db", "http", "memory"}); len(missing) != 1 || missing[0] != "memory" {
t.Fatalf("★ Missing = %v,期望 [memory]", missing)
}
}
// TestSyncBusReadyInOrderIsStable:就绪顺序可复现,重复标记不重复记录。
func TestSyncBusReadyInOrderIsStable(t *testing.T) {
b := kstate.NewSyncBus()
b.MarkReady("a")
b.MarkReady("a")
b.MarkReady("b")
got := b.ReadyInOrder()
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("★ ReadyInOrder = %v,期望 [a b]", got)
}
}
// TestSyncBusKnownVsReady:声明过但未就绪 ≠ 未声明。
func TestSyncBusKnownVsReady(t *testing.T) {
b := kstate.NewSyncBus()
b.MarkDeclared([]string{"db"})
if !b.Known("db") {
t.Fatal("★ 声明过的设施 Known=false")
}
if b.IsReady("db") {
t.Fatal("★ 只声明未就绪却报告 IsReady")
}
if b.Known("ghost") {
t.Fatal("★ 未声明的设施 Known=true")
}
}
// TestSyncBusKernelComplete:内核完成标记是可观察输出。
func TestSyncBusKernelComplete(t *testing.T) {
b := kstate.NewSyncBus()
if b.KernelComplete() {
t.Fatal("★ 初始就报告内核已完成")
}
b.MarkKernelComplete()
if !b.KernelComplete() {
t.Fatal("★ MarkKernelComplete 后仍报告未完成")
}
}
// TestSyncBusSnapshotIsCopy:改快照不得影响总线。
func TestSyncBusSnapshotIsCopy(t *testing.T) {
b := kstate.NewSyncBus()
b.MarkReady("db")
snap := b.Snapshot()
if !snap["db"] {
t.Fatal("★ Snapshot 缺 db")
}
snap["db"] = false
snap["ghost"] = true
if !b.IsReady("db") {
t.Fatal("★ 外部改快照影响了总线内部")
}
if b.IsReady("ghost") {
t.Fatal("★ 外部往快照塞值影响了总线内部")
}
}
// TestMountGet:Mount → Get 拿到同一份状态(模块最核心的输入→输出)。
func TestMountGet(t *testing.T) {
s := &kstate.State{DataDir: "/contract/tmp"}
kstate.Mount(s)
if got := kstate.Get(); got != s {
t.Fatalf("★ Get() = %v,期望刚 Mount 的那份", got)
}
}
// TestStateCloseAllInvokesClosers:AddCloser 注册的清理必须被调用一次。
func TestStateCloseAllInvokesClosers(t *testing.T) {
s := kstate.New()
n := 0
s.AddCloser(func() { n++ })
s.CloseAll()
if n != 1 {
t.Fatalf("★ CloseAll 后 closer 调用 %d 次,期望 1", n)
}
}