refactor: remove IO route mapping, add HTTP API tests, system prompt update

This commit is contained in:
root
2026-07-02 15:47:16 +08:00
parent 3d7d24ebb8
commit 304c3ae294
25 changed files with 3427 additions and 110 deletions

View File

@ -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)

View File

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

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

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

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

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

View File

@ -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 → speakervoice_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 {

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

View File

@ -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")

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

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

View File

@ -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 {

View 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
// 应该只找到 cold72h 前未访问且访问 ≤ 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"])
}
}

View File

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

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

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

View File

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

View File

@ -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)

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

View File

@ -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 ""

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

View File

@ -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 {

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

View File

@ -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 {

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