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:
JianFeeeee
2026-11-09 14:41:43 +08:00
parent b2a5fad95f
commit 3420ceca60
4 changed files with 315 additions and 0 deletions

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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("★ 默认模型名为空")
}
}