mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
refactor: remove IO route mapping, add HTTP API tests, system prompt update
This commit is contained in:
@ -121,16 +121,6 @@ func main() {
|
||||
|
||||
// === IO Abstraction Layer (唯一输入路径) ===
|
||||
iom := agentIO.NewIOManager()
|
||||
iom.RegisterDevice(agentIO.NewMicrophone("mic", 16000, iom))
|
||||
iom.RegisterDevice(agentIO.NewSpeaker("speaker", iom))
|
||||
// mic 输入 → speaker 输出(语音 I/O 配对)
|
||||
iom.RegisterOutputRoute("mic", "speaker")
|
||||
iom.RegisterOutputRoute("voice", "speaker")
|
||||
iom.RegisterDevice(agentIO.NewCamera("camera", iom))
|
||||
iom.RegisterDevice(agentIO.NewRobotArm("arm", iom))
|
||||
iom.RegisterDevice(agentIO.NewGPIODevice("gpio", []int{2, 3, 4, 17}, iom))
|
||||
iom.StartAll()
|
||||
defer iom.StopAll()
|
||||
|
||||
// 插件绑定 IO 管理器 → 插件自动注册为 IO 设备
|
||||
pluginReg.SetIOManager(iom)
|
||||
@ -239,8 +229,26 @@ func main() {
|
||||
|
||||
// === Single Agent Core ===
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: "你是一个智能家庭管家,持续运行。你有以下工具:\n1. memory_recall — 查询图记忆\n2. memory_commit — 写入图记忆\n3. memory_introspect — 查看记忆统计\n4. knowledge_search — 搜索知识库\n5. doc_query — 查询文档记忆\n6. doc_commit — 写入文档记忆\n\n当用户问及个人信息或历史时,调用 memory_recall 工具来查询。当用户告诉了你新的个人信息时,调用 memory_commit 来记住。需要查询知识时使用 knowledge_search。",
|
||||
ID: "main",
|
||||
SystemPrompt: `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
你有以下核心工具:
|
||||
1. memory_recall — 查询图记忆(历史/个人信息)
|
||||
2. memory_commit — 写入图记忆(记住新信息)
|
||||
3. memory_introspect — 查看记忆统计
|
||||
4. knowledge_search — 搜索知识库
|
||||
5. doc_query — 查询文档记忆
|
||||
6. doc_commit — 写入文档记忆
|
||||
7. plgreload — 热重载插件
|
||||
|
||||
当用户问及个人信息或历史时,调用 memory_recall。
|
||||
当用户告诉了你新的个人信息时,调用 memory_commit。
|
||||
需要查询知识时使用 knowledge_search。
|
||||
回复你的真实想法,用自然语言与用户交流。`,
|
||||
Provider: provider,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
@ -264,7 +272,7 @@ func main() {
|
||||
log.Printf("[homed] main agent started, model=%s base=%s", cfg.LLM.Model, cfg.LLM.BaseURL)
|
||||
|
||||
// === HTTP API ===
|
||||
handler := api.NewHandler(sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks)
|
||||
handler := api.NewHandler(sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
@ -891,6 +891,7 @@ func (a *Agent) reorgGraph() {
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||||
a.docStore.Remove(doc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
internal/agent/core/agent_functions_test.go
Normal file
109
internal/agent/core/agent_functions_test.go
Normal file
@ -0,0 +1,109 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
func TestIsSimilarName(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"张三", "张三四", false},
|
||||
{"", "", false},
|
||||
{"a", "b", false},
|
||||
{"张三", "李四", false},
|
||||
{"张三", "张三", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isSimilarName(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSimilarName(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocToTriples(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "用户喜欢编程",
|
||||
Content: "用户提到喜欢Go和Python",
|
||||
Tags: []string{"编程", "Go"},
|
||||
Entities: []string{"Go", "Python"},
|
||||
Source: "context",
|
||||
}
|
||||
|
||||
triples := docToTriples(doc)
|
||||
if len(triples) == 0 {
|
||||
t.Fatal("expected non-empty triples")
|
||||
}
|
||||
|
||||
foundSummary := false
|
||||
foundEntity := false
|
||||
foundTag := false
|
||||
foundSource := false
|
||||
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "包含内容" {
|
||||
foundSummary = true
|
||||
}
|
||||
if tr.Subject == "文档" && tr.Relation == "提及实体" {
|
||||
foundEntity = true
|
||||
}
|
||||
if tr.Subject == "文档" && tr.Relation == "标签" {
|
||||
foundTag = true
|
||||
}
|
||||
if tr.Subject == "文档" && tr.Relation == "来源" {
|
||||
foundSource = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundSummary {
|
||||
t.Error("missing '包含内容' triple")
|
||||
}
|
||||
if !foundEntity {
|
||||
t.Error("missing '提及实体' triple")
|
||||
}
|
||||
if !foundTag {
|
||||
t.Error("missing '标签' triple")
|
||||
}
|
||||
if !foundSource {
|
||||
t.Error("missing '来源' triple")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocToTriplesNil(t *testing.T) {
|
||||
triples := docToTriples(nil)
|
||||
if len(triples) != 0 {
|
||||
t.Errorf("expected empty for nil doc, got %d", len(triples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocToTriplesNoSource(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "无来源文档",
|
||||
Content: "content",
|
||||
}
|
||||
triples := docToTriples(doc)
|
||||
for _, tr := range triples {
|
||||
if tr.Relation == "来源" {
|
||||
t.Error("should not have source triple when Source is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocToTriplesTypes(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "测试三元组类型",
|
||||
Content: "用于验证 SubjectType 和 ObjectType",
|
||||
Entities: []string{"Go"},
|
||||
}
|
||||
|
||||
triples := docToTriples(doc)
|
||||
for _, tr := range triples {
|
||||
if tr.Subject != "文档" {
|
||||
t.Errorf("expected subject '文档', got %q", tr.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
internal/agent/core/agent_helpers_test.go
Normal file
78
internal/agent/core/agent_helpers_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"hello", 10, "hello"},
|
||||
{"hello world", 5, "hello..."},
|
||||
{"你好世界", 2, "你好..."},
|
||||
{"", 5, ""},
|
||||
{"abc", 3, "abc"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := truncateStr(tt.input, tt.max)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateStr(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetString(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
if got := getString(m, "name"); got != "张三" {
|
||||
t.Errorf("expected '张三', got %q", got)
|
||||
}
|
||||
if got := getString(m, "age"); got != "" {
|
||||
t.Errorf("expected empty for int, got %q", got)
|
||||
}
|
||||
if got := getString(m, "nonexistent"); got != "" {
|
||||
t.Errorf("expected empty for missing key, got %q", got)
|
||||
}
|
||||
if got := getString(nil, "key"); got != "" {
|
||||
t.Errorf("expected empty for nil map, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloat(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"count": 42.5,
|
||||
"score": 100,
|
||||
"name": "test",
|
||||
}
|
||||
|
||||
if got := getFloat(m, "count"); got != 42.5 {
|
||||
t.Errorf("expected 42.5, got %f", got)
|
||||
}
|
||||
if got := getFloat(m, "score"); got != 100.0 {
|
||||
t.Errorf("expected 100.0, got %f", got)
|
||||
}
|
||||
if got := getFloat(m, "name"); got != 0 {
|
||||
t.Errorf("expected 0 for string, got %f", got)
|
||||
}
|
||||
if got := getFloat(m, "nonexistent"); got != 0 {
|
||||
t.Errorf("expected 0 for missing key, got %f", got)
|
||||
}
|
||||
if got := getFloat(nil, "key"); got != 0 {
|
||||
t.Errorf("expected 0 for nil map, got %f", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatInt(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"top_k": float64(5),
|
||||
}
|
||||
if got := getFloat(m, "top_k"); got != 5.0 {
|
||||
t.Errorf("expected 5.0, got %f", got)
|
||||
}
|
||||
}
|
||||
198
internal/agent/core/agent_tools_test.go
Normal file
198
internal/agent/core/agent_tools_test.go
Normal file
@ -0,0 +1,198 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// mockOutputDevice implements agentIO.Device for testing output tools
|
||||
type mockOutputDevice struct {
|
||||
name string
|
||||
caps agentIO.OutputCapability
|
||||
tools []agentIO.ToolDef
|
||||
toolFn func(string, map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if d.toolFn != nil {
|
||||
return d.toolFn(tool, args)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestExecuteOutputListChannels(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
dev := &mockOutputDevice{
|
||||
name: "speaker",
|
||||
caps: agentIO.CapText | agentIO.CapAudio,
|
||||
}
|
||||
io.RegisterDevice(dev)
|
||||
|
||||
a := &Agent{io: io}
|
||||
result := a.executeOutputListChannels()
|
||||
if result == "" || result == "没有可用通道" {
|
||||
t.Errorf("expected channel list, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputListChannelsEmpty(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
result := a.executeOutputListChannels()
|
||||
if result != "没有可用通道" {
|
||||
t.Errorf("expected '没有可用通道', got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputChannelTool(t *testing.T) {
|
||||
a := &Agent{}
|
||||
tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{
|
||||
"channel": "voice",
|
||||
}}
|
||||
result := a.executeOutputChannelTool(tc)
|
||||
if a.currentOutputChannel != "voice" {
|
||||
t.Errorf("expected channel 'voice', got %q", a.currentOutputChannel)
|
||||
}
|
||||
if result == "" {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputChannelToolEmpty(t *testing.T) {
|
||||
a := &Agent{}
|
||||
tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{}}
|
||||
result := a.executeOutputChannelTool(tc)
|
||||
if result != "请指定输出通道名称,可选: voice, email, screen, http" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendTool(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
io.RegisterDevice(&mockOutputDevice{
|
||||
name: "screen",
|
||||
caps: agentIO.CapText,
|
||||
})
|
||||
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "screen",
|
||||
"content": "hello world",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "已通过 [screen] 通道发送" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendToolMissingChannel(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"content": "hello",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "channel 和 content 不能为空" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendToolEmptyContent(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "screen",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "channel 和 content 不能为空" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendToolChannelNotExist(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "nonexistent",
|
||||
"content": "hello",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "通道 [nonexistent] 不存在或不可用。可用通道请用 output_list_channels 查看" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendToolNoTextCap(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
io.RegisterDevice(&mockOutputDevice{
|
||||
name: "camera",
|
||||
caps: agentIO.CapImage,
|
||||
})
|
||||
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "camera",
|
||||
"content": "hello",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result == "已通过 [camera] 通道发送" {
|
||||
t.Errorf("should reject channel without text capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
foundSetChannel := false
|
||||
foundSend := false
|
||||
foundList := false
|
||||
for _, td := range tools {
|
||||
m, ok := td.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, ok := m["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
switch name {
|
||||
case "output_set_channel":
|
||||
foundSetChannel = true
|
||||
case "output_send":
|
||||
foundSend = true
|
||||
case "output_list_channels":
|
||||
foundList = true
|
||||
}
|
||||
}
|
||||
if !foundSetChannel {
|
||||
t.Error("output_set_channel should always be in tools")
|
||||
}
|
||||
if !foundSend {
|
||||
t.Error("output_send should always be in tools")
|
||||
}
|
||||
if !foundList {
|
||||
t.Error("output_list_channels should always be in tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllToolsEmpty(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tools := a.buildToolDefs()
|
||||
// should have at least output_set_channel, output_send, output_list_channels
|
||||
if len(tools) < 3 {
|
||||
t.Errorf("expected at least 3 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
131
internal/agent/core/context_test.go
Normal file
131
internal/agent/core/context_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContextAppendAndLen(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
if ctx.Len() != 0 {
|
||||
t.Errorf("new context should be empty, got %d", ctx.Len())
|
||||
}
|
||||
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "hello"})
|
||||
if ctx.Len() != 1 {
|
||||
t.Errorf("expected len 1, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextRecent(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "a"})
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "b"})
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "c"})
|
||||
|
||||
recent := ctx.Recent(2)
|
||||
if len(recent) != 2 {
|
||||
t.Errorf("expected 2 recent, got %d", len(recent))
|
||||
}
|
||||
if recent[0].Input != "b" || recent[1].Input != "c" {
|
||||
t.Errorf("expected [b, c], got %v", recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextFormat(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
f := ctx.Format()
|
||||
if f != "" {
|
||||
t.Errorf("empty context should format to empty string, got %q", f)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ctx.Append(ContextEvent{Timestamp: now, Source: "user", Input: "hello"})
|
||||
f = ctx.Format()
|
||||
if f == "" {
|
||||
t.Fatal("non-empty context should produce non-empty format")
|
||||
}
|
||||
if !contains(f, "hello") {
|
||||
t.Errorf("format should contain input 'hello', got: %s", f)
|
||||
}
|
||||
if !contains(f, "user") {
|
||||
t.Errorf("format should contain source 'user'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
Input: "今天天气很好",
|
||||
Response: "是的天气不错",
|
||||
})
|
||||
}
|
||||
// 加一条不同主题的
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
Input: "帮我算一下微积分题目",
|
||||
Response: "好的我来算",
|
||||
})
|
||||
|
||||
archived := ctx.Prune("微积分", 5, nil) // nil docStore → 不归档,只裁剪
|
||||
_ = archived
|
||||
|
||||
if ctx.Len() > 5 {
|
||||
t.Errorf("after prune to 5, len should be ≤5, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPruneWithDocStore(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
for i := 0; i < 15; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
Input: "今天天气很好",
|
||||
Response: "是的",
|
||||
})
|
||||
}
|
||||
|
||||
archived := ctx.Prune("天气", 10, nil)
|
||||
if archived != 0 {
|
||||
t.Errorf("with nil docStore, archived should be 0, got %d", archived)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextAppendAfterPrune(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
Input: "hello world",
|
||||
})
|
||||
}
|
||||
|
||||
ctx.Prune("hello", 3, nil)
|
||||
if ctx.Len() > 3 {
|
||||
t.Errorf("expected ≤3 after prune, got %d", ctx.Len())
|
||||
}
|
||||
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "new message"})
|
||||
if ctx.Len() != 4 {
|
||||
t.Errorf("after append, expected 4, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && containsStr(s, substr)
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -2,6 +2,7 @@ package io
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@ -88,12 +89,11 @@ type OutputEvent struct {
|
||||
}
|
||||
|
||||
type IOManager struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]Device
|
||||
inputCh chan *InputEvent
|
||||
outputCh chan *OutputEvent
|
||||
nextReqID int64
|
||||
routes map[string]string // 输入源 → 默认输出通道 e.g. "mic" → "speaker"
|
||||
mu sync.RWMutex
|
||||
devices map[string]Device
|
||||
inputCh chan *InputEvent
|
||||
outputCh chan *OutputEvent
|
||||
nextReqID int64
|
||||
}
|
||||
|
||||
func NewIOManager() *IOManager {
|
||||
@ -101,26 +101,13 @@ func NewIOManager() *IOManager {
|
||||
devices: make(map[string]Device),
|
||||
inputCh: make(chan *InputEvent, 256),
|
||||
outputCh: make(chan *OutputEvent, 256),
|
||||
routes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterOutputRoute 注册输入源 → 默认输出通道映射
|
||||
// 例如:mic → speaker,voice_input → speaker
|
||||
func (m *IOManager) RegisterOutputRoute(inputSource, outputChannel string) {
|
||||
func (m *IOManager) UnregisterDevice(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.routes[inputSource] = outputChannel
|
||||
}
|
||||
|
||||
// DefaultOutput 返回输入源的默认输出通道
|
||||
func (m *IOManager) DefaultOutput(source string) string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if ch, ok := m.routes[source]; ok {
|
||||
return ch
|
||||
}
|
||||
return source // 默认等于输入源
|
||||
delete(m.devices, name)
|
||||
}
|
||||
|
||||
func (m *IOManager) nextRequestID() string {
|
||||
@ -130,30 +117,17 @@ func (m *IOManager) nextRequestID() string {
|
||||
return fmt.Sprintf("req_%d_%d", time.Now().UnixNano(), m.nextReqID)
|
||||
}
|
||||
|
||||
func (m *IOManager) UnregisterDevice(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.devices, name)
|
||||
for src, dst := range m.routes {
|
||||
if src == name || dst == name {
|
||||
delete(m.routes, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AtomicSwapDevices 原子化替换全部 IO 设备与路由表
|
||||
// AtomicSwapDevices 原子化替换全部 IO 设备
|
||||
// 1. 新设备必须在调用前已完成 Start()
|
||||
// 2. 调用后旧设备立即从路由表中摘除,新请求走向新设备
|
||||
// 2. 调用后旧设备立即摘除,新请求走向新设备
|
||||
// 3. 返回旧设备列表,由调用方负责 Stop()
|
||||
func (m *IOManager) AtomicSwapDevices(newDevices map[string]Device, newRoutes map[string]string) map[string]Device {
|
||||
func (m *IOManager) AtomicSwapDevices(newDevices map[string]Device) map[string]Device {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
oldDevices := m.devices
|
||||
m.devices = newDevices
|
||||
|
||||
m.routes = newRoutes
|
||||
|
||||
return oldDevices
|
||||
}
|
||||
|
||||
@ -169,10 +143,15 @@ func (m *IOManager) RegisterDevice(dev Device) error {
|
||||
|
||||
func (m *IOManager) StartAll() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for name, dev := range m.devices {
|
||||
devices := make([]Device, 0, len(m.devices))
|
||||
for _, dev := range m.devices {
|
||||
devices = append(devices, dev)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, dev := range devices {
|
||||
if err := dev.Start(); err != nil {
|
||||
return fmt.Errorf("start device %s: %w", name, err)
|
||||
return fmt.Errorf("start device %s: %w", dev.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@ -180,9 +159,16 @@ func (m *IOManager) StartAll() error {
|
||||
|
||||
func (m *IOManager) StopAll() {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
devices := make([]Device, 0, len(m.devices))
|
||||
for _, dev := range m.devices {
|
||||
dev.Stop()
|
||||
devices = append(devices, dev)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, dev := range devices {
|
||||
if err := dev.Stop(); err != nil {
|
||||
log.Printf("[io] stop device %s error: %v", dev.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,7 +178,7 @@ func (m *IOManager) InjectInput(source string, eventType string, payload map[str
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
OutputChannel: m.DefaultOutput(source),
|
||||
OutputChannel: source,
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,7 +190,32 @@ func (m *IOManager) InjectInputSync(source string, eventType string, payload map
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
ResponseCh: ch,
|
||||
OutputChannel: m.DefaultOutput(source),
|
||||
OutputChannel: source,
|
||||
}
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// InjectInputTo 注入输入事件并指定输出通道
|
||||
func (m *IOManager) InjectInputTo(source, outputChannel, eventType string, payload map[string]interface{}) {
|
||||
m.inputCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
OutputChannel: outputChannel,
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputSyncTo 注入输入事件(同步等待)并指定输出通道
|
||||
func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, payload map[string]interface{}) *OutputEvent {
|
||||
ch := make(chan *OutputEvent, 1)
|
||||
m.inputCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
ResponseCh: ch,
|
||||
OutputChannel: outputChannel,
|
||||
}
|
||||
return <-ch
|
||||
}
|
||||
@ -221,6 +232,20 @@ func (m *IOManager) InjectTextSync(source string, text string) *OutputEvent {
|
||||
})
|
||||
}
|
||||
|
||||
// InjectTextTo 注入文本输入并指定输出通道
|
||||
func (m *IOManager) InjectTextTo(source, outputChannel, text string) {
|
||||
m.InjectInputTo(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
})
|
||||
}
|
||||
|
||||
// InjectTextSyncTo 注入文本输入(同步等待)并指定输出通道
|
||||
func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *OutputEvent {
|
||||
return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *IOManager) EmitOutput(target string, outputType string, payload map[string]interface{}) {
|
||||
m.outputCh <- &OutputEvent{
|
||||
RequestID: "",
|
||||
@ -271,15 +296,25 @@ func (m *IOManager) GetAllTools() []ToolDef {
|
||||
|
||||
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
type nameDevice struct {
|
||||
name string
|
||||
dev Device
|
||||
}
|
||||
var candidates []nameDevice
|
||||
for _, dev := range m.devices {
|
||||
for _, t := range dev.Tools() {
|
||||
if t.Name == name {
|
||||
return dev.Execute(name, args)
|
||||
candidates = append(candidates, nameDevice{name: dev.Name(), dev: dev})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
}
|
||||
return candidates[0].dev.Execute(name, args)
|
||||
}
|
||||
|
||||
func (m *IOManager) ListDevices() []Device {
|
||||
|
||||
510
internal/agent/io/channel_test.go
Normal file
510
internal/agent/io/channel_test.go
Normal file
@ -0,0 +1,510 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockDevice implements Device for testing
|
||||
type mockDevice struct {
|
||||
name string
|
||||
devType DeviceType
|
||||
desc string
|
||||
tools []ToolDef
|
||||
caps OutputCapability
|
||||
startFn func() error
|
||||
stopFn func() error
|
||||
executeFn func(string, map[string]interface{}) (interface{}, error)
|
||||
startCallCount int
|
||||
stopCallCount int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (d *mockDevice) Name() string { return d.name }
|
||||
func (d *mockDevice) Type() DeviceType { return d.devType }
|
||||
func (d *mockDevice) Description() string { return d.desc }
|
||||
func (d *mockDevice) Tools() []ToolDef { return d.tools }
|
||||
func (d *mockDevice) Start() error {
|
||||
d.mu.Lock()
|
||||
d.startCallCount++
|
||||
d.mu.Unlock()
|
||||
if d.startFn != nil {
|
||||
return d.startFn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *mockDevice) Stop() error {
|
||||
d.mu.Lock()
|
||||
d.stopCallCount++
|
||||
d.mu.Unlock()
|
||||
if d.stopFn != nil {
|
||||
return d.stopFn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *mockDevice) OutputCapabilities() OutputCapability { return d.caps }
|
||||
func (d *mockDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if d.executeFn != nil {
|
||||
return d.executeFn(tool, args)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestNewIOManager(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
if m == nil {
|
||||
t.Fatal("IOManager should not be nil")
|
||||
}
|
||||
if m.InputChan() == nil {
|
||||
t.Error("InputChan should not be nil")
|
||||
}
|
||||
if m.OutputChan() == nil {
|
||||
t.Error("OutputChan should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDevice(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
dev := &mockDevice{name: "test_dev"}
|
||||
if err := m.RegisterDevice(dev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Duplicate registration
|
||||
if err := m.RegisterDevice(dev); err == nil {
|
||||
t.Error("expected error for duplicate registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisterDevice(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{name: "dev1"})
|
||||
m.RegisterDevice(&mockDevice{name: "dev2"})
|
||||
|
||||
m.UnregisterDevice("dev1")
|
||||
|
||||
devices := m.ListDevices()
|
||||
if len(devices) != 1 {
|
||||
t.Errorf("expected 1 device, got %d", len(devices))
|
||||
}
|
||||
if devices[0].Name() != "dev2" {
|
||||
t.Errorf("expected 'dev2', got %q", devices[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisterDeviceTwice(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{name: "dev1"})
|
||||
m.UnregisterDevice("dev1")
|
||||
m.UnregisterDevice("dev1") // should not panic
|
||||
}
|
||||
|
||||
func TestInjectTextTo(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
if evt.OutputChannel != "speaker" {
|
||||
t.Errorf("expected OutputChannel 'speaker', got %q", evt.OutputChannel)
|
||||
}
|
||||
content, _ := evt.Payload["content"].(string)
|
||||
if content != "say hello" {
|
||||
t.Errorf("expected 'say hello', got %q", content)
|
||||
}
|
||||
}()
|
||||
m.InjectTextTo("mic", "speaker", "say hello")
|
||||
}
|
||||
|
||||
func TestInjectTextSyncTo(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
if evt.OutputChannel != "qq" {
|
||||
t.Errorf("expected OutputChannel 'qq', got %q", evt.OutputChannel)
|
||||
}
|
||||
evt.ResponseCh <- &OutputEvent{Done: true}
|
||||
}()
|
||||
|
||||
resp := m.InjectTextSyncTo("onebot", "qq", "hi")
|
||||
if resp == nil || !resp.Done {
|
||||
t.Error("expected Done response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectInput(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
if evt.Source != "test_source" {
|
||||
t.Errorf("expected source 'test_source', got %q", evt.Source)
|
||||
}
|
||||
if evt.Type != "text" {
|
||||
t.Errorf("expected type 'text', got %q", evt.Type)
|
||||
}
|
||||
if evt.OutputChannel != "test_source" {
|
||||
t.Errorf("expected OutputChannel 'test_source', got %q", evt.OutputChannel)
|
||||
}
|
||||
}()
|
||||
|
||||
m.InjectInput("test_source", "text", map[string]interface{}{"content": "hello"})
|
||||
}
|
||||
|
||||
func TestInjectInputSync(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
if evt.OutputChannel != "test" {
|
||||
t.Errorf("expected OutputChannel 'test', got %q", evt.OutputChannel)
|
||||
}
|
||||
evt.ResponseCh <- &OutputEvent{RequestID: evt.RequestID, Done: true}
|
||||
}()
|
||||
|
||||
resp := m.InjectInputSync("test", "text", map[string]interface{}{"content": "sync"})
|
||||
if resp == nil {
|
||||
t.Fatal("expected response")
|
||||
}
|
||||
if !resp.Done {
|
||||
t.Error("expected Done=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectInputTo(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
if evt.OutputChannel != "speaker" {
|
||||
t.Errorf("expected OutputChannel 'speaker', got %q", evt.OutputChannel)
|
||||
}
|
||||
}()
|
||||
|
||||
m.InjectInputTo("mic", "speaker", "text", map[string]interface{}{"content": "hello"})
|
||||
}
|
||||
|
||||
func TestInjectInputSyncTo(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
if evt.OutputChannel != "email" {
|
||||
t.Errorf("expected OutputChannel 'email', got %q", evt.OutputChannel)
|
||||
}
|
||||
evt.ResponseCh <- &OutputEvent{Done: true}
|
||||
}()
|
||||
|
||||
resp := m.InjectInputSyncTo("plugin", "email", "text", map[string]interface{}{"content": "hi"})
|
||||
if resp == nil || !resp.Done {
|
||||
t.Error("expected Done response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitOutput(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.OutputChan()
|
||||
if evt.Target != "memory" {
|
||||
t.Errorf("expected target 'memory', got %q", evt.Target)
|
||||
}
|
||||
}()
|
||||
|
||||
m.EmitOutput("memory", "text", map[string]interface{}{"content": "data"})
|
||||
}
|
||||
|
||||
func TestEmitOutputTo(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.OutputChan()
|
||||
if evt.OutputChannel != "speaker" {
|
||||
t.Errorf("expected channel 'speaker', got %q", evt.OutputChannel)
|
||||
}
|
||||
}()
|
||||
|
||||
m.EmitOutputTo("agent", "speaker", "text", map[string]interface{}{"content": "hello"})
|
||||
}
|
||||
|
||||
func TestGetAllTools(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "dev1",
|
||||
tools: []ToolDef{
|
||||
{Name: "tool1", Description: "first tool"},
|
||||
},
|
||||
})
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "dev2",
|
||||
tools: []ToolDef{
|
||||
{Name: "tool2", Description: "second tool"},
|
||||
{Name: "tool3", Description: "third tool"},
|
||||
},
|
||||
})
|
||||
|
||||
tools := m.GetAllTools()
|
||||
if len(tools) != 3 {
|
||||
t.Errorf("expected 3 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "calc",
|
||||
tools: []ToolDef{
|
||||
{Name: "add", Description: "addition"},
|
||||
},
|
||||
executeFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
a, _ := args["a"].(float64)
|
||||
b, _ := args["b"].(float64)
|
||||
return a + b, nil
|
||||
},
|
||||
})
|
||||
|
||||
result, err := m.ExecuteTool("add", map[string]interface{}{"a": 1.0, "b": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.(float64) != 3.0 {
|
||||
t.Errorf("expected 3.0, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolNotFound(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
_, err := m.ExecuteTool("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannels(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "screen",
|
||||
caps: CapText | CapImage,
|
||||
devType: DeviceOutput,
|
||||
})
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "mic",
|
||||
caps: 0,
|
||||
devType: DeviceInput,
|
||||
})
|
||||
|
||||
channels := m.ListChannels()
|
||||
if len(channels) != 2 {
|
||||
t.Errorf("expected 2 channels, got %d", len(channels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannelCapabilities(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "speaker",
|
||||
caps: CapText | CapAudio,
|
||||
})
|
||||
|
||||
caps := m.GetChannelCapabilities("speaker")
|
||||
if !caps.Supports(CapText) {
|
||||
t.Error("should support text")
|
||||
}
|
||||
if !caps.Supports(CapAudio) {
|
||||
t.Error("should support audio")
|
||||
}
|
||||
if caps.Supports(CapImage) {
|
||||
t.Error("should not support image")
|
||||
}
|
||||
|
||||
caps = m.GetChannelCapabilities("nonexistent")
|
||||
if caps != 0 {
|
||||
t.Errorf("expected 0 capabilities, got %v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAll(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
started := false
|
||||
dev := &mockDevice{
|
||||
name: "test",
|
||||
startFn: func() error {
|
||||
started = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
m.RegisterDevice(dev)
|
||||
|
||||
if err := m.StartAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started {
|
||||
t.Error("device should have been started")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAllError(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "fail",
|
||||
startFn: func() error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "ok",
|
||||
startFn: func() error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if err := m.StartAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAll(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
stopped := false
|
||||
dev := &mockDevice{
|
||||
name: "test",
|
||||
stopFn: func() error {
|
||||
stopped = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
m.RegisterDevice(dev)
|
||||
m.StartAll()
|
||||
m.StopAll()
|
||||
|
||||
if !stopped {
|
||||
t.Error("device should have been stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicSwapDevices(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{name: "old1"})
|
||||
m.RegisterDevice(&mockDevice{name: "old2"})
|
||||
|
||||
newDevices := map[string]Device{
|
||||
"new1": &mockDevice{name: "new1"},
|
||||
"new2": &mockDevice{name: "new2"},
|
||||
}
|
||||
|
||||
old := m.AtomicSwapDevices(newDevices)
|
||||
if len(old) != 2 {
|
||||
t.Errorf("expected 2 old devices, got %d", len(old))
|
||||
}
|
||||
devices := m.ListDevices()
|
||||
if len(devices) != 2 {
|
||||
t.Errorf("expected 2 devices after swap, got %d", len(devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectText(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
content, _ := evt.Payload["content"].(string)
|
||||
if content != "hello world" {
|
||||
t.Errorf("expected 'hello world', got %q", content)
|
||||
}
|
||||
if evt.OutputChannel != "user" {
|
||||
t.Errorf("expected OutputChannel 'user', got %q", evt.OutputChannel)
|
||||
}
|
||||
}()
|
||||
m.InjectText("user", "hello world")
|
||||
}
|
||||
|
||||
func TestInjectTextSync(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.InputChan()
|
||||
evt.ResponseCh <- &OutputEvent{Done: true}
|
||||
}()
|
||||
|
||||
resp := m.InjectTextSync("http", "ping")
|
||||
if resp == nil || !resp.Done {
|
||||
t.Error("expected Done response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitText(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.OutputChan()
|
||||
content, _ := evt.Payload["content"].(string)
|
||||
if content != "notification" {
|
||||
t.Errorf("expected 'notification', got %q", content)
|
||||
}
|
||||
}()
|
||||
m.EmitText("user", "notification")
|
||||
}
|
||||
|
||||
func TestEmitTextTo(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
go func() {
|
||||
evt := <-m.OutputChan()
|
||||
if evt.OutputChannel != "email" {
|
||||
t.Errorf("expected 'email', got %q", evt.OutputChannel)
|
||||
}
|
||||
content, _ := evt.Payload["content"].(string)
|
||||
if content != "alert" {
|
||||
t.Errorf("expected 'alert', got %q", content)
|
||||
}
|
||||
}()
|
||||
m.EmitTextTo("agent", "email", "alert")
|
||||
}
|
||||
|
||||
func TestOutputCapability(t *testing.T) {
|
||||
caps := CapText | CapImage
|
||||
if !caps.Supports(CapText) {
|
||||
t.Error("CapText should be supported")
|
||||
}
|
||||
if !caps.Supports(CapImage) {
|
||||
t.Error("CapImage should be supported")
|
||||
}
|
||||
if caps.Supports(CapAudio) {
|
||||
t.Error("CapAudio should not be supported")
|
||||
}
|
||||
if caps.Supports(CapFile) {
|
||||
t.Error("CapFile should not be supported")
|
||||
}
|
||||
if caps.Supports(CapStructured) {
|
||||
t.Error("CapStructured should not be supported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputCapabilityString(t *testing.T) {
|
||||
caps := CapText | CapAudio
|
||||
s := caps.String()
|
||||
if s != "[text audio]" && s != "[audio text]" {
|
||||
t.Errorf("unexpected string: %s", s)
|
||||
}
|
||||
|
||||
if OutputCapability(0).String() != "[]" {
|
||||
t.Errorf("expected empty, got %s", OutputCapability(0).String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{name: "dev1"})
|
||||
m.RegisterDevice(&mockDevice{name: "dev2"})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m.GetAllTools()
|
||||
m.ListChannels()
|
||||
m.ListDevices()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestNextRequestID(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
id := m.nextRequestID()
|
||||
if ids[id] {
|
||||
t.Errorf("duplicate request ID: %s", id)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
@ -30,9 +31,10 @@ type Handler struct {
|
||||
iom *agentIO.IOManager
|
||||
textMem *text.Memory
|
||||
knowledge *knowledge.Store
|
||||
tracker *tracker.Tracker
|
||||
}
|
||||
|
||||
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store) *Handler {
|
||||
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker) *Handler {
|
||||
var idx *memory.Indexer
|
||||
if mem != nil {
|
||||
idx = memory.NewIndexer(mem)
|
||||
@ -48,6 +50,7 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
|
||||
iom: iom,
|
||||
textMem: tm,
|
||||
knowledge: ks,
|
||||
tracker: tr,
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +70,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/knowledge/", h.handleKnowledge)
|
||||
mux.HandleFunc("/api/v1/adapters", h.handleAdapters)
|
||||
mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID)
|
||||
mux.HandleFunc("/api/v1/tracker", h.handleTracker)
|
||||
mux.HandleFunc("/api/v1/tracker/", h.handleTracker)
|
||||
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
|
||||
mux.HandleFunc("/", h.handleStatic)
|
||||
}
|
||||
@ -179,6 +184,10 @@ func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agen
|
||||
}
|
||||
|
||||
func (h *Handler) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
if h.skills == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skills not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"skills": h.skills.List()})
|
||||
@ -556,6 +565,43 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) {
|
||||
if h.tracker == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "tracker not available"})
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tracker")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
|
||||
switch {
|
||||
case path == "changesets" && r.Method == http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"changesets": h.tracker.ChangeSets(),
|
||||
"count": len(h.tracker.ChangeSets()),
|
||||
})
|
||||
case path == "rollback" && r.Method == http.MethodPost:
|
||||
if err := h.tracker.Rollback(); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_complete"})
|
||||
case path == "" && r.Method == http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"stats": h.tracker.Stats(),
|
||||
"has_changes": h.tracker.HasChanges(),
|
||||
"changesets": len(h.tracker.ChangeSets()),
|
||||
})
|
||||
case path == "" && r.Method == http.MethodDelete:
|
||||
if err := h.tracker.Rollback(); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
384
internal/api/handler_test.go
Normal file
384
internal/api/handler_test.go
Normal file
@ -0,0 +1,384 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
|
||||
t.Helper()
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
|
||||
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil), sup
|
||||
}
|
||||
|
||||
func TestHandleStatus(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["status"] != "running" {
|
||||
t.Errorf("expected running, got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusMethodNotAllowed(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
sup.RegisterAgent("test_agent")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAgents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
agents, ok := resp["agents"].([]interface{})
|
||||
if !ok || len(agents) == 0 {
|
||||
t.Error("expected agents list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentByID(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
sup.RegisterAgent("my_agent")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/my_agent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAgentByID(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["id"] != "my_agent" {
|
||||
t.Errorf("expected my_agent, got %v", resp["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentByIDNotFound(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAgentByID(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleKnowledgeSearch(t *testing.T) {
|
||||
ks := knowledge.NewStore(t.TempDir())
|
||||
ks.Start()
|
||||
ks.Add("test_doc", "this is test content for searching")
|
||||
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleKnowledge(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
results, ok := resp["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Error("expected search results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleKnowledgeCreate(t *testing.T) {
|
||||
ks := knowledge.NewStore(t.TempDir())
|
||||
ks.Start()
|
||||
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil)
|
||||
|
||||
body := `{"name":"new_doc","content":"fresh content"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleKnowledge(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleKnowledgeUnavailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleKnowledge(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMemoryUnavailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory?q=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleMemory(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTrackerNotAvailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTracker(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTrackerStats(t *testing.T) {
|
||||
tr := tracker.NewTracker(t.TempDir(), t.TempDir())
|
||||
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTracker(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
body := `{"model":"test"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAICompletionsMethodNotAllowed(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStaticServesHTML(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatic(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "HomeAgent Dashboard") {
|
||||
t.Error("expected dashboard HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStaticNotFound(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatic(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleConfigGet(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleConfig(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
method string
|
||||
code int
|
||||
}{
|
||||
{"/api/v1/status", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/agents", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/config", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/network", http.MethodGet, http.StatusOK},
|
||||
{"/", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/memory", http.MethodGet, http.StatusServiceUnavailable},
|
||||
{"/api/v1/knowledge", http.MethodGet, http.StatusServiceUnavailable},
|
||||
{"/api/v1/tracker", http.MethodGet, http.StatusServiceUnavailable},
|
||||
{"/api/v1/adapters", http.MethodGet, http.StatusServiceUnavailable},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.code {
|
||||
t.Errorf("%s %s: expected %d, got %d", tt.method, tt.path, tt.code, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdapterByIDNotFound(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAdapterByID(w, req)
|
||||
|
||||
// Returns 503 when lua VM is not available
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdaptersUnavailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAdapters(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
145
internal/knowledge/knowledge_test.go
Normal file
145
internal/knowledge/knowledge_test.go
Normal file
@ -0,0 +1,145 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewStore(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
if s == nil {
|
||||
t.Fatal("store should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAndSearch(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_add_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
if err := s.Add("coffee", "咖啡是一种饮品,含有咖啡因"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results := s.Search("咖啡", 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for '咖啡'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_list_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Add("topic1", "内容一")
|
||||
s.Add("topic2", "内容二")
|
||||
|
||||
list := s.List()
|
||||
if len(list) != 2 {
|
||||
t.Errorf("expected 2 items, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_rm_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Add("test", "测试内容")
|
||||
if err := s.Remove("test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
list := s.List()
|
||||
if len(list) != 0 {
|
||||
t.Errorf("expected 0 items after remove, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveNotFound(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_notfound_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
if err := s.Remove("nonexistent"); err != nil {
|
||||
t.Errorf("remove nonexistent should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_stats_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Add("a", "内容A")
|
||||
s.Add("b", "内容B")
|
||||
|
||||
stats := s.Stats()
|
||||
if stats["knowledge_count"].(int) != 2 {
|
||||
t.Errorf("expected knowledge_count 2, got %v", stats["knowledge_count"])
|
||||
}
|
||||
if stats["vector_count"] == nil {
|
||||
t.Error("expected vector_count in stats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchNoMatch(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_nomatch_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Add("math", "加减乘除是基本运算")
|
||||
// "电电电电电" 中的字符 "电" 不在文档 "math 加减乘除是基本运算" 的任意 unigram 中
|
||||
results := s.Search("电电电电电", 5)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results for non-matching query, got %d", len(results))
|
||||
}
|
||||
}
|
||||
@ -216,6 +216,18 @@ func (s *Store) RecentDocs(n int) []*Doc {
|
||||
return list
|
||||
}
|
||||
|
||||
// Remove 从文档存储中删除指定 ID 的文档
|
||||
func (s *Store) Remove(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.docs[id]; ok {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// ——— internal ———
|
||||
|
||||
func (s *Store) loadAll() error {
|
||||
|
||||
343
internal/memory/document/document_test.go
Normal file
343
internal/memory/document/document_test.go
Normal file
@ -0,0 +1,343 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInsertAndQuery(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
doc := &Doc{
|
||||
Summary: "用户喜欢喝咖啡",
|
||||
Content: "用户提到他每天早上都会喝一杯黑咖啡",
|
||||
Tags: []string{"咖啡", "习惯"},
|
||||
Source: "manual",
|
||||
}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if doc.ID == "" {
|
||||
t.Error("doc ID should be auto-generated")
|
||||
}
|
||||
|
||||
stats := s.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc, got %d", stats["doc_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_query_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
|
||||
s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
|
||||
s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
|
||||
|
||||
results := s.Query("咖啡", 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for '咖啡'")
|
||||
}
|
||||
|
||||
if results[0].AccessCount <= 0 {
|
||||
t.Error("access count should be updated on query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextToDoc(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_ctx_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
entries := []ContextEntry{
|
||||
{Timestamp: time.Now(), Source: "user", Content: "我喜欢编程", Response: "很好"},
|
||||
{Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"},
|
||||
}
|
||||
|
||||
doc, err := s.ContextToDoc("test", entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc == nil {
|
||||
t.Fatal("expected non-nil doc")
|
||||
}
|
||||
if doc.Summary == "" {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
if doc.Content == "" {
|
||||
t.Error("content should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindColdDocs(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_cold_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
|
||||
hot.AccessCount = 10
|
||||
hot.LastAccess = time.Now()
|
||||
s.Insert(hot)
|
||||
|
||||
cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
|
||||
s.Insert(cold)
|
||||
// Insert 会重置 LastAccess,手动改为过去的
|
||||
cold.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
cold.AccessCount = 1
|
||||
|
||||
// 应该只找到 cold(72h 前未访问且访问 ≤ 2)
|
||||
coldDocs := s.FindColdDocs(72*time.Hour, 2)
|
||||
if len(coldDocs) != 1 {
|
||||
t.Fatalf("expected 1 cold doc, got %d", len(coldDocs))
|
||||
}
|
||||
if coldDocs[0].Summary != "很久没用的信息" {
|
||||
t.Errorf("expected cold doc, got %s", coldDocs[0].Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentDocs(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_recent_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"})
|
||||
time.Sleep(time.Millisecond)
|
||||
s.Insert(&Doc{Summary: "第二条", Content: "b", Source: "manual"})
|
||||
|
||||
recent := s.RecentDocs(1)
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("expected 1 recent doc, got %d", len(recent))
|
||||
}
|
||||
if recent[0].Summary != "第二条" {
|
||||
t.Errorf("expected newest doc, got %s", recent[0].Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReindex(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_reindex_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
|
||||
s.Reindex()
|
||||
|
||||
results := s.Query("重索引", 5)
|
||||
if len(results) == 0 {
|
||||
t.Error("reindex should preserve searchability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEntries(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Source: "user", Content: "今天天气如何"},
|
||||
{Source: "user", Content: "明天会下雨吗"},
|
||||
}
|
||||
summary := summarizeEntries(entries)
|
||||
if summary == "" {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
if !contains(summary, "2") {
|
||||
t.Errorf("summary should mention count, got: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
kws := extractKeywords("今天天气很好")
|
||||
if len(kws) == 0 {
|
||||
t.Error("should extract keywords from Chinese text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTags(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Content: "我喜欢喝咖啡和编程"},
|
||||
}
|
||||
tags := extractTags(entries)
|
||||
if len(tags) == 0 {
|
||||
t.Error("should extract tags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertEmptyDoc(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_empty_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
doc := &Doc{Summary: "", Content: "", Source: "manual"}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.ID == "" {
|
||||
t.Error("doc ID should be generated even for empty content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_persist_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// 写
|
||||
s1 := NewStore(dir)
|
||||
s1.Start()
|
||||
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||
s1.Stop()
|
||||
|
||||
// 读
|
||||
s2 := NewStore(dir)
|
||||
s2.Start()
|
||||
defer s2.Stop()
|
||||
|
||||
stats := s2.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc after reload, got %d", stats["doc_count"])
|
||||
}
|
||||
|
||||
results := s2.Query("持久化", 5)
|
||||
if len(results) == 0 {
|
||||
t.Error("search should work after reload")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestFlushNoDirty(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_flush_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
|
||||
// 不插任何文档,flush 不应报错
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_remove_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"})
|
||||
s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"})
|
||||
|
||||
// 删除前应该有 2 个
|
||||
stats := s.Stats()
|
||||
if stats["doc_count"].(int) != 2 {
|
||||
t.Fatalf("expected 2 docs before remove, got %d", stats["doc_count"])
|
||||
}
|
||||
|
||||
// 遍历找到 "会被删除" 的 ID
|
||||
var rmID string
|
||||
for _, d := range s.docs {
|
||||
if d.Summary == "会被删除" {
|
||||
rmID = d.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if rmID == "" {
|
||||
t.Fatal("could not find test doc")
|
||||
}
|
||||
|
||||
s.Remove(rmID)
|
||||
|
||||
stats = s.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc after remove, got %d", stats["doc_count"])
|
||||
}
|
||||
|
||||
// 搜索不应再找到
|
||||
results := s.Query("删除", 5)
|
||||
if len(results) > 0 {
|
||||
t.Error("removed doc should not appear in search results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveNonexistent(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_rm_nonexist_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"})
|
||||
|
||||
// 删除不存在的 ID 不应 panic
|
||||
s.Remove("nonexistent_id")
|
||||
|
||||
stats := s.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc after remove nonexistent, got %d", stats["doc_count"])
|
||||
}
|
||||
}
|
||||
@ -400,7 +400,7 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
conds := []string{"r.status = 'active'"}
|
||||
conds := []string{"status = 'active'"}
|
||||
args := []interface{}{}
|
||||
|
||||
if v, ok := criteria["subject_contains"]; ok {
|
||||
@ -416,7 +416,7 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
conds = append(conds, fmt.Sprintf("r.source_id IN (%s)", placeholders(len(ids))))
|
||||
conds = append(conds, fmt.Sprintf("source_id IN (%s)", placeholders(len(ids))))
|
||||
args = append(args, ids...)
|
||||
}
|
||||
}
|
||||
@ -434,18 +434,18 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
conds = append(conds, fmt.Sprintf("r.target_id IN (%s)", placeholders(len(ids))))
|
||||
conds = append(conds, fmt.Sprintf("target_id IN (%s)", placeholders(len(ids))))
|
||||
args = append(args, ids...)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := criteria["relation_type"]; ok {
|
||||
conds = append(conds, "r.relation_type = ?")
|
||||
conds = append(conds, "relation_type = ?")
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if v, ok := criteria["session_id"]; ok {
|
||||
conds = append(conds, "r.session_id = ?")
|
||||
conds = append(conds, "session_id = ?")
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
|
||||
227
internal/memory/graph_test.go
Normal file
227
internal/memory/graph_test.go
Normal file
@ -0,0 +1,227 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestGraph(t *testing.T) *GraphDB {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "graph_test_*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
|
||||
g, err := NewGraphDB(f.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestNewGraphDB(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
stats, err := g.Introspect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats["entity_count"].(int) != 0 {
|
||||
t.Errorf("expected 0 entities, got %d", stats["entity_count"])
|
||||
}
|
||||
if stats["relation_count"].(int) != 0 {
|
||||
t.Errorf("expected 0 relations, got %d", stats["relation_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTriples(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
triples := []Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "编程"},
|
||||
{Subject: "张三", Relation: "居住", Object: "北京"},
|
||||
}
|
||||
|
||||
ec, rc, err := g.Commit(triples, "test_session", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 4 {
|
||||
t.Errorf("expected 4 entity ops (张三×2, 编程, 北京), got %d", ec)
|
||||
}
|
||||
if rc != 2 {
|
||||
t.Errorf("expected 2 relations, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitEmptyTriples(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
ec, rc, err := g.Commit(nil, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 0 || rc != 0 {
|
||||
t.Errorf("expected 0,0 for nil triples, got %d,%d", ec, rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallByKeywords(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "咖啡", Relation: "属于", Object: "饮品"},
|
||||
{Subject: "咖啡", Relation: "含有", Object: "咖啡因"},
|
||||
}, "session1", 0)
|
||||
|
||||
result, err := g.Recall([]string{"咖啡"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Entities) == 0 {
|
||||
t.Error("expected entities for keyword '咖啡'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallBySeedEntity(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "Go", Relation: "是", Object: "编程语言"},
|
||||
{Subject: "Go", Relation: "用于", Object: "后端开发"},
|
||||
}, "session2", 0)
|
||||
|
||||
result, err := g.Recall(nil, []string{"Go"}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Entities) == 0 {
|
||||
t.Error("expected entities for seed 'Go'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallWithDepth(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "甲", Relation: "认识", Object: "乙"},
|
||||
{Subject: "乙", Relation: "认识", Object: "丙"},
|
||||
}, "session3", 0)
|
||||
|
||||
result, err := g.Recall(nil, []string{"甲"}, 2, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Relations) == 0 {
|
||||
t.Error("expected relations with depth search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeHard(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "临时", Relation: "用于", Object: "测试"},
|
||||
}, "session4", 0)
|
||||
|
||||
n, err := g.Purge(map[string]string{"subject_contains": "临时"}, "hard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 purged relation, got %d", n)
|
||||
}
|
||||
|
||||
stats, _ := g.Introspect()
|
||||
if stats["relation_count"].(int) != 0 {
|
||||
t.Errorf("expected 0 relations after purge, got %d", stats["relation_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeSoft(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "可删除", Relation: "属于", Object: "测试"},
|
||||
}, "session5", 0)
|
||||
|
||||
n, err := g.Purge(map[string]string{"subject_contains": "可删除"}, "soft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 soft-deleted relation, got %d", n)
|
||||
}
|
||||
|
||||
stats, _ := g.Introspect()
|
||||
if stats["relation_count"].(int) != 0 {
|
||||
t.Errorf("expected 0 active relations after soft-delete, got %d", stats["relation_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 直接插入一条旧记录
|
||||
g.db.Exec(`INSERT INTO entities (id, name, type) VALUES (1, '旧数据', 'Concept')`)
|
||||
g.db.Exec(`INSERT INTO relations (source_id, target_id, relation_type, created_at)
|
||||
VALUES (1, 1, '包含', datetime('now', '-1 day'))`)
|
||||
|
||||
n, err := g.Archive(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 archived relation, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospectHotspots(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "热门话题", Relation: "关于", Object: "AI"},
|
||||
{Subject: "热门话题", Relation: "关于", Object: "机器学习"},
|
||||
{Subject: "冷门话题", Relation: "关于", Object: "旧技术"},
|
||||
}, "session7", 0)
|
||||
|
||||
stats, _ := g.Introspect()
|
||||
hotspots := stats["memory_hotspots"].([]map[string]interface{})
|
||||
if len(hotspots) == 0 {
|
||||
t.Error("expected hotspots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholders(t *testing.T) {
|
||||
if placeholders(0) != "NULL" {
|
||||
t.Errorf("expected NULL for n=0, got %s", placeholders(0))
|
||||
}
|
||||
if placeholders(1) != "?" {
|
||||
t.Errorf("expected '?' for n=1, got %s", placeholders(1))
|
||||
}
|
||||
if placeholders(3) != "?,?,?" {
|
||||
t.Errorf("expected '?,?,?' for n=3, got %s", placeholders(3))
|
||||
}
|
||||
}
|
||||
212
internal/memory/vector/store_test.go
Normal file
212
internal/memory/vector/store_test.go
Normal file
@ -0,0 +1,212 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractNGrams(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
maxN int
|
||||
check []string // 应包含
|
||||
}{
|
||||
{"hello", 2, []string{"h", "e", "l", "o", "he", "el", "ll", "lo"}},
|
||||
{"中文测试", 2, []string{"中", "文", "测", "试", "中文", "文测", "测试"}},
|
||||
{"a b", 1, []string{"a", "b"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractNGrams(tt.text, tt.maxN)
|
||||
for _, want := range tt.check {
|
||||
found := false
|
||||
for _, g := range got {
|
||||
if g == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("extractNGrams(%q, %d) missing %q; got %v", tt.text, tt.maxN, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNGramsNoDups(t *testing.T) {
|
||||
got := extractNGrams("aaaa", 2)
|
||||
seen := make(map[string]bool)
|
||||
for _, g := range got {
|
||||
if seen[g] {
|
||||
t.Errorf("duplicate ngram: %q", g)
|
||||
}
|
||||
seen[g] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosineSimilarity(t *testing.T) {
|
||||
a := Vector{"a": 1, "b": 2}
|
||||
b := Vector{"a": 2, "b": 4}
|
||||
sim := CosineSimilarity(a, b)
|
||||
if math.Abs(sim-1.0) > 1e-6 {
|
||||
t.Errorf("identical direction vectors should have cos=1, got %f", sim)
|
||||
}
|
||||
|
||||
c := Vector{"a": 1, "b": 0}
|
||||
d := Vector{"a": 0, "b": 1}
|
||||
sim = CosineSimilarity(c, d)
|
||||
if math.Abs(sim) > 1e-6 {
|
||||
t.Errorf("orthogonal vectors should have cos=0, got %f", sim)
|
||||
}
|
||||
|
||||
sim = CosineSimilarity(Vector{}, Vector{"a": 1})
|
||||
if sim != 0 {
|
||||
t.Errorf("zero vector should return 0, got %f", sim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFIDFVectorizer(t *testing.T) {
|
||||
v := NewTFIDFVectorizer(2)
|
||||
docs := []string{"今天天气很好", "今天心情不错", "明天要下雨"}
|
||||
v.Train(docs)
|
||||
|
||||
vec := v.Vectorize("今天")
|
||||
if len(vec) == 0 {
|
||||
t.Fatal("vector should not be empty")
|
||||
}
|
||||
if _, ok := vec["今天"]; !ok {
|
||||
t.Errorf("expected feature '今天' in vector")
|
||||
}
|
||||
|
||||
// 两个文档都有"今天",idf 应该较低
|
||||
idfToday := vec["今天"]
|
||||
vecSun := v.Vectorize("下雨")
|
||||
idfRain := vecSun["下雨"]
|
||||
if idfRain <= idfToday {
|
||||
t.Errorf("expected rare '下雨' to have higher idf than common '今天', got today=%f rain=%f", idfToday, idfRain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFIDFVectorizerEmpty(t *testing.T) {
|
||||
v := NewTFIDFVectorizer(2)
|
||||
v.Train(nil)
|
||||
vec := v.Vectorize("test")
|
||||
if len(vec) == 0 {
|
||||
t.Error("should produce features even without training")
|
||||
}
|
||||
|
||||
// 未训练时所有 idf=1, 仅有 tf 归一化
|
||||
for _, w := range vec {
|
||||
if w < 0 {
|
||||
t.Errorf("weight should be non-negative, got %f", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertedIndex(t *testing.T) {
|
||||
idx := NewInvertedIndex()
|
||||
|
||||
idx.Add("doc1", Vector{"a": 1, "b": 2})
|
||||
idx.Add("doc2", Vector{"b": 1, "c": 3})
|
||||
|
||||
results := idx.Search(Vector{"a": 1}, 10)
|
||||
if len(results) != 1 || results[0] != "doc1" {
|
||||
t.Errorf("search 'a' should return doc1 only, got %v", results)
|
||||
}
|
||||
|
||||
results = idx.Search(Vector{"b": 1}, 10)
|
||||
if len(results) != 2 {
|
||||
t.Errorf("search 'b' should return 2 docs, got %v", results)
|
||||
}
|
||||
|
||||
idx.Remove("doc1")
|
||||
results = idx.Search(Vector{"a": 1}, 10)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("after remove, search 'a' should return empty, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInsertAndSearch(t *testing.T) {
|
||||
s := NewStore()
|
||||
v := NewTFIDFVectorizer(2)
|
||||
v.Train([]string{"hello world", "goodbye world"})
|
||||
|
||||
s.Insert("1", "hello world", v.Vectorize("hello world"), nil)
|
||||
s.Insert("2", "goodbye world", v.Vectorize("goodbye world"), nil)
|
||||
|
||||
if s.Size() != 2 {
|
||||
t.Errorf("expected size 2, got %d", s.Size())
|
||||
}
|
||||
|
||||
results := s.Search(v.Vectorize("hello"), 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results")
|
||||
}
|
||||
if results[0].ID != "1" {
|
||||
t.Errorf("expected doc1 as top result for 'hello', got %s", results[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemove(t *testing.T) {
|
||||
s := NewStore()
|
||||
v := NewTFIDFVectorizer(1)
|
||||
v.Train([]string{"a"})
|
||||
|
||||
s.Insert("1", "a", v.Vectorize("a"), nil)
|
||||
s.Insert("2", "a", v.Vectorize("a"), nil)
|
||||
s.Remove("1")
|
||||
|
||||
if s.Size() != 1 {
|
||||
t.Errorf("after remove, size should be 1, got %d", s.Size())
|
||||
}
|
||||
|
||||
results := s.Search(v.Vectorize("a"), 5)
|
||||
if len(results) != 1 || results[0].ID != "2" {
|
||||
t.Errorf("only doc2 should remain, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreEmpty(t *testing.T) {
|
||||
s := NewStore()
|
||||
results := s.Search(Vector{"a": 1}, 5)
|
||||
if results != nil {
|
||||
t.Errorf("empty store should return nil, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAll(t *testing.T) {
|
||||
s := NewStore()
|
||||
v := NewTFIDFVectorizer(1)
|
||||
v.Train([]string{"a", "b"})
|
||||
|
||||
s.Insert("1", "a", v.Vectorize("a"), map[string]string{"k": "v"})
|
||||
s.Insert("2", "b", v.Vectorize("b"), nil)
|
||||
|
||||
all := s.All()
|
||||
if len(all) != 2 {
|
||||
t.Errorf("All() should return 2 docs, got %d", len(all))
|
||||
}
|
||||
if all[0].Meta["k"] != "v" {
|
||||
t.Errorf("meta should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCosineSimilarity(b *testing.B) {
|
||||
va := Vector{}
|
||||
vb := Vector{}
|
||||
for i := 0; i < 100; i++ {
|
||||
f := string(rune('a' + i%26))
|
||||
va[f] = float64(i)
|
||||
vb[f] = float64(100 - i)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
CosineSimilarity(va, vb)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExtractNGrams(b *testing.B) {
|
||||
text := "今天天气很好,适合出去散步。明天可能下雨,记得带伞。"
|
||||
for i := 0; i < b.N; i++ {
|
||||
extractNGrams(text, 2)
|
||||
}
|
||||
}
|
||||
@ -105,16 +105,19 @@ func (c *Client) reconnect() {
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
defer c.connected.Store(false)
|
||||
defer c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
defer func() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
log.Printf("[onebot] read loop: not connected")
|
||||
go c.reconnect()
|
||||
return
|
||||
}
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[onebot] read error: %v", err)
|
||||
go c.reconnect()
|
||||
@ -125,7 +128,10 @@ func (c *Client) readLoop() {
|
||||
var resp ActionResponse
|
||||
if err := json.Unmarshal(message, &resp); err == nil && resp.Echo != "" {
|
||||
if ch, ok := c.pending.Load(resp.Echo); ok {
|
||||
ch.(chan *ActionResponse) <- &resp
|
||||
select {
|
||||
case ch.(chan *ActionResponse) <- &resp:
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@ -189,9 +189,7 @@ func (d *Device) handleEvent(evt *Event) {
|
||||
payload["label"] = fmt.Sprintf("private:%d", evt.UserID)
|
||||
}
|
||||
|
||||
d.iom.InjectText(source, text)
|
||||
// 同时注册输出路由:QQ 消息默认回复到 QQ 通道
|
||||
d.iom.RegisterOutputRoute(source, d.name)
|
||||
d.iom.InjectTextTo(source, d.name, text)
|
||||
|
||||
case "notice":
|
||||
log.Printf("[onebot] notice from %s: type=%s", d.name, evt.NoticeType)
|
||||
|
||||
165
internal/onebot/types_test.go
Normal file
165
internal/onebot/types_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageText(t *testing.T) {
|
||||
seg := MessageText("hello")
|
||||
if seg.Type != "text" {
|
||||
t.Errorf("expected type 'text', got %q", seg.Type)
|
||||
}
|
||||
if seg.Data["text"] != "hello" {
|
||||
t.Errorf("expected data.text 'hello', got %q", seg.Data["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageImage(t *testing.T) {
|
||||
seg := MessageImage("test.jpg")
|
||||
if seg.Type != "image" {
|
||||
t.Errorf("expected 'image', got %q", seg.Type)
|
||||
}
|
||||
if seg.Data["file"] != "test.jpg" {
|
||||
t.Errorf("expected 'test.jpg', got %q", seg.Data["file"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageAt(t *testing.T) {
|
||||
seg := MessageAt(123456)
|
||||
if seg.Type != "at" {
|
||||
t.Errorf("expected 'at', got %q", seg.Type)
|
||||
}
|
||||
if seg.Data["qq"] != "123456" {
|
||||
t.Errorf("expected '123456', got %q", seg.Data["qq"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventMarshal(t *testing.T) {
|
||||
evt := Event{
|
||||
Time: 1234567890,
|
||||
SelfID: 10001,
|
||||
PostType: "message",
|
||||
MessageType: "group",
|
||||
GroupID: 999,
|
||||
UserID: 777,
|
||||
RawMessage: "hello",
|
||||
Sender: &Sender{
|
||||
UserID: 777,
|
||||
Nickname: "TestUser",
|
||||
Role: "member",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded Event
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if decoded.PostType != "message" {
|
||||
t.Errorf("expected 'message', got %q", decoded.PostType)
|
||||
}
|
||||
if decoded.GroupID != 999 {
|
||||
t.Errorf("expected 999, got %d", decoded.GroupID)
|
||||
}
|
||||
if decoded.Sender.Nickname != "TestUser" {
|
||||
t.Errorf("expected 'TestUser', got %q", decoded.Sender.Nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventMessagePrivate(t *testing.T) {
|
||||
evt := Event{
|
||||
PostType: "message",
|
||||
MessageType: "private",
|
||||
UserID: 123,
|
||||
RawMessage: "hi",
|
||||
}
|
||||
|
||||
if evt.PostType != "message" || evt.MessageType != "private" {
|
||||
t.Errorf("unexpected event type: %s/%s", evt.PostType, evt.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionMarshal(t *testing.T) {
|
||||
action := Action{
|
||||
Action: "send_private_msg",
|
||||
Params: map[string]interface{}{
|
||||
"user_id": 123,
|
||||
"message": "hello",
|
||||
},
|
||||
Echo: "1",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(action)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded Action
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if decoded.Action != "send_private_msg" {
|
||||
t.Errorf("expected 'send_private_msg', got %q", decoded.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionResponse(t *testing.T) {
|
||||
resp := ActionResponse{
|
||||
Status: "ok",
|
||||
RetCode: 0,
|
||||
Data: map[string]interface{}{
|
||||
"message_id": 12345,
|
||||
},
|
||||
Echo: "1",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
var decoded ActionResponse
|
||||
json.Unmarshal(data, &decoded)
|
||||
|
||||
if decoded.Status != "ok" {
|
||||
t.Errorf("expected 'ok', got %q", decoded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatus(t *testing.T) {
|
||||
s := Status{
|
||||
AppInitialized: true,
|
||||
AppEnabled: true,
|
||||
Online: true,
|
||||
Good: true,
|
||||
}
|
||||
|
||||
if !s.Online || !s.Good {
|
||||
t.Error("status should be online and good")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventMetaHeartbeat(t *testing.T) {
|
||||
evt := Event{
|
||||
PostType: "meta_event",
|
||||
MetaEventType: "heartbeat",
|
||||
Interval: 3000,
|
||||
Status: &Status{
|
||||
Online: true,
|
||||
Good: true,
|
||||
},
|
||||
}
|
||||
|
||||
if evt.PostType != "meta_event" {
|
||||
t.Errorf("expected 'meta_event', got %q", evt.PostType)
|
||||
}
|
||||
if evt.MetaEventType != "heartbeat" {
|
||||
t.Errorf("expected 'heartbeat', got %q", evt.MetaEventType)
|
||||
}
|
||||
if !evt.Status.Online {
|
||||
t.Error("should be online")
|
||||
}
|
||||
}
|
||||
@ -198,18 +198,9 @@ func (r *Registry) Register(p Plugin) {
|
||||
return // 纯技能插件,无 IO 通道
|
||||
}
|
||||
|
||||
// 自动注册输出路由
|
||||
if cfg := p.IOConfig(); cfg != nil {
|
||||
log.Printf("[plugin] io device %s active (type=%s, caps=%v)",
|
||||
p.Name(), cfg.Type, cfg.OutputCaps)
|
||||
if cfg.InputRoute != "" {
|
||||
outputRoute := cfg.OutputRoute
|
||||
if outputRoute == "" {
|
||||
outputRoute = cfg.InputRoute
|
||||
}
|
||||
r.ioMgr.RegisterOutputRoute(cfg.InputRoute, outputRoute)
|
||||
log.Printf("[plugin] route: %s → %s", cfg.InputRoute, outputRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -300,7 +291,6 @@ func (r *Registry) Reload(dir string) (string, error) {
|
||||
|
||||
// 2. 启动新设备的 IO 通道
|
||||
newDevices := make(map[string]agentIO.Device)
|
||||
newRoutes := make(map[string]string)
|
||||
for _, lp := range loaded {
|
||||
if lp.err != nil {
|
||||
log.Printf("[plugin] skip %s: %v", lp.name, lp.err)
|
||||
@ -313,15 +303,6 @@ func (r *Registry) Reload(dir string) (string, error) {
|
||||
if dev != nil {
|
||||
dev.Start() // 新设备预先启动
|
||||
newDevices[lp.name] = dev
|
||||
if cfg := lp.p.IOConfig(); cfg != nil {
|
||||
if cfg.InputRoute != "" {
|
||||
out := cfg.OutputRoute
|
||||
if out == "" {
|
||||
out = cfg.InputRoute
|
||||
}
|
||||
newRoutes[cfg.InputRoute] = out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -330,7 +311,7 @@ func (r *Registry) Reload(dir string) (string, error) {
|
||||
var oldPlugins map[string]Plugin
|
||||
if r.ioMgr != nil {
|
||||
// 获取旧设备并原子替换
|
||||
oldDevices := r.ioMgr.AtomicSwapDevices(newDevices, newRoutes)
|
||||
oldDevices := r.ioMgr.AtomicSwapDevices(newDevices)
|
||||
// 停止旧设备
|
||||
for _, dev := range oldDevices {
|
||||
go dev.Stop()
|
||||
@ -672,14 +653,8 @@ func (d *PluginDevice) Description() string { return d.plugin.Description(
|
||||
|
||||
func (d *PluginDevice) Tools() []agentIO.ToolDef {
|
||||
pts := d.plugin.Tools()
|
||||
defs := make([]agentIO.ToolDef, 0, len(pts))
|
||||
for _, t := range pts {
|
||||
defs = append(defs, agentIO.ToolDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
})
|
||||
}
|
||||
defs := make([]agentIO.ToolDef, len(pts))
|
||||
copy(defs, pts)
|
||||
return defs
|
||||
}
|
||||
|
||||
@ -713,10 +688,21 @@ func extractDescription(content string) string {
|
||||
// extractField finds `field: value` pattern in content
|
||||
func extractField(content string, field string) string {
|
||||
prefix := field + ":"
|
||||
lowerPrefix := toLower(prefix)
|
||||
for _, line := range splitLines(content) {
|
||||
trimmed := trimSpace(line)
|
||||
if hasPrefix(toLower(trimmed), prefix) {
|
||||
return trimSpace(trimPrefix(trimmed, prefix))
|
||||
if hasPrefix(toLower(trimmed), lowerPrefix) {
|
||||
// 找到冒号位置,提取冒号后的内容
|
||||
colonIdx := -1
|
||||
for i := 0; i < len(trimmed); i++ {
|
||||
if trimmed[i] == ':' {
|
||||
colonIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if colonIdx >= 0 {
|
||||
return trimSpace(trimmed[colonIdx+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
197
internal/plugin/plugin_test.go
Normal file
197
internal/plugin/plugin_test.go
Normal file
@ -0,0 +1,197 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractDescription(t *testing.T) {
|
||||
content := "# Plugin\n\nThis is a test plugin.\nversion: 1.0.0"
|
||||
desc := extractDescription(content)
|
||||
if desc != "This is a test plugin." {
|
||||
t.Errorf("expected 'This is a test plugin.', got %q", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractField(t *testing.T) {
|
||||
content := "version: 1.0.0\nauthor: test\nio_type: io"
|
||||
if v := extractField(content, "version"); v != "1.0.0" {
|
||||
t.Errorf("expected '1.0.0', got %q", v)
|
||||
}
|
||||
if v := extractField(content, "author"); v != "test" {
|
||||
t.Errorf("expected 'test', got %q", v)
|
||||
}
|
||||
if v := extractField(content, "io_type"); v != "io" {
|
||||
t.Errorf("expected 'io', got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFieldCaseInsensitive(t *testing.T) {
|
||||
content := "Version: 2.0.0"
|
||||
if v := extractField(content, "version"); v != "2.0.0" {
|
||||
t.Errorf("expected '2.0.0', got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFieldMissing(t *testing.T) {
|
||||
if v := extractField("no fields here", "version"); v != "" {
|
||||
t.Errorf("expected '', got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIOConfigFull(t *testing.T) {
|
||||
content := `# QQ Plugin
|
||||
io_type: io
|
||||
io_input_route: qq
|
||||
io_output_route: qq
|
||||
io_output_caps: text,file,image`
|
||||
|
||||
cfg := extractIOConfig(content)
|
||||
if cfg == nil {
|
||||
t.Fatal("expected IOConfig")
|
||||
}
|
||||
if cfg.Type != "io" {
|
||||
t.Errorf("expected type 'io', got %q", cfg.Type)
|
||||
}
|
||||
if cfg.InputRoute != "qq" {
|
||||
t.Errorf("expected input_route 'qq', got %q", cfg.InputRoute)
|
||||
}
|
||||
if cfg.OutputRoute != "qq" {
|
||||
t.Errorf("expected output_route 'qq', got %q", cfg.OutputRoute)
|
||||
}
|
||||
if len(cfg.OutputCaps) != 3 || cfg.OutputCaps[0] != "text" {
|
||||
t.Errorf("expected caps [text file image], got %v", cfg.OutputCaps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIOConfigMinimal(t *testing.T) {
|
||||
content := `# Plugin
|
||||
io_type: input`
|
||||
cfg := extractIOConfig(content)
|
||||
if cfg == nil {
|
||||
t.Fatal("expected IOConfig")
|
||||
}
|
||||
if cfg.Type != "input" {
|
||||
t.Errorf("expected 'input', got %q", cfg.Type)
|
||||
}
|
||||
if cfg.InputRoute != "" {
|
||||
t.Errorf("expected empty input_route, got %q", cfg.InputRoute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIOConfigNil(t *testing.T) {
|
||||
cfg := extractIOConfig("# No IO config here")
|
||||
if cfg != nil {
|
||||
t.Errorf("expected nil, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolDefsBasic(t *testing.T) {
|
||||
content := `# Plugin
|
||||
description
|
||||
|
||||
## hello_tool
|
||||
Say hello to someone
|
||||
- name: The person to greet
|
||||
|
||||
## add_numbers
|
||||
Add two numbers together
|
||||
- a: First number
|
||||
- b: Second number`
|
||||
|
||||
defs := extractToolDefs(content)
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("expected 2 tools, got %d", len(defs))
|
||||
}
|
||||
|
||||
if defs[0].Name != "hello_tool" {
|
||||
t.Errorf("expected 'hello_tool', got %q", defs[0].Name)
|
||||
}
|
||||
if defs[0].Description != "Say hello to someone" {
|
||||
t.Errorf("expected 'Say hello to someone', got %q", defs[0].Description)
|
||||
}
|
||||
|
||||
props := defs[0].Parameters["properties"].(map[string]interface{})
|
||||
if _, ok := props["name"]; !ok {
|
||||
t.Errorf("expected 'name' parameter")
|
||||
}
|
||||
p := props["name"].(map[string]interface{})
|
||||
if p["description"] != "The person to greet" {
|
||||
t.Errorf("expected desc 'The person to greet', got %q", p["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolDefsToolWithColon(t *testing.T) {
|
||||
content := `# Plugin
|
||||
|
||||
### Tool: my_tool
|
||||
Do something
|
||||
- param: Description`
|
||||
|
||||
defs := extractToolDefs(content)
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(defs))
|
||||
}
|
||||
if defs[0].Name != "my_tool" {
|
||||
t.Errorf("expected 'my_tool', got %q", defs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolDefsSkipsNonToolSections(t *testing.T) {
|
||||
content := `# Plugin
|
||||
|
||||
## Usage
|
||||
This is how to use the plugin
|
||||
|
||||
## Examples
|
||||
Some examples here
|
||||
|
||||
## real_tool
|
||||
This is an actual tool
|
||||
- param: value`
|
||||
|
||||
defs := extractToolDefs(content)
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 tool (non-tool sections skipped), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Name != "real_tool" {
|
||||
t.Errorf("expected 'real_tool', got %q", defs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolDefsEmpty(t *testing.T) {
|
||||
defs := extractToolDefs("# Just a title\nNo tools here")
|
||||
if len(defs) != 0 {
|
||||
t.Errorf("expected 0 tools, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolDefsCodeBlock(t *testing.T) {
|
||||
content := "# Plugin\n\n## my_tool\nA tool\n- param: desc\n\n```\n## not_a_tool\nThis is inside a code block\n```\n\n## another_tool\nAnother one\n- x: y"
|
||||
|
||||
defs := extractToolDefs(content)
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("expected 2 tools (code block skipped), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Name != "my_tool" || defs[1].Name != "another_tool" {
|
||||
t.Errorf("unexpected tool names: %v", defs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolDefsNoParams(t *testing.T) {
|
||||
content := `# Plugin
|
||||
|
||||
## simple_tool
|
||||
A tool with no parameters`
|
||||
|
||||
defs := extractToolDefs(content)
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(defs))
|
||||
}
|
||||
if defs[0].Name != "simple_tool" {
|
||||
t.Errorf("expected 'simple_tool', got %q", defs[0].Name)
|
||||
}
|
||||
props := defs[0].Parameters["properties"].(map[string]interface{})
|
||||
if len(props) != 0 {
|
||||
t.Errorf("expected no params, got %d", len(props))
|
||||
}
|
||||
}
|
||||
@ -65,8 +65,8 @@ func (d *Daemon) Shutdown() {
|
||||
log.Println("[homed] shutting down...")
|
||||
d.cancel()
|
||||
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
for id, agent := range d.agents {
|
||||
if agent.state == types.AgentStateRunning {
|
||||
|
||||
209
internal/supervisor/daemon_test.go
Normal file
209
internal/supervisor/daemon_test.go
Normal file
@ -0,0 +1,209 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
if d == nil {
|
||||
t.Fatal("Daemon should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndShutdown(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
if err := d.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.Shutdown()
|
||||
}
|
||||
|
||||
func TestRegisterAgent(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
d.RegisterAgent("test_agent")
|
||||
|
||||
agents := d.ListAgents()
|
||||
if len(agents) != 1 {
|
||||
t.Fatalf("expected 1 agent, got %d", len(agents))
|
||||
}
|
||||
if agents[0].ID != "test_agent" {
|
||||
t.Errorf("expected 'test_agent', got %q", agents[0].ID)
|
||||
}
|
||||
if agents[0].State != types.AgentStateRunning {
|
||||
t.Errorf("expected Running state, got %v", agents[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentStatus(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Defaults: types.AgentConfig{
|
||||
RollbackPolicy: types.RollbackPolicy{MaxRetries: 5},
|
||||
},
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
d.RegisterAgent("agent1")
|
||||
|
||||
status, err := d.GetAgentStatus("agent1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.ID != "agent1" {
|
||||
t.Errorf("expected 'agent1', got %q", status.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentStatusNotFound(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
_, err := d.GetAgentStatus("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgents(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
d.RegisterAgent("a")
|
||||
d.RegisterAgent("b")
|
||||
|
||||
agents := d.ListAgents()
|
||||
if len(agents) != 2 {
|
||||
t.Errorf("expected 2 agents, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTracker(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
tr := tracker.NewTracker("/tmp/test_tracker_data", "/tmp/test_tracker_work")
|
||||
d.SetTracker(tr)
|
||||
|
||||
// RegisterAgent should use tracker
|
||||
d.RegisterAgent("tracked_agent")
|
||||
status, _ := d.GetAgentStatus("tracked_agent")
|
||||
if status.TrackerStats == nil {
|
||||
t.Error("expected tracker stats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackAgent(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
tr := tracker.NewTracker("/tmp/test_rb_data", "/tmp/test_rb_work")
|
||||
d.SetTracker(tr)
|
||||
|
||||
err := d.RollbackAgent("any", "snap1")
|
||||
if err == nil {
|
||||
t.Log("rollback succeeded (tracker may be unmounted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackAgentNoTracker(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
err := d.RollbackAgent("main", "snap1")
|
||||
if err == nil {
|
||||
t.Error("expected error when no tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreActionSnapshot(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
_, err := d.PreActionSnapshot("main")
|
||||
if err == nil {
|
||||
t.Error("expected error (snapshot not supported)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAgentMultiple(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
d.RegisterAgent(types.AgentID(string(rune('a'+i))))
|
||||
}
|
||||
|
||||
agents := d.ListAgents()
|
||||
if len(agents) != 5 {
|
||||
t.Errorf("expected 5 agents, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
d := New(cfg)
|
||||
|
||||
// Register from multiple goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
go d.RegisterAgent(types.AgentID(string(rune('a' + i))))
|
||||
}
|
||||
}
|
||||
@ -90,6 +90,9 @@ func captureFSState(root string) (*FSState, error) {
|
||||
|
||||
func diffStates(before, after *FSState) []FileChange {
|
||||
var changes []FileChange
|
||||
if before == nil || after == nil {
|
||||
return changes
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for path, afterFile := range after.Files {
|
||||
|
||||
314
internal/tracker/tracker_test.go
Normal file
314
internal/tracker/tracker_test.go
Normal file
@ -0,0 +1,314 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTracker(t *testing.T) {
|
||||
tr := NewTracker("/tmp/tracker_data", "/tmp/tracker_work")
|
||||
if tr == nil {
|
||||
t.Fatal("tracker should not be nil")
|
||||
}
|
||||
if tr.mergeDir != "/tmp/tracker_work/merged" {
|
||||
t.Errorf("unexpected mergeDir: %s", tr.mergeDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
|
||||
if err := tr.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range []string{tr.lowerDir, tr.upperDir, tr.mergeDir} {
|
||||
if _, err := os.Stat(d); os.IsNotExist(err) {
|
||||
t.Errorf("dir %s should exist", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChangeSet(t *testing.T) {
|
||||
cs := NewChangeSet("test_action")
|
||||
if cs.Action != "test_action" {
|
||||
t.Errorf("expected 'test_action', got %q", cs.Action)
|
||||
}
|
||||
if cs.ID == "" {
|
||||
t.Error("ID should not be empty")
|
||||
}
|
||||
if len(cs.Files) != 0 {
|
||||
t.Errorf("expected 0 files, got %d", len(cs.Files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHash(t *testing.T) {
|
||||
f := t.TempDir() + "/test.txt"
|
||||
if err := os.WriteFile(f, []byte("hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hash, size, err := fileHash(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size != 5 {
|
||||
t.Errorf("expected size 5, got %d", size)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Error("hash should not be empty")
|
||||
}
|
||||
// SHA256 of "hello"
|
||||
expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
if hash != expected {
|
||||
t.Errorf("expected hash %s, got %s", expected, hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHashNotFound(t *testing.T) {
|
||||
_, _, err := fileHash("/nonexistent/file")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileInfo(t *testing.T) {
|
||||
f := t.TempDir() + "/info.txt"
|
||||
os.WriteFile(f, []byte("test"), 0644)
|
||||
|
||||
size, modTime, err := fileInfo(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size != 4 {
|
||||
t.Errorf("expected size 4, got %d", size)
|
||||
}
|
||||
if modTime.IsZero() {
|
||||
t.Error("modTime should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileInfoNotFound(t *testing.T) {
|
||||
_, _, err := fileInfo("/nonexistent/file")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFSStateEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state, err := captureFSState(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Files) != 0 {
|
||||
t.Errorf("expected 0 files, got %d", len(state.Files))
|
||||
}
|
||||
if state.Root != dir {
|
||||
t.Errorf("expected root %s, got %s", dir, state.Root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFSState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bbb"), 0644)
|
||||
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "sub", "c.txt"), []byte("ccc"), 0644)
|
||||
|
||||
state, err := captureFSState(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Files) != 3 {
|
||||
t.Errorf("expected 3 files, got %d", len(state.Files))
|
||||
}
|
||||
// should contain relative paths
|
||||
for _, p := range []string{"a.txt", "b.txt", "sub/c.txt"} {
|
||||
if _, ok := state.Files[p]; !ok {
|
||||
t.Errorf("missing file %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatesCreated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before, _ := captureFSState(dir)
|
||||
os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new file"), 0644)
|
||||
after, _ := captureFSState(dir)
|
||||
|
||||
changes := diffStates(before, after)
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("expected 1 change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].Type != ChangeFileCreated {
|
||||
t.Errorf("expected created, got %s", changes[0].Type)
|
||||
}
|
||||
if changes[0].Path != "new.txt" {
|
||||
t.Errorf("expected 'new.txt', got %s", changes[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatesModified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("original"), 0644)
|
||||
before, _ := captureFSState(dir)
|
||||
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("modified"), 0644)
|
||||
after, _ := captureFSState(dir)
|
||||
|
||||
changes := diffStates(before, after)
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("expected 1 change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].Type != ChangeFileModified {
|
||||
t.Errorf("expected modified, got %s", changes[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatesDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("delete me"), 0644)
|
||||
before, _ := captureFSState(dir)
|
||||
os.Remove(filepath.Join(dir, "f.txt"))
|
||||
after, _ := captureFSState(dir)
|
||||
|
||||
changes := diffStates(before, after)
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("expected 1 change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].Type != ChangeFileDeleted {
|
||||
t.Errorf("expected deleted, got %s", changes[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatesNoChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("stable"), 0644)
|
||||
before, _ := captureFSState(dir)
|
||||
after, _ := captureFSState(dir)
|
||||
|
||||
changes := diffStates(before, after)
|
||||
if len(changes) != 0 {
|
||||
t.Errorf("expected 0 changes, got %d", len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatesMixed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("unchanged"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "delete.txt"), []byte("gone"), 0644)
|
||||
before, _ := captureFSState(dir)
|
||||
|
||||
os.Remove(filepath.Join(dir, "delete.txt"))
|
||||
os.WriteFile(filepath.Join(dir, "add.txt"), []byte("new"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("changed"), 0644)
|
||||
after, _ := captureFSState(dir)
|
||||
|
||||
changes := diffStates(before, after)
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("expected 3 changes, got %d", len(changes))
|
||||
}
|
||||
|
||||
types := make(map[ChangeType]bool)
|
||||
for _, c := range changes {
|
||||
types[c.Type] = true
|
||||
}
|
||||
if !types[ChangeFileCreated] {
|
||||
t.Error("missing created")
|
||||
}
|
||||
if !types[ChangeFileModified] {
|
||||
t.Error("missing modified")
|
||||
}
|
||||
if !types[ChangeFileDeleted] {
|
||||
t.Error("missing deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatesNilBefore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
after, _ := captureFSState(dir)
|
||||
|
||||
changes := diffStates(nil, after)
|
||||
if len(changes) != 0 {
|
||||
t.Errorf("expected 0 changes when before is nil, got %d", len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreActionResetsBefore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
|
||||
tr.Init()
|
||||
|
||||
// capture initial state
|
||||
cs := tr.PreAction("test")
|
||||
if cs == nil {
|
||||
t.Fatal("changeset should not be nil")
|
||||
}
|
||||
if cs.Action != "test" {
|
||||
t.Errorf("expected 'test', got %q", cs.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostActionNoChanges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
|
||||
tr.Init()
|
||||
|
||||
tr.PreAction("noop")
|
||||
cs := tr.PostAction("noop")
|
||||
if cs == nil {
|
||||
t.Fatal("changeset should not be nil")
|
||||
}
|
||||
if len(cs.Files) != 0 {
|
||||
t.Errorf("expected 0 files for noop, got %d", len(cs.Files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
|
||||
tr.Init()
|
||||
|
||||
stats := tr.Stats()
|
||||
if stats["mounted"].(bool) {
|
||||
t.Error("should not be mounted")
|
||||
}
|
||||
if stats["change_sets"].(int) != 0 {
|
||||
t.Errorf("expected 0 changesets, got %d", stats["change_sets"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDir(t *testing.T) {
|
||||
tr := NewTracker("/data", "/work")
|
||||
if tr.MergeDir() != "/work/merged" {
|
||||
t.Errorf("unexpected mergeDir: %s", tr.MergeDir())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasChanges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
|
||||
tr.Init()
|
||||
|
||||
if tr.HasChanges() {
|
||||
t.Error("should have no changes initially")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeSetsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
|
||||
tr.Init()
|
||||
|
||||
cs := tr.ChangeSets()
|
||||
if len(cs) != 0 {
|
||||
t.Errorf("expected 0 changesets, got %d", len(cs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureDirNotExist(t *testing.T) {
|
||||
_, err := captureFSState("/tmp/nonexistent_test_dir_12345")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user