Skip to content

Commit 28245ca

Browse files
whitmoclaude
andcommitted
test: Add comprehensive tests for PRs dlorenc#334, dlorenc#335, dlorenc#336, dlorenc#340, dlorenc#342
Add 659 lines of tests covering: - All 18 structured error constructors from PR dlorenc#340 (individual + bulk format test) - JSON CLI output edge cases from PR dlorenc#335 (empty/nested/all-internal subcommands) - Structured CLIError validation for workspace names from PR dlorenc#340 integration - Message routing edge cases from PR dlorenc#342 (no acked, mixed ack status) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e2287c3 commit 28245ca

3 files changed

Lines changed: 659 additions & 0 deletions

File tree

internal/cli/cli_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/dlorenc/multiclaude/internal/daemon"
13+
"github.com/dlorenc/multiclaude/internal/errors"
1314
"github.com/dlorenc/multiclaude/internal/messages"
1415
"github.com/dlorenc/multiclaude/internal/socket"
1516
"github.com/dlorenc/multiclaude/internal/state"
@@ -1893,6 +1894,53 @@ func TestValidateWorkspaceName(t *testing.T) {
18931894
}
18941895
}
18951896

1897+
// PR #340: Verify validateWorkspaceName returns structured CLIErrors
1898+
func TestValidateWorkspaceNameStructuredErrors(t *testing.T) {
1899+
tests := []struct {
1900+
name string
1901+
workspace string
1902+
wantContains string
1903+
}{
1904+
{"empty", "", "cannot be empty"},
1905+
{"dot", ".", "cannot be '.' or '..'"},
1906+
{"dotdot", "..", "cannot be '.' or '..'"},
1907+
{"starts with dot", ".hidden", "cannot start with '.' or '-'"},
1908+
{"starts with dash", "-bad", "cannot start with '.' or '-'"},
1909+
{"ends with dot", "bad.", "cannot end with '.' or '/'"},
1910+
{"ends with slash", "bad/", "cannot end with '.' or '/'"},
1911+
{"contains dotdot", "bad..name", "cannot contain '..'"},
1912+
{"contains space", "bad name", "cannot contain ' '"},
1913+
}
1914+
1915+
for _, tt := range tests {
1916+
t.Run(tt.name, func(t *testing.T) {
1917+
err := validateWorkspaceName(tt.workspace)
1918+
if err == nil {
1919+
t.Fatalf("expected error for workspace name %q", tt.workspace)
1920+
}
1921+
1922+
// Verify it's a CLIError (structured error from PR #340)
1923+
cliErr, ok := err.(*errors.CLIError)
1924+
if !ok {
1925+
t.Fatalf("expected *errors.CLIError, got %T: %v", err, err)
1926+
}
1927+
1928+
if cliErr.Category != errors.CategoryUsage {
1929+
t.Errorf("expected CategoryUsage, got %v", cliErr.Category)
1930+
}
1931+
1932+
if !strings.Contains(cliErr.Message, tt.wantContains) {
1933+
t.Errorf("expected message to contain %q, got: %s", tt.wantContains, cliErr.Message)
1934+
}
1935+
1936+
// All invalid workspace name errors should suggest naming conventions
1937+
if cliErr.Suggestion == "" {
1938+
t.Error("expected a suggestion for naming conventions")
1939+
}
1940+
})
1941+
}
1942+
}
1943+
18961944
func TestCLIWorkspaceListEmpty(t *testing.T) {
18971945
cli, d, cleanup := setupTestEnvironment(t)
18981946
defer cleanup()
@@ -3139,6 +3187,100 @@ func TestCommandSchemaConversion(t *testing.T) {
31393187
}
31403188
}
31413189

3190+
// PR #335: Additional JSON output edge cases
3191+
3192+
func TestCommandSchemaEmptySubcommands(t *testing.T) {
3193+
cmd := &Command{
3194+
Name: "leaf",
3195+
Description: "leaf command with no subcommands",
3196+
}
3197+
3198+
schema := cmd.toSchema()
3199+
3200+
if schema.Name != "leaf" {
3201+
t.Errorf("expected name 'leaf', got '%s'", schema.Name)
3202+
}
3203+
if schema.Subcommands != nil {
3204+
t.Errorf("expected nil subcommands for leaf command, got %v", schema.Subcommands)
3205+
}
3206+
}
3207+
3208+
func TestCommandSchemaNestedSubcommands(t *testing.T) {
3209+
cmd := &Command{
3210+
Name: "root",
3211+
Description: "root command",
3212+
Subcommands: map[string]*Command{
3213+
"level1": {
3214+
Name: "level1",
3215+
Description: "level 1",
3216+
Subcommands: map[string]*Command{
3217+
"level2": {
3218+
Name: "level2",
3219+
Description: "level 2",
3220+
Usage: "root level1 level2",
3221+
},
3222+
},
3223+
},
3224+
},
3225+
}
3226+
3227+
schema := cmd.toSchema()
3228+
3229+
l1, exists := schema.Subcommands["level1"]
3230+
if !exists {
3231+
t.Fatal("expected level1 subcommand")
3232+
}
3233+
l2, exists := l1.Subcommands["level2"]
3234+
if !exists {
3235+
t.Fatal("expected level2 nested subcommand")
3236+
}
3237+
if l2.Usage != "root level1 level2" {
3238+
t.Errorf("expected nested usage, got: %s", l2.Usage)
3239+
}
3240+
}
3241+
3242+
func TestCommandSchemaAllInternalFiltered(t *testing.T) {
3243+
cmd := &Command{
3244+
Name: "test",
3245+
Description: "test",
3246+
Subcommands: map[string]*Command{
3247+
"_a": {Name: "_a", Description: "internal a"},
3248+
"_b": {Name: "_b", Description: "internal b"},
3249+
},
3250+
}
3251+
3252+
schema := cmd.toSchema()
3253+
3254+
// When all subcommands are internal, map should be empty but not nil
3255+
if len(schema.Subcommands) != 0 {
3256+
t.Errorf("expected 0 subcommands (all internal filtered), got %d", len(schema.Subcommands))
3257+
}
3258+
}
3259+
3260+
func TestHelpJSONSubcommandOutput(t *testing.T) {
3261+
cli, _, cleanup := setupTestEnvironment(t)
3262+
defer cleanup()
3263+
3264+
// Test various subcommand --json combinations
3265+
subcommands := [][]string{
3266+
{"repo", "--json"},
3267+
{"worker", "--json"},
3268+
{"workspace", "--json"},
3269+
{"daemon", "--json"},
3270+
{"message", "--json"},
3271+
{"agent", "--help", "--json"},
3272+
}
3273+
3274+
for _, args := range subcommands {
3275+
t.Run(strings.Join(args, "_"), func(t *testing.T) {
3276+
err := cli.Execute(args)
3277+
if err != nil {
3278+
t.Errorf("Execute(%v) failed: %v", args, err)
3279+
}
3280+
})
3281+
}
3282+
}
3283+
31423284
func TestShowHelpNoPanic(t *testing.T) {
31433285
cli, _, cleanup := setupTestEnvironment(t)
31443286
defer cleanup()

internal/daemon/daemon_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,137 @@ func TestMessageRoutingCleansUpAckedMessages(t *testing.T) {
12581258
}
12591259
}
12601260

1261+
// PR #342: Test message cleanup with no acked messages (edge case)
1262+
func TestMessageRoutingNoAckedMessages(t *testing.T) {
1263+
tmuxClient := tmux.NewClient()
1264+
if !tmuxClient.IsTmuxAvailable() {
1265+
t.Fatal("tmux is required for this test but not available")
1266+
}
1267+
1268+
d, cleanup := setupTestDaemon(t)
1269+
defer cleanup()
1270+
1271+
sessionName := "mc-test-noack"
1272+
if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil {
1273+
t.Fatalf("tmux is required for this test but cannot create sessions in this environment: %v", err)
1274+
}
1275+
defer tmuxClient.KillSession(context.Background(), sessionName)
1276+
1277+
if err := tmuxClient.CreateWindow(context.Background(), sessionName, "worker1"); err != nil {
1278+
t.Fatalf("Failed to create worker window: %v", err)
1279+
}
1280+
1281+
repo := &state.Repository{
1282+
GithubURL: "https://github.com/test/repo",
1283+
TmuxSession: sessionName,
1284+
Agents: make(map[string]state.Agent),
1285+
}
1286+
if err := d.state.AddRepo("test-repo", repo); err != nil {
1287+
t.Fatalf("Failed to add repo: %v", err)
1288+
}
1289+
1290+
worker := state.Agent{
1291+
Type: state.AgentTypeWorker,
1292+
TmuxWindow: "worker1",
1293+
Task: "Test task",
1294+
CreatedAt: time.Now(),
1295+
}
1296+
if err := d.state.AddAgent("test-repo", "worker1", worker); err != nil {
1297+
t.Fatalf("Failed to add worker: %v", err)
1298+
}
1299+
1300+
// Send messages but DON'T ack them
1301+
msgMgr := messages.NewManager(d.paths.MessagesDir)
1302+
for i := 0; i < 3; i++ {
1303+
_, err := msgMgr.Send("test-repo", "supervisor", "worker1", "Test message")
1304+
if err != nil {
1305+
t.Fatalf("Failed to send message: %v", err)
1306+
}
1307+
}
1308+
1309+
// Trigger message routing - should not delete unacked messages
1310+
d.TriggerMessageRouting()
1311+
1312+
// Verify all 3 messages still exist (none were acked)
1313+
msgs, err := msgMgr.List("test-repo", "worker1")
1314+
if err != nil {
1315+
t.Fatalf("Failed to list messages: %v", err)
1316+
}
1317+
if len(msgs) != 3 {
1318+
t.Errorf("Expected 3 unacked messages to remain, got %d", len(msgs))
1319+
}
1320+
}
1321+
1322+
// PR #342: Test mixed acked and unacked messages
1323+
func TestMessageRoutingMixedAckStatus(t *testing.T) {
1324+
tmuxClient := tmux.NewClient()
1325+
if !tmuxClient.IsTmuxAvailable() {
1326+
t.Fatal("tmux is required for this test but not available")
1327+
}
1328+
1329+
d, cleanup := setupTestDaemon(t)
1330+
defer cleanup()
1331+
1332+
sessionName := "mc-test-mixed"
1333+
if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil {
1334+
t.Fatalf("tmux is required for this test but cannot create sessions in this environment: %v", err)
1335+
}
1336+
defer tmuxClient.KillSession(context.Background(), sessionName)
1337+
1338+
if err := tmuxClient.CreateWindow(context.Background(), sessionName, "worker1"); err != nil {
1339+
t.Fatalf("Failed to create worker window: %v", err)
1340+
}
1341+
1342+
repo := &state.Repository{
1343+
GithubURL: "https://github.com/test/repo",
1344+
TmuxSession: sessionName,
1345+
Agents: make(map[string]state.Agent),
1346+
}
1347+
if err := d.state.AddRepo("test-repo", repo); err != nil {
1348+
t.Fatalf("Failed to add repo: %v", err)
1349+
}
1350+
1351+
worker := state.Agent{
1352+
Type: state.AgentTypeWorker,
1353+
TmuxWindow: "worker1",
1354+
Task: "Test task",
1355+
CreatedAt: time.Now(),
1356+
}
1357+
if err := d.state.AddAgent("test-repo", "worker1", worker); err != nil {
1358+
t.Fatalf("Failed to add worker: %v", err)
1359+
}
1360+
1361+
// Send 4 messages, ack 2
1362+
msgMgr := messages.NewManager(d.paths.MessagesDir)
1363+
var msgIDs []string
1364+
for i := 0; i < 4; i++ {
1365+
msg, err := msgMgr.Send("test-repo", "supervisor", "worker1", "Test message")
1366+
if err != nil {
1367+
t.Fatalf("Failed to send message: %v", err)
1368+
}
1369+
msgIDs = append(msgIDs, msg.ID)
1370+
}
1371+
1372+
// Ack first 2 messages
1373+
for _, id := range msgIDs[:2] {
1374+
if err := msgMgr.Ack("test-repo", "worker1", id); err != nil {
1375+
t.Fatalf("Failed to ack message: %v", err)
1376+
}
1377+
}
1378+
1379+
// Trigger routing
1380+
d.TriggerMessageRouting()
1381+
1382+
// Verify only unacked messages remain
1383+
msgs, err := msgMgr.List("test-repo", "worker1")
1384+
if err != nil {
1385+
t.Fatalf("Failed to list messages: %v", err)
1386+
}
1387+
if len(msgs) != 2 {
1388+
t.Errorf("Expected 2 unacked messages to remain, got %d", len(msgs))
1389+
}
1390+
}
1391+
12611392
func TestWakeLoopUpdatesNudgeTime(t *testing.T) {
12621393
tmuxClient := tmux.NewClient()
12631394
if !tmuxClient.IsTmuxAvailable() {

0 commit comments

Comments
 (0)