- Clock API routes: start, pause, resume, advance, rewind, jump, get, warnings - Role-based access control (floor+ for mutations, any auth for reads) - Clock state persistence callback to DB on meaningful changes - Blind structure levels loaded from DB on clock start - Clock registry wired into HTTP server and cmd/leaf main - 25 tests covering: state machine, countdown, pause/resume, auto-advance, jump, rewind, hand-for-hand, warnings, overtime, crash recovery, snapshot - Fix missing crypto/rand import in auth/pin.go (Rule 3 auto-fix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
160 lines
4 KiB
Go
160 lines
4 KiB
Go
package clock
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestWarningThresholdDetection(t *testing.T) {
|
|
engine := NewClockEngine("test-warnings-1", nil)
|
|
engine.SetWarnings([]Warning{
|
|
{Seconds: 5, Type: "both", SoundID: "warning_5s", Message: "5 seconds"},
|
|
})
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 8},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
// Tick until we cross the 5s threshold
|
|
// The level is 8 seconds, so after ~3 seconds we should hit the 5s warning
|
|
warningFired := false
|
|
for i := 0; i < 100; i++ {
|
|
time.Sleep(50 * time.Millisecond)
|
|
engine.Tick()
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.RemainingMs < 5000 {
|
|
// Check if warning was emitted by checking internal state
|
|
engine.mu.RLock()
|
|
warningFired = engine.emittedWarnings[5]
|
|
engine.mu.RUnlock()
|
|
if warningFired {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !warningFired {
|
|
t.Error("expected 5-second warning to fire")
|
|
}
|
|
}
|
|
|
|
func TestWarningNotReEmitted(t *testing.T) {
|
|
engine := NewClockEngine("test-warnings-2", nil)
|
|
engine.SetWarnings([]Warning{
|
|
{Seconds: 5, Type: "both", SoundID: "warning_5s", Message: "5 seconds"},
|
|
})
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 8},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
// Tick until warning fires
|
|
for i := 0; i < 100; i++ {
|
|
time.Sleep(50 * time.Millisecond)
|
|
engine.Tick()
|
|
|
|
engine.mu.RLock()
|
|
fired := engine.emittedWarnings[5]
|
|
engine.mu.RUnlock()
|
|
if fired {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Mark that we've seen the warning
|
|
engine.mu.RLock()
|
|
firstFired := engine.emittedWarnings[5]
|
|
engine.mu.RUnlock()
|
|
if !firstFired {
|
|
t.Fatal("warning never fired")
|
|
}
|
|
|
|
// Continue ticking -- emittedWarnings[5] should remain true (not re-emitted)
|
|
for i := 0; i < 10; i++ {
|
|
time.Sleep(10 * time.Millisecond)
|
|
engine.Tick()
|
|
}
|
|
|
|
engine.mu.RLock()
|
|
stillFired := engine.emittedWarnings[5]
|
|
engine.mu.RUnlock()
|
|
if !stillFired {
|
|
t.Error("warning flag was cleared unexpectedly")
|
|
}
|
|
}
|
|
|
|
func TestWarningResetOnLevelChange(t *testing.T) {
|
|
engine := NewClockEngine("test-warnings-3", nil)
|
|
engine.SetWarnings([]Warning{
|
|
{Seconds: 3, Type: "both", SoundID: "warning_3s", Message: "3 seconds"},
|
|
})
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 5},
|
|
{Position: 1, LevelType: "round", GameType: "nlhe", SmallBlind: 200, BigBlind: 400, DurationSeconds: 5},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
// Tick until warning fires for level 0
|
|
for i := 0; i < 100; i++ {
|
|
time.Sleep(30 * time.Millisecond)
|
|
engine.Tick()
|
|
|
|
engine.mu.RLock()
|
|
fired := engine.emittedWarnings[3]
|
|
engine.mu.RUnlock()
|
|
if fired {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Manual advance to level 1
|
|
engine.AdvanceLevel("op1")
|
|
|
|
// After advance, warnings should be reset
|
|
engine.mu.RLock()
|
|
resetCheck := engine.emittedWarnings[3]
|
|
engine.mu.RUnlock()
|
|
|
|
if resetCheck {
|
|
t.Error("expected warnings to be reset after level change")
|
|
}
|
|
}
|
|
|
|
func TestDefaultWarnings(t *testing.T) {
|
|
warnings := DefaultWarnings()
|
|
if len(warnings) != 3 {
|
|
t.Fatalf("expected 3 default warnings, got %d", len(warnings))
|
|
}
|
|
|
|
expectedSeconds := []int{60, 30, 10}
|
|
for i, w := range warnings {
|
|
if w.Seconds != expectedSeconds[i] {
|
|
t.Errorf("warning %d: expected %ds, got %ds", i, expectedSeconds[i], w.Seconds)
|
|
}
|
|
if w.Type != "both" {
|
|
t.Errorf("warning %d: expected type 'both', got '%s'", i, w.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCustomWarnings(t *testing.T) {
|
|
engine := NewClockEngine("test-custom-warnings", nil)
|
|
|
|
custom := []Warning{
|
|
{Seconds: 120, Type: "visual", SoundID: "custom_120s", Message: "2 minutes"},
|
|
{Seconds: 15, Type: "audio", SoundID: "custom_15s", Message: "15 seconds"},
|
|
}
|
|
engine.SetWarnings(custom)
|
|
|
|
got := engine.GetWarnings()
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 custom warnings, got %d", len(got))
|
|
}
|
|
if got[0].Seconds != 120 {
|
|
t.Errorf("expected first warning at 120s, got %ds", got[0].Seconds)
|
|
}
|
|
if got[1].Type != "audio" {
|
|
t.Errorf("expected second warning type 'audio', got '%s'", got[1].Type)
|
|
}
|
|
}
|