claude-failover/internal/dispatcher/dispatcher_test.go
Ubuntu 0a7e5efcfd feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00

211 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dispatcher
import (
"log"
"os"
"path/filepath"
"testing"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// mockTmux is a minimal in-memory tmux.Client for tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]string
sentKeys []string
}
func newMockTmux() *mockTmux {
return &mockTmux{
sessions: make(map[string]bool),
paneOutput: make(map[string]string),
}
}
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
func (m *mockTmux) KillSession(_ string) error { return nil }
func (m *mockTmux) SendKeys(_, keys string) error {
m.sentKeys = append(m.sentKeys, keys)
return nil
}
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
// TestParseFrontmatter verifies YAML frontmatter extraction.
func TestParseFrontmatter(t *testing.T) {
input := "---\ntitle: Fix bug\npriority: critical\n---\nDo the fix."
fm, body := parseFrontmatter([]byte(input))
if fm.Title != "Fix bug" {
t.Errorf("expected title 'Fix bug', got %q", fm.Title)
}
if fm.Priority != "critical" {
t.Errorf("expected priority critical, got %q", fm.Priority)
}
if body != "Do the fix." {
t.Errorf("expected body 'Do the fix.', got %q", body)
}
}
// TestParseFrontmatterNoHeader handles files without a YAML header.
func TestParseFrontmatterNoHeader(t *testing.T) {
input := "Just plain content."
fm, body := parseFrontmatter([]byte(input))
if fm.Title != "" {
t.Errorf("expected empty title, got %q", fm.Title)
}
if body != "Just plain content." {
t.Errorf("expected full body, got %q", body)
}
}
// TestModelForPriority maps priority strings to model names.
func TestModelForPriority(t *testing.T) {
cases := []struct{ priority, want string }{
{"critical", "opus"},
{"CRITICAL", "opus"},
{"high", "sonnet"},
{"default", "sonnet"},
{"", "sonnet"},
}
for _, c := range cases {
if got := modelForPriority(c.priority); got != c.want {
t.Errorf("modelForPriority(%q) = %q, want %q", c.priority, got, c.want)
}
}
}
// TestFindFreeSessionSkipsFailed verifies that recently-failed sessions are skipped.
func TestFindFreeSessionSkipsFailed(t *testing.T) {
tc := newMockTmux()
tc.sessions["sess-1"] = true
tc.sessions["sess-2"] = true
s := state.New("")
s.SetIdle("sess-1")
s.SetFailed("sess-2")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "sess-1"}, {Name: "sess-2"}},
Autonomous: config.AutonomousConfig{Max: 0},
},
},
logger: log.Default(),
}
got := d.findFreeSession()
if got != "sess-1" {
t.Errorf("expected sess-1, got %q", got)
}
}
// TestFindFreeSessionMissingTmux skips sessions not in tmux.
func TestFindFreeSessionMissingTmux(t *testing.T) {
tc := newMockTmux()
// sess-1 missing from tmux, sess-2 present and idle.
tc.sessions["sess-2"] = true
s := state.New("")
s.SetIdle("sess-1")
s.SetIdle("sess-2")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "sess-1"}, {Name: "sess-2"}},
Autonomous: config.AutonomousConfig{Max: 0},
},
},
logger: log.Default(),
}
got := d.findFreeSession()
if got != "sess-2" {
t.Errorf("expected sess-2, got %q", got)
}
}
// TestDispatchProject creates a task file, dispatches it, and checks state + rename.
func TestDispatchProject(t *testing.T) {
dir := t.TempDir()
inbox := filepath.Join(dir, ".agent-queue", "inbox")
os.MkdirAll(inbox, 0755)
taskContent := "---\ntitle: My Task\npriority: high\n---\nDo the work."
taskPath := filepath.Join(inbox, "task-001.md")
os.WriteFile(taskPath, []byte(taskContent), 0644)
tc := newMockTmux()
tc.sessions["free-sess"] = true
// Return prompt on first CapturePaneTail call (Claude is ready).
tc.paneOutput["free-sess"] = " "
s := state.New("")
s.SetIdle("free-sess")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "free-sess", Project: dir}},
Autonomous: config.AutonomousConfig{Max: 0},
},
},
logger: log.Default(),
}
d.dispatchProject(inbox)
if st := s.GetSession("free-sess"); st == nil || st.State != "working" {
t.Errorf("expected session working after dispatch, got %v", st)
}
// Original file renamed to .dispatched.
if _, err := os.Stat(taskPath + ".dispatched"); os.IsNotExist(err) {
t.Error("expected .dispatched marker")
}
if _, err := os.Stat(taskPath); !os.IsNotExist(err) {
t.Error("expected original task file to be renamed")
}
}
// TestDispatchProjectNoFreeSession leaves the task untouched when no session is available.
func TestDispatchProjectNoFreeSession(t *testing.T) {
dir := t.TempDir()
inbox := filepath.Join(dir, ".agent-queue", "inbox")
os.MkdirAll(inbox, 0755)
taskPath := filepath.Join(inbox, "task-002.md")
os.WriteFile(taskPath, []byte("content"), 0644)
tc := newMockTmux() // no sessions
s := state.New("")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Max: 0},
},
},
logger: log.Default(),
}
d.dispatchProject(inbox)
// File must remain unchanged.
if _, err := os.Stat(taskPath); os.IsNotExist(err) {
t.Error("task file should remain when no session is free")
}
}