mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 20:27:30 +00:00
test: 按模块导出接口重写黑盒判据(批次 10:agent / lua / ollama / log)
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 20 条判据,只调导出接口,输入 → 可观察输出。
## internal/agent —— 人格文件
TestSaveThenLoadPersonality Save → Load 内容/路径
TestLoadMissingPersonalityGivesEmpty 文件不存在 → 空人格
TestInjectPromptContainsContentAndRules 注入含原文 + 内核说话规则
TestInjectPromptEmptyPersonalityIsEmpty
TestPersonaStaleHints 陈旧措辞给提示;干净文本不报
TestSavePersonalityCreatesParentDirs 自动建父目录
## internal/lua —— VM 适配器
TestNewVMExposesAdapterDir 构造参数 → AdapterDir
TestListAdaptersEmptyThenLoaded LoadAdapterSource → ListAdapters
TestLoadAdapterSourceRejectsBadScript
TestCallTransformRequestUnknownAdapter
TestGetAdapterEndpointUnknownIsEmpty
## providers/ollama —— httptest 真实 HTTP
TestGenerateReturnsText 200 → Response.Text
TestGenerateMarksTruncated done_reason=length → Truncated
TestGenerateHTTPError 5xx → 报错
TestGenerateRejectsInvalidJSONSchema
TestNewFallsBackToDefaultModel 空 model 走默认值
**判据写错过一次**:第一版断言"空 model 构造失败",但 `optString`
对空值回退默认,构造成功才是真实契约,按此修正。
## internal/log —— Manager 生命周期
TestManagerStartStopIsSafe Start/cancel/Stop 不挂住
TestManagerStopIsIdempotent 连停两次不 panic
TestRetentionPoliciesAreDistinct 四个策略常量互异
## 变异验证(改回旧实现必须红)
agent SavePersonality 不建父目录 → ★ 文件没落盘
lua ListAdapters 恒空 → ★ ListAdapters = [],期望 1 个
ollama 丢 Truncated → ★ done_reason=length 未标记 Truncated
log Stop 重复 close → panic: close of closed channel
lua / log 第一版变异分别是文本不匹配与"import 变未使用导致编译失败",
都不算干净的断言红;重做成只改行为后确认有效。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 39 个测试包 / 100 包无测试 / 0 FAIL
4 个新包 -race 全绿
源码目录 _test.go 0
This commit is contained in:
85
internal/agent/test/personality_contract_test.go
Normal file
85
internal/agent/test/personality_contract_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package test
|
||||
|
||||
// internal/agent 的黑盒判据:人格文件往返 + InjectPrompt 输出。
|
||||
// 不碰内部正则表、不读源码。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent"
|
||||
)
|
||||
|
||||
// TestSaveThenLoadPersonality:Save → Load 拿回原文与路径。
|
||||
func TestSaveThenLoadPersonality(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "personal.md")
|
||||
const content = "你是小宅,说话自然一点。"
|
||||
if err := agent.SavePersonality(path, content); err != nil {
|
||||
t.Fatalf("SavePersonality: %v", err)
|
||||
}
|
||||
p, err := agent.LoadPersonality(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPersonality: %v", err)
|
||||
}
|
||||
if p.Content != content {
|
||||
t.Fatalf("★ Content = %q,期望原文", p.Content)
|
||||
}
|
||||
if p.Path != path {
|
||||
t.Fatalf("★ Path = %q,期望 %q", p.Path, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadMissingPersonalityGivesEmpty:文件不存在返回空人格而非报错。
|
||||
func TestLoadMissingPersonalityGivesEmpty(t *testing.T) {
|
||||
p, err := agent.LoadPersonality(filepath.Join(t.TempDir(), "nope.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("★ 文件不存在却报错:%v", err)
|
||||
}
|
||||
if p == nil || p.Content != "" {
|
||||
t.Fatalf("★ 期望空人格,实际 %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectPromptContainsContentAndRules:非空人格注入后含原文与说话规则。
|
||||
func TestInjectPromptContainsContentAndRules(t *testing.T) {
|
||||
p := &agent.Personality{Content: "你是小宅"}
|
||||
out := p.InjectPrompt()
|
||||
if !strings.Contains(out, "你是小宅") || !strings.Contains(out, "[Persona]") {
|
||||
t.Fatalf("★ InjectPrompt 输出不符:%q", out)
|
||||
}
|
||||
if len(out) <= len(p.Content) {
|
||||
t.Fatal("★ 内核没追加说话规则")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectPromptEmptyPersonalityIsEmpty:空人格不注入任何内容。
|
||||
func TestInjectPromptEmptyPersonalityIsEmpty(t *testing.T) {
|
||||
if got := (&agent.Personality{}).InjectPrompt(); got != "" {
|
||||
t.Fatalf("★ 空人格却注入了内容:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaStaleHints:含版本号字面量/陈旧措辞时给出提示;干净文本不提示。
|
||||
func TestPersonaStaleHints(t *testing.T) {
|
||||
dirty := "你是 HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手。"
|
||||
if got := agent.PersonaStaleHints(dirty); len(got) == 0 {
|
||||
t.Fatal("★ 陈旧人格卡没给出提示")
|
||||
}
|
||||
clean := "你是小宅,说话自然,别用客服腔。"
|
||||
if got := agent.PersonaStaleHints(clean); len(got) != 0 {
|
||||
t.Fatalf("★ 干净人格卡被误报:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavePersonalityCreatesParentDirs:父目录不存在时自动创建。
|
||||
func TestSavePersonalityCreatesParentDirs(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a", "b", "personal.md")
|
||||
if err := agent.SavePersonality(path, "x"); err != nil {
|
||||
t.Fatalf("SavePersonality: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("★ 文件没落盘:%v", err)
|
||||
}
|
||||
}
|
||||
54
internal/log/test/manager_contract_test.go
Normal file
54
internal/log/test/manager_contract_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package test
|
||||
|
||||
// internal/log 的黑盒判据:NewManager → Start/Stop 不 panic;
|
||||
// RetentionPolicy 常量是对外契约。不读日志文件内容、不碰 m.done。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/log"
|
||||
)
|
||||
|
||||
type stubCfg struct{}
|
||||
|
||||
func (c *stubCfg) GetString(key, def string) string { return def }
|
||||
func (c *stubCfg) GetInt(key string, def int) int { return def }
|
||||
|
||||
// TestManagerStartStopIsSafe:构造 + 启动 + 停止不得 panic/挂住。
|
||||
func TestManagerStartStopIsSafe(t *testing.T) {
|
||||
m := log.NewManager(t.TempDir(), &stubCfg{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.Start(ctx)
|
||||
cancel()
|
||||
done := make(chan struct{})
|
||||
go func() { m.Stop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("★ Stop 挂住")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerStopIsIdempotent:连停两次不得 panic(close 纪律)。
|
||||
func TestManagerStopIsIdempotent(t *testing.T) {
|
||||
m := log.NewManager(t.TempDir(), &stubCfg{})
|
||||
m.Stop()
|
||||
m.Stop()
|
||||
}
|
||||
|
||||
// TestRetentionPoliciesAreDistinct:四个策略常量互不相同且非空。
|
||||
func TestRetentionPoliciesAreDistinct(t *testing.T) {
|
||||
all := []log.RetentionPolicy{
|
||||
log.RetentionThisWeek, log.RetentionThisMonth,
|
||||
log.RetentionNMonths, log.RetentionForever,
|
||||
}
|
||||
seen := map[log.RetentionPolicy]bool{}
|
||||
for _, p := range all {
|
||||
if p == "" || seen[p] {
|
||||
t.Fatalf("★ 策略常量为空或重复:%q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
77
internal/lua/test/vm_contract_test.go
Normal file
77
internal/lua/test/vm_contract_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package test
|
||||
|
||||
// internal/lua 的黑盒判据:NewVM → LoadAdapterSource → 可观察输出。
|
||||
// 不读适配器源码、不碰 v.pools。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/lua"
|
||||
)
|
||||
|
||||
func newVM(t *testing.T) *lua.VM {
|
||||
t.Helper()
|
||||
return lua.NewVM(t.TempDir())
|
||||
}
|
||||
|
||||
// TestNewVMExposesAdapterDir:构造参数可从 AdapterDir 读回。
|
||||
func TestNewVMExposesAdapterDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v := lua.NewVM(dir)
|
||||
if got := v.AdapterDir(); got != dir {
|
||||
t.Fatalf("★ AdapterDir = %q,期望 %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdaptersEmptyThenLoaded:加载适配器后 ListAdapters 反映它。
|
||||
func TestListAdaptersEmptyThenLoaded(t *testing.T) {
|
||||
v := newVM(t)
|
||||
if got := v.ListAdapters(); len(got) != 0 {
|
||||
t.Fatalf("★ 新 VM 的 ListAdapters = %v,期望空", got)
|
||||
}
|
||||
// 最小可用适配器脚本(静态检查要求声明 name/version)
|
||||
code := `-- name: contract-adapter
|
||||
-- version: 1.0
|
||||
return {
|
||||
transform_request = function(raw) return raw end,
|
||||
transform_response = function(raw) return raw end,
|
||||
transform_stream_chunk = function(raw) return raw end,
|
||||
endpoint = function() return "http://127.0.0.1/v1" end,
|
||||
headers = function() return {} end,
|
||||
}`
|
||||
if err := v.LoadAdapterSource("contract-adapter", code); err != nil {
|
||||
t.Fatalf("LoadAdapterSource: %v", err)
|
||||
}
|
||||
got := v.ListAdapters()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("★ ListAdapters = %v,期望 1 个", got)
|
||||
}
|
||||
if got[0].Name != "contract-adapter" {
|
||||
t.Fatalf("★ Name = %q", got[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAdapterSourceRejectsBadScript:明显不合法的脚本必须报错。
|
||||
func TestLoadAdapterSourceRejectsBadScript(t *testing.T) {
|
||||
v := newVM(t)
|
||||
if err := v.LoadAdapterSource("bad", "this is not lua at all $$$"); err == nil {
|
||||
t.Fatal("★ 非法脚本被接受")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallTransformRequestUnknownAdapter:未加载的适配器调用必须报错。
|
||||
func TestCallTransformRequestUnknownAdapter(t *testing.T) {
|
||||
v := newVM(t)
|
||||
_, err := v.CallTransformRequest("ghost", `{}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "not loaded") {
|
||||
t.Fatalf("★ 未加载适配器的报错不可诊断:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAdapterEndpointUnknownIsEmpty:未知适配器端点为空串、不 panic。
|
||||
func TestGetAdapterEndpointUnknownIsEmpty(t *testing.T) {
|
||||
if got := newVM(t).GetAdapterEndpoint("ghost"); got != "" {
|
||||
t.Fatalf("★ 未知适配器端点 = %q,期望空", got)
|
||||
}
|
||||
}
|
||||
99
providers/ollama/test/generate_contract_test.go
Normal file
99
providers/ollama/test/generate_contract_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package test
|
||||
|
||||
// providers/ollama 的黑盒判据:New(cfg) → Generate → Response 输出。
|
||||
// 用 httptest 提供真实 HTTP 输入,不 mock client、不碰 p.endpoint。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/generation"
|
||||
ollama "github.com/JianFeeeee/HologramAgent/providers/ollama"
|
||||
)
|
||||
|
||||
func newProvider(t *testing.T, endpoint string) *ollama.Provider {
|
||||
t.Helper()
|
||||
p, err := ollama.New(generation.Config{Options: map[string]string{
|
||||
"endpoint": endpoint, "model": "contract-model",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(p.Close)
|
||||
return p
|
||||
}
|
||||
|
||||
// TestGenerateReturnsText:200 + {"response":...} → Response.Text。
|
||||
func TestGenerateReturnsText(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"response":"生成结果","done_reason":"stop"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, err := newProvider(t, srv.URL).Generate(context.Background(), generation.Request{Prompt: "你好"})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out.Text != "生成结果" {
|
||||
t.Fatalf("★ Text = %q,期望 生成结果", out.Text)
|
||||
}
|
||||
if out.Truncated {
|
||||
t.Fatal("★ done_reason=stop 却报告 Truncated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateMarksTruncated:done_reason=length → Truncated=true。
|
||||
func TestGenerateMarksTruncated(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"半截","done_reason":"length"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, err := newProvider(t, srv.URL).Generate(context.Background(), generation.Request{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.Truncated {
|
||||
t.Fatal("★ done_reason=length 未标记 Truncated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateHTTPError:非 200 → 报错。
|
||||
func TestGenerateHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newProvider(t, srv.URL).Generate(context.Background(), generation.Request{}); err == nil {
|
||||
t.Fatal("★ 5xx 却没报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRejectsInvalidJSONSchema:非 JSON 的 schema 必须报错。
|
||||
func TestGenerateRejectsInvalidJSONSchema(t *testing.T) {
|
||||
p, err := ollama.New(generation.Config{Options: map[string]string{"model": "m"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
if _, err := p.Generate(context.Background(), generation.Request{JSONSchema: "not-json"}); err == nil {
|
||||
t.Fatal("★ 非法 JSONSchema 被接受")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFallsBackToDefaultModel:空 model 走默认值,构造仍可用。
|
||||
func TestNewFallsBackToDefaultModel(t *testing.T) {
|
||||
p, err := ollama.New(generation.Config{Options: map[string]string{"model": ""}})
|
||||
if err != nil {
|
||||
t.Fatalf("★ 空 model 应回退默认值,实际报错:%v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
if got := p.Info().Model; got == "" {
|
||||
t.Fatal("★ 默认模型名为空")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user