diff --git a/agents/robot_test.go b/agents/robot_test.go new file mode 100644 index 0000000..0076347 --- /dev/null +++ b/agents/robot_test.go @@ -0,0 +1,290 @@ +package agents + +import ( + "context" + "log/slog" + "os" + "strings" + "testing" + "time" + + "github.com/enmanuel/agents/internal/config" + "github.com/enmanuel/agents/pkg/command" + "github.com/enmanuel/agents/pkg/decision" +) + +// newTestRobot creates a minimal Robot for testing without requiring +// Matrix or network. Fields are initialized directly. +func newTestRobot(t *testing.T) *Robot { + t.Helper() + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ + ID: "test-robot", + Name: "Test Robot", + Type: "robot", + Description: "robot for tests", + Version: "1.0.0", + }, + } + r := &Robot{ + cfg: cfg, + logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), + done: make(chan struct{}), + commands: make(map[string]CommandHandler), + cmdAliases: command.BuiltinNames(), + startTime: time.Now(), + } + r.registerBuiltinCommands() + return r +} + +// TestRobotCmdHelp verifies !help lists built-in commands. +func TestRobotCmdHelp(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdHelp(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Comandos disponibles") { + t.Error("help reply missing header") + } + for _, cmd := range []string{"help", "ping", "status", "info", "version"} { + if !strings.Contains(reply, "!"+cmd) { + t.Errorf("help reply missing command !%s", cmd) + } + } + // Robot should NOT show agent-only commands + for _, cmd := range []string{"!tools", "!tool", "!clear", "!prompts"} { + if strings.Contains(reply, cmd+"`") { + t.Errorf("help reply should not contain agent-only command %s", cmd) + } + } +} + +// TestRobotCmdHelpWithCustom verifies !help includes custom commands. +func TestRobotCmdHelpWithCustom(t *testing.T) { + r := newTestRobot(t) + + r.RegisterCommand( + command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "!deploy "}, + func(_ context.Context, _ decision.MessageContext) string { return "deployed" }, + ) + + reply := r.cmdHelp(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Comandos del robot") { + t.Error("help reply missing 'Comandos del robot' section") + } + if !strings.Contains(reply, "!deploy") { + t.Error("help reply missing custom command !deploy") + } +} + +// TestRobotCmdPing verifies !ping returns pong. +func TestRobotCmdPing(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdPing(context.Background(), decision.MessageContext{}) + + if !strings.HasPrefix(reply, "pong") { + t.Errorf("ping reply should start with 'pong', got %q", reply) + } +} + +// TestRobotCmdStatus verifies !status includes type and uptime. +func TestRobotCmdStatus(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdStatus(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "robot") { + t.Error("status reply missing type 'robot'") + } + if !strings.Contains(reply, "Uptime") { + t.Error("status reply missing Uptime") + } +} + +// TestRobotCmdInfo verifies !info shows robot identity. +func TestRobotCmdInfo(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdInfo(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Test Robot") { + t.Error("info reply missing robot name") + } + if !strings.Contains(reply, "test-robot") { + t.Error("info reply missing robot ID") + } + if !strings.Contains(reply, "robot") { + t.Error("info reply missing type 'robot'") + } +} + +// TestRobotCmdVersion verifies !version returns name + version. +func TestRobotCmdVersion(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdVersion(context.Background(), decision.MessageContext{}) + + if reply != "Test Robot 1.0.0" { + t.Errorf("version reply = %q, want %q", reply, "Test Robot 1.0.0") + } +} + +// TestRobotIgnoresNonCommand verifies that handleEvent silently ignores +// non-command messages (no error, no reply). +func TestRobotIgnoresNonCommand(t *testing.T) { + r := newTestRobot(t) + + // handleEvent with empty Command should not panic. + // Since we can't easily mock the Matrix client, we verify the method + // returns without error by checking it doesn't reach command dispatch. + msgCtx := decision.MessageContext{ + Command: "", // non-command + Content: "hola bot", + } + + // The robot should just return without doing anything. + // We can't call handleEvent directly because it needs an *event.Event, + // but we can verify the logic by checking the command map behavior. + if _, ok := r.commands[""]; ok { + t.Error("empty string should not be a registered command") + } + + // Verify no commands match empty string. + if _, ok := r.cmdAliases[""]; ok { + t.Error("empty string should not be in aliases") + } + + _ = msgCtx // used to document test intent +} + +// TestRobotCustomCommand verifies RegisterCommand works and the handler executes. +func TestRobotCustomCommand(t *testing.T) { + r := newTestRobot(t) + + executed := false + r.RegisterCommand( + command.Spec{ + Name: "deploy", + Aliases: []string{"d"}, + Description: "Deploy to env", + Usage: "!deploy ", + }, + func(_ context.Context, msgCtx decision.MessageContext) string { + executed = true + if len(msgCtx.Args) == 0 { + return "Uso: !deploy " + } + return "Deploying to " + msgCtx.Args[0] + }, + ) + + // Verify command is registered + handler, ok := r.commands["deploy"] + if !ok { + t.Fatal("deploy command not registered") + } + + // Execute the handler + reply := handler(context.Background(), decision.MessageContext{ + Command: "deploy", + Args: []string{"staging"}, + }) + + if !executed { + t.Error("handler was not executed") + } + if reply != "Deploying to staging" { + t.Errorf("reply = %q, want %q", reply, "Deploying to staging") + } + + // Verify alias works + canonical, ok := r.cmdAliases["d"] + if !ok { + t.Fatal("alias 'd' not registered") + } + if canonical != "deploy" { + t.Errorf("alias canonical = %q, want %q", canonical, "deploy") + } + + // Verify custom spec is tracked (for !help) + if len(r.customSpecs) != 1 { + t.Fatalf("customSpecs len = %d, want 1", len(r.customSpecs)) + } + if r.customSpecs[0].Name != "deploy" { + t.Errorf("customSpecs[0].Name = %q, want %q", r.customSpecs[0].Name, "deploy") + } +} + +// TestRobotStopAndDone verifies lifecycle methods work correctly. +func TestRobotStopAndDone(t *testing.T) { + r := &Robot{ + done: make(chan struct{}), + } + + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + + started := make(chan struct{}) + go func() { + close(started) + <-ctx.Done() + close(r.done) + }() + + <-started + + r.Stop() + + select { + case <-r.Done(): + // ok + case <-time.After(2 * time.Second): + t.Fatal("Done() did not close within 2s after Stop()") + } +} + +// TestRobotStopNilCancel verifies Stop is safe when cancel is nil. +func TestRobotStopNilCancel(t *testing.T) { + r := &Robot{ + done: make(chan struct{}), + } + // cancel is nil — must not panic. + r.Stop() +} + +// TestRunnerInterfaceSatisfied verifies that both Agent and Robot +// satisfy the Runner interface at compile time. +func TestRunnerInterfaceSatisfied(t *testing.T) { + // These are compile-time checks — if they compile, the test passes. + var _ Runner = (*Agent)(nil) + var _ Runner = (*Robot)(nil) +} + +// TestRobotBuiltinCommandCount verifies the robot has exactly the expected +// built-in commands and not more. +func TestRobotBuiltinCommandCount(t *testing.T) { + r := newTestRobot(t) + + expected := map[string]bool{ + "help": true, + "ping": true, + "status": true, + "info": true, + "version": true, + } + + for name := range r.commands { + if !expected[name] { + t.Errorf("unexpected built-in command %q in robot", name) + } + } + + for name := range expected { + if _, ok := r.commands[name]; !ok { + t.Errorf("missing built-in command %q in robot", name) + } + } +}