From b215bfb3ad9be234bae77a85dd87435b1b018823 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 14 Nov 2026 10:22:12 +0800 Subject: [PATCH] =?UTF-8?q?test:=20io/lua/system/tracker=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E5=9D=97=E8=81=8C=E8=B4=A3=E7=BA=A7=E9=BB=91?= =?UTF-8?q?=E7=9B=92=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../agent/io/test/channel_contract_test.go | 148 +++++++++++++++--- internal/lua/test/vm_contract_test.go | 50 ++++++ internal/system/test/guard_contract_test.go | 115 +++++++++----- .../tracker/test/tracker_contract_test.go | 111 +++++++------ 4 files changed, 322 insertions(+), 102 deletions(-) diff --git a/internal/agent/io/test/channel_contract_test.go b/internal/agent/io/test/channel_contract_test.go index 47eab5e5..87a45177 100644 --- a/internal/agent/io/test/channel_contract_test.go +++ b/internal/agent/io/test/channel_contract_test.go @@ -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() -} diff --git a/internal/lua/test/vm_contract_test.go b/internal/lua/test/vm_contract_test.go index 777d50d6..f2f55fb1 100644 --- a/internal/lua/test/vm_contract_test.go +++ b/internal/lua/test/vm_contract_test.go @@ -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) + } +} diff --git a/internal/system/test/guard_contract_test.go b/internal/system/test/guard_contract_test.go index 25b77390..5a5057cd 100644 --- a/internal/system/test/guard_contract_test.go +++ b/internal/system/test/guard_contract_test.go @@ -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) } } diff --git a/internal/tracker/test/tracker_contract_test.go b/internal/tracker/test/tracker_contract_test.go index 770c393f..f6416689 100644 --- a/internal/tracker/test/tracker_contract_test.go +++ b/internal/tracker/test/tracker_contract_test.go @@ -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") } }