feat(quota): Phase 2.3 — QuotaMonitor (scraping pane tmux)

- internal/quota: SwitchRequest, poll() toutes les 30s
- isQuotaExhausted: 5 patterns (hit limit, rate limit, quota exceeded, etc.)
- extractResetTime: regex pour "resets 8pm / resets at 11:30pm / resets in N min"
- Seuils: >=2 sessions pool OU >=1 session dedicated → SwitchRequest channel(1)
- 5 tests: patterns, reset time, trigger 2 pool, trigger 1 dedicated, no-trigger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-14 20:31:04 +00:00
parent 0a7e5efcfd
commit 133165b432
2 changed files with 342 additions and 0 deletions

View file

@ -0,0 +1,152 @@
package quota
import (
"testing"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// mockTmux for quota tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]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(_, _ string) error { return nil }
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
// TestIsQuotaExhausted verifies pattern matching on pane output.
func TestIsQuotaExhausted(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"You've hit your limit for Claude Pro.", true},
{"rate limit exceeded", true},
{"quota exceeded for this period", true},
{"Usage limit reached", true},
{"Too many requests", true},
{"Some normal output ", false},
{" ", false},
{"still running 5s · ", false},
}
for _, c := range cases {
if got := isQuotaExhausted(c.input); got != c.want {
t.Errorf("isQuotaExhausted(%q) = %v, want %v", c.input, got, c.want)
}
}
}
// TestExtractResetTime parses various reset time formats.
func TestExtractResetTime(t *testing.T) {
cases := []struct {
input string
want string
}{
{"Usage resets 8pm", "8pm"},
{"Your quota resets at 11:30pm", "11:30pm"},
{"resets in 45 minutes", "in 45 minutes"},
{"resets in 2 hours", "in 2 hours"},
{"no reset info here", ""},
}
for _, c := range cases {
if got := extractResetTime(c.input); got != c.want {
t.Errorf("extractResetTime(%q) = %q, want %q", c.input, got, c.want)
}
}
}
// TestPollTriggersSwitchOnTwoBlockedPool verifies swap trigger for >=2 blocked pool sessions.
func TestPollTriggersSwitchOnTwoBlockedPool(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-auto-0"] = true
tc.sessions["ccl-auto-1"] = true
tc.paneOutput["ccl-auto-0"] = "You've hit your limit for Claude Pro."
tc.paneOutput["ccl-auto-1"] = "rate limit exceeded"
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Max: 2},
},
}
m := New(tc, s, cfg)
m.poll()
select {
case req := <-m.switchCh:
if req.From != "compte1" {
t.Errorf("expected From=compte1, got %q", req.From)
}
default:
t.Fatal("expected SwitchRequest on channel")
}
}
// TestPollTriggersSwitchOnOneBlockedInteractive verifies swap trigger for >=1 dedicated session.
func TestPollTriggersSwitchOnOneBlockedInteractive(t *testing.T) {
tc := newMockTmux()
tc.sessions["my-session"] = true
tc.paneOutput["my-session"] = "quota exceeded"
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "my-session"}},
Autonomous: config.AutonomousConfig{Max: 0},
},
}
m := New(tc, s, cfg)
m.poll()
select {
case req := <-m.switchCh:
if req.From != "compte1" {
t.Errorf("expected From=compte1, got %q", req.From)
}
default:
t.Fatal("expected SwitchRequest on channel")
}
}
// TestPollNoTriggerWhenBelowThreshold verifies no swap for a single blocked pool session.
func TestPollNoTriggerWhenBelowThreshold(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-auto-0"] = true
tc.sessions["ccl-auto-1"] = true
tc.paneOutput["ccl-auto-0"] = "rate limit exceeded"
tc.paneOutput["ccl-auto-1"] = " " // fine
s := state.New("")
cfg := &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Max: 2},
},
}
m := New(tc, s, cfg)
m.poll()
select {
case req := <-m.switchCh:
t.Errorf("unexpected SwitchRequest: %+v", req)
default:
// Correct: only 1 blocked pool session, threshold is 2.
}
}