test: io/lua/system/tracker 改为模块职责级黑盒契约

## internal/agent/io

    原:只有 OutputCapability 位标志 + String
    现:
      InjectText → 输入事件(source/output_channel/request_id)
      InjectInputTo → 显式输出通道被保留
      InjectTextSync → 响应回程交到调用方
      RegisterDevice → GetDevice → StartAll/StopAll 真实调用设备
      重复注册被拒;StartAll 传播设备启动失败
    能力标志语义保留(任一位命中)
    变异:允许重复注册 / 吞掉启动错误 → 均断言红

## internal/lua

    新增:
      transform_request 真的执行 Lua body(原来只验证加载与未加载报错)
      endpoint/headers 静态声明经公开 API 读回
    变异:transform_request 直接返回入参 → 断言红

## internal/system

    原:只断言 archived=true 与 baseline 目录非空
    现:留档 → 覆盖 → RestoreFileFromBaseline 内容还原为原文
         非受保护路径与新建文件不留档
         内容未变时重复留档不产生新档案
    变异:留档写空内容 / 去掉幂等判断 → 均断言红

## internal/tracker

    原:无文件变更时断言无 changeset,rollback 起点为空
    现:真实 upper 目录写入 → PostAction 记录 ChangeFileCreated
         修改原文 → RollbackLatest 恢复原文内容
         新建文件 → RollbackLatest 后消失
         空历史定向回滚报错
    变异:不记录变更 → 两条断言红

## 验证

    go build ./... / go vet ./...              PASS
    gofmt -l                                  干净
    CI 门禁自检                                PASS
    go test ./... -count=1 -race              51 测试包 / 100 包无测试 / 0 FAIL
    源码目录 _test.go                          0
This commit is contained in:
JianFeeeee
2026-11-14 10:22:12 +08:00
parent a7e8f55085
commit b215bfb3ad
4 changed files with 322 additions and 102 deletions

View File

@ -1,46 +1,158 @@
package test
// internal/agent/io 的黑盒判据:OutputCapability 的导出方法,输入 → 输出。
// 不构造设备、不碰内部注册表。
// internal/agent/io 的模块级黑盒判据:IOManager 的输入注入 → 可观察事件,
// 设备注册/查询/生命周期,以及能力标志语义。
import (
"errors"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// TestSupportsSingleAndCombinedFlags:位标志的 Supports 语义。
type contractDevice struct {
name string
started int
stopped int
startErr error
}
func (d *contractDevice) Name() string { return d.name }
func (d *contractDevice) Type() io.DeviceType { return io.DeviceIO }
func (d *contractDevice) ChannelDef() io.ChannelDef { return io.ChannelDef{} }
func (d *contractDevice) Description() string { return "contract device" }
func (d *contractDevice) Tools() []io.ToolDef { return nil }
func (d *contractDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return nil, nil
}
func (d *contractDevice) Start() error { d.started++; return d.startErr }
func (d *contractDevice) Stop() error { d.stopped++; return nil }
func (d *contractDevice) OutputCapabilities() io.OutputCapability { return io.CapText }
func receiveInput(t *testing.T, m *io.IOManager) *io.InputEvent {
t.Helper()
select {
case evt := <-m.InputChan():
return evt
case <-time.After(2 * time.Second):
t.Fatal("injected input never reached the module's input queue")
return nil
}
}
// TestInjectTextDeliversInputEvent:注入文本必须成为一条可观察的输入事件。
func TestInjectTextDeliversInputEvent(t *testing.T) {
m := io.NewIOManager()
m.InjectText("qq", "你好")
evt := receiveInput(t, m)
if evt.Source != "qq" || evt.OutputChannel != "qq" {
t.Fatalf("event routing = %+v", evt)
}
if text, _ := evt.Payload["content"].(string); text != "你好" {
t.Fatalf("payload text = %v", evt.Payload)
}
if evt.RequestID == "" {
t.Fatal("injected event has no request id")
}
}
// TestInjectInputToRoutesToRequestedChannel:显式输出通道必须被保留。
func TestInjectInputToRoutesToRequestedChannel(t *testing.T) {
m := io.NewIOManager()
m.InjectInputTo("qq", "webui", "message", map[string]interface{}{"text": "hi"})
evt := receiveInput(t, m)
if evt.Source != "qq" || evt.OutputChannel != "webui" {
t.Fatalf("routing = %+v", evt)
}
}
// TestSyncInjectionWaitsForResponse:同步注入必须把响应交回调用方。
func TestSyncInjectionWaitsForResponse(t *testing.T) {
m := io.NewIOManager()
go func() {
evt := <-m.InputChan()
if evt.ResponseCh != nil {
evt.ResponseCh <- &io.OutputEvent{Payload: map[string]interface{}{"content": "answer"}}
}
}()
got := m.InjectTextSync("qq", "问题")
if got == nil {
t.Fatal("sync injection returned nil")
}
if c, _ := got.Payload["content"].(string); c != "answer" {
t.Fatalf("response payload = %v", got.Payload)
}
}
// TestDeviceRegistrationLifecycle:注册 → 查询 → StartAll/StopAll 真实生效。
func TestDeviceRegistrationLifecycle(t *testing.T) {
m := io.NewIOManager()
dev := &contractDevice{name: "contract-dev"}
if err := m.RegisterDevice(dev); err != nil {
t.Fatalf("RegisterDevice: %v", err)
}
if got := m.GetDevice("contract-dev"); got == nil || got.Name() != "contract-dev" {
t.Fatalf("GetDevice = %v", got)
}
if err := m.StartAll(); err != nil {
t.Fatalf("StartAll: %v", err)
}
m.StopAll()
if dev.started != 1 || dev.stopped != 1 {
t.Fatalf("device lifecycle started=%d stopped=%d", dev.started, dev.stopped)
}
}
// TestDuplicateDeviceRejected:重复注册必须报错,不静默覆盖。
func TestDuplicateDeviceRejected(t *testing.T) {
m := io.NewIOManager()
if err := m.RegisterDevice(&contractDevice{name: "dup"}); err != nil {
t.Fatal(err)
}
if err := m.RegisterDevice(&contractDevice{name: "dup"}); err == nil {
t.Fatal("duplicate device name accepted")
}
}
// TestStartAllPropagatesDeviceFailure:设备启动失败必须向上传播。
func TestStartAllPropagatesDeviceFailure(t *testing.T) {
m := io.NewIOManager()
dev := &contractDevice{name: "bad", startErr: errors.New("boom")}
if err := m.RegisterDevice(dev); err != nil {
t.Fatal(err)
}
err := m.StartAll()
if err == nil || !strings.Contains(err.Error(), "bad") {
t.Fatalf("StartAll error = %v", err)
}
}
// TestSupportsSingleAndCombinedFlags:位标志的 Supports 语义(任一位命中)。
func TestSupportsSingleAndCombinedFlags(t *testing.T) {
c := io.CapText | io.CapAudio
if !c.Supports(io.CapText) || !c.Supports(io.CapAudio) {
t.Fatal("★ 已置位的能力报告不支持")
t.Fatal("set capability reported as unsupported")
}
if c.Supports(io.CapImage) {
t.Fatal("★ 未置位的能力报告支持")
t.Fatal("unset capability reported as supported")
}
// Supports 的语义是「任一位命中」,不是「全部命中」。
if !io.CapText.Supports(io.CapText | io.CapImage) {
t.Fatal("★ 任一位命中应报告支持")
t.Fatal("any-bit semantics broken")
}
if io.CapText.Supports(io.CapImage) {
t.Fatal("★ 完全不相交却报告支持")
t.Fatal("disjoint capability reported supported")
}
}
// TestCapabilityStringListsFlags:String 输出列出已置位的能力名。
// TestCapabilityStringListsFlags:String 列出已置位能力名。
func TestCapabilityStringListsFlags(t *testing.T) {
c := io.CapText | io.CapImage
s := c.String()
s := (io.CapText | io.CapImage).String()
if !strings.Contains(s, "text") || !strings.Contains(s, "image") {
t.Fatalf("★ String() = %q,缺已置位能力名", s)
t.Fatalf("String() = %q", s)
}
if strings.Contains(s, "audio") {
t.Fatalf("★ String() = %q,含未置位能力", s)
t.Fatalf("String() = %q contains unset capability", s)
}
}
// TestZeroCapabilityString:零能力字符串可用(不 panic)。
func TestZeroCapabilityString(t *testing.T) {
_ = io.OutputCapability(0).String()
}

View File

@ -75,3 +75,53 @@ func TestGetAdapterEndpointUnknownIsEmpty(t *testing.T) {
t.Fatalf("★ 未知适配器端点 = %q,期望空", got)
}
}
// TestTransformActuallyRunsAdapter:加载后调用 transform_request,
// 必须真的执行 Lua 脚本(而非只返回输入或报未加载)。
func TestTransformActuallyRunsAdapter(t *testing.T) {
v := newVM(t)
code := `-- name: contract-adapter
-- version: 1.0
return {
transform_request = function(raw) return '{"rewritten":true}' end,
transform_response = function(raw) return raw end,
transform_stream_chunk = function(raw) return raw end,
}`
if err := v.LoadAdapterSource("contract-adapter", code); err != nil {
t.Fatalf("LoadAdapterSource: %v", err)
}
out, err := v.CallTransformRequest("contract-adapter", `{"model":"m"}`)
if err != nil {
t.Fatalf("CallTransformRequest: %v", err)
}
if !strings.Contains(out, "rewritten") {
t.Fatalf("adapter body not executed: %q", out)
}
}
// TestAdapterHeadersAndEndpointExposed:静态声明的 endpoint/headers 可读回。
func TestAdapterHeadersAndEndpointExposed(t *testing.T) {
v := newVM(t)
code := `-- name: hdr
-- version: 1.0
return {
endpoint = 'http://127.0.0.1:9000/v1',
headers = { ['X-Contract'] = 'yes' },
transform_request = function(raw) return raw end,
transform_response = function(raw) return raw end,
transform_stream_chunk = function(raw) return raw end,
}`
if err := v.LoadAdapterSource("hdr", code); err != nil {
t.Fatalf("LoadAdapterSource: %v", err)
}
if got := v.GetAdapterEndpoint("hdr"); got != "http://127.0.0.1:9000/v1" {
t.Fatalf("endpoint = %q", got)
}
h, err := v.BuildHeaders("hdr", nil)
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
if h["X-Contract"] != "yes" {
t.Fatalf("headers = %v", h)
}
}

View File

@ -1,7 +1,7 @@
package test
// internal/system 的黑盒判据:导出路径判定函数,输入 → 输出。
// 不改全局状态、不碰系统文件。
// internal/system 的模块级黑盒判据:受保护路径的写前留档与恢复,
// 用真实文件系统输入 → 可观察文件内容输出。
import (
"os"
@ -11,64 +11,99 @@ import (
"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) {
// TestArchiveAndRestoreRoundTrip 覆盖 guard 的完整职责:
// 受保护文件被覆盖前留档 → 覆盖后可恢复成原内容。
func TestArchiveAndRestoreRoundTrip(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 {
if err := os.MkdirAll(protected, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(protected, "config.txt")
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
const original = "original-content"
if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
archived, err := system.ArchiveBeforeWrite(base, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite: %v", err)
}
if !archived {
t.Fatal("★ 已存在的文件却没留档")
t.Fatal("existing protected file was not archived")
}
// 留档目录是导出的 FileBaselineDir
baseline := system.FileBaselineDir(base)
if baseline == "" {
t.Fatal("★ FileBaselineDir 为空")
// 调用方随后覆盖文件(模拟 L0 写入)。
if err := os.WriteFile(target, []byte("broken-update"), 0o644); err != nil {
t.Fatal(err)
}
restored, err := system.RestoreFileFromBaseline(base, target)
if err != nil {
t.Fatalf("RestoreFileFromBaseline: %v", err)
}
if !restored {
t.Fatal("restore reported no baseline")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Fatalf("restored content = %q, want %q", got, original)
}
}
// 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)
// TestArchiveSkipsUnprotectedAndNewFiles:非受保护路径与新建文件都不留档。
func TestArchiveSkipsUnprotectedAndNewFiles(t *testing.T) {
base := t.TempDir()
protected := filepath.Join(base, "etc")
other := filepath.Join(base, "home")
system.SetProtectedPaths([]string{protected + string(os.PathSeparator)})
t.Cleanup(func() { system.SetProtectedPaths(nil) })
for _, d := range []string{protected, other} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
unprotected := filepath.Join(other, "file.txt")
if err := os.WriteFile(unprotected, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if archived, err := system.ArchiveBeforeWrite(base, unprotected); err != nil || archived {
t.Fatalf("unprotected file archived=%v err=%v", archived, err)
}
newFile := filepath.Join(protected, "does-not-exist.txt")
if archived, err := system.ArchiveBeforeWrite(base, newFile); err != nil || archived {
t.Fatalf("new file archived=%v err=%v", archived, err)
}
if _, err := os.Stat(filepath.Join(system.FileBaselineDir(base), "etc", "does-not-exist.txt")); err == nil {
t.Fatal("baseline written for a file that did not exist")
}
}
// TestLoadBaselineMissingReportsError:读不存在的基线必须报错。
func TestLoadBaselineMissingReportsError(t *testing.T) {
if _, err := system.LoadNetworkBaseline(t.TempDir()); err == nil {
t.Fatal("★ 读不存在的基线却返回成功")
// TestArchiveIsIdempotentForUnchangedContent:内容未变时重复留档不再产生新档案。
func TestArchiveIsIdempotentForUnchangedContent(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, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(protected, "same.txt")
if err := os.WriteFile(target, []byte("same"), 0o644); err != nil {
t.Fatal(err)
}
if archived, err := system.ArchiveBeforeWrite(base, target); err != nil || !archived {
t.Fatalf("first archive: archived=%v err=%v", archived, err)
}
if archived, err := system.ArchiveBeforeWrite(base, target); err != nil || archived {
t.Fatalf("second archive on unchanged content: archived=%v err=%v", archived, err)
}
}

View File

@ -1,78 +1,101 @@
package test
// internal/tracker 的黑盒判据:NewTracker/PreAction/PostAction → 可观察输出。
// 不碰 t.changeSets、不读内部目录结构(只用公开方法返回的结果)。
// internal/tracker 的模块级黑盒判据:真实文件变更 → changeset → 回滚恢复文件。
// 使用 upper 目录作为可写工作区的真实输入,不碰 t.changeSets 之外无公开替代的状态。
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/tracker"
)
func newTracker(t *testing.T) *tracker.Tracker {
func newTracker(t *testing.T) (*tracker.Tracker, string) {
t.Helper()
dir := t.TempDir()
tr := tracker.NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
if err := tr.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
return tr
t.Cleanup(func() { _ = tr.Stop() })
return tr, filepath.Join(dir, "work", "upper")
}
// TestFreshTrackerHasNoChanges:新 tracker 无 changeset。
func TestFreshTrackerHasNoChanges(t *testing.T) {
tr := newTracker(t)
if tr.HasChanges() {
t.Fatal("★ 新 tracker 报告有变更")
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if got := tr.ChangeSets(); len(got) != 0 {
t.Fatalf("★ ChangeSets() = %v,期望空", got)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// TestNewChangeSetCarriesAction:构造出的 changeset 带 action/ID/时间。
func TestNewChangeSetCarriesAction(t *testing.T) {
cs := tracker.NewChangeSet("write_file")
if cs.Action != "write_file" {
t.Fatalf("★ Action = %q", cs.Action)
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if cs.ID == "" || cs.Timestamp.IsZero() {
t.Fatalf("★ ID/时间未填:%+v", cs)
return string(b)
}
// TestPostActionRecordsRealFileChange:真实新建文件后 PostAction 必须记录该文件。
func TestPostActionRecordsRealFileChange(t *testing.T) {
tr, upper := newTracker(t)
tr.PreAction("write_file")
writeFile(t, filepath.Join(upper, "new.txt"), "hello")
cs := tr.PostAction("write_file")
if len(cs.Files) != 1 || cs.Files[0].Path != "new.txt" || cs.Files[0].Type != tracker.ChangeFileCreated {
t.Fatalf("changeset = %+v", cs.Files)
}
if !tr.HasChanges() {
t.Fatal("HasChanges false after a tracked change")
}
}
// TestPostActionWithoutFileChangeIsNoop:没有文件变更时 PostAction
// 不得凭空造出 changeset。
func TestPostActionWithoutFileChangeIsNoop(t *testing.T) {
tr := newTracker(t)
_ = tr.PreAction("write_file")
got := tr.PostAction("write_file")
if len(got.Files) != 0 {
t.Fatalf("★ 无文件变更却记录了 %d 条", len(got.Files))
// TestRollbackLatestRestoresModifiedContent:修改已存在文件的原文被回滚恢复。
func TestRollbackLatestRestoresModifiedContent(t *testing.T) {
tr, upper := newTracker(t)
const original = "original"
const changed = "changed"
writeFile(t, filepath.Join(upper, "cfg.txt"), original)
tr.PreAction("edit")
writeFile(t, filepath.Join(upper, "cfg.txt"), changed)
tr.PostAction("edit")
if got := readFile(t, filepath.Join(upper, "cfg.txt")); got != changed {
t.Fatalf("precondition: %q", got)
}
if tr.HasChanges() {
t.Fatal("★ 无文件变更却 HasChanges=true")
if err := tr.RollbackLatest(); err != nil {
t.Fatalf("RollbackLatest: %v", err)
}
if got := readFile(t, filepath.Join(upper, "cfg.txt")); got != original {
t.Fatalf("after rollback = %q, want %q", got, original)
}
}
// TestRollbackLatestWithoutChangesReportsError:无 changeset 时定向回滚报错。
// TestRollbackLatestRemovesCreatedFile:新建文件被回滚时应当消失。
func TestRollbackLatestRemovesCreatedFile(t *testing.T) {
tr, upper := newTracker(t)
tr.PreAction("write_file")
path := filepath.Join(upper, "temp.txt")
writeFile(t, path, "scratch")
tr.PostAction("write_file")
if err := tr.RollbackLatest(); err != nil {
t.Fatalf("RollbackLatest: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("created file survived rollback (err=%v)", err)
}
}
// TestRollbackLatestWithoutChangesReportsError:没有变更时定向回滚必须明确报错。
func TestRollbackLatestWithoutChangesReportsError(t *testing.T) {
tr := newTracker(t)
tr, _ := newTracker(t)
if err := tr.RollbackLatest(); err == nil {
t.Fatal("★ 无 changeset 时 RollbackLatest 应报错")
}
}
// TestRollbackClearsChangeSets:Rollback 后视图清空(无需挂载 overlay)。
func TestRollbackClearsChangeSets(t *testing.T) {
tr := newTracker(t)
_ = tr.PreAction("a")
_ = tr.PostAction("a")
if err := tr.Rollback(); err != nil {
t.Fatalf("Rollback: %v", err)
}
if tr.HasChanges() {
t.Fatal("★ Rollback 后仍有变更")
t.Fatal("RollbackLatest accepted empty history")
}
}