- 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>
463 lines
13 KiB
Go
463 lines
13 KiB
Go
package clock
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testLevels returns a set of levels for testing:
|
|
// Level 0: Round (NLHE 100/200, 15 min)
|
|
// Level 1: Break (5 min)
|
|
// Level 2: Round (NLHE 200/400, 15 min)
|
|
func testLevels() []Level {
|
|
return []Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 10000, BigBlind: 20000, Ante: 0, DurationSeconds: 900},
|
|
{Position: 1, LevelType: "break", GameType: "", SmallBlind: 0, BigBlind: 0, Ante: 0, DurationSeconds: 300},
|
|
{Position: 2, LevelType: "round", GameType: "nlhe", SmallBlind: 20000, BigBlind: 40000, Ante: 5000, DurationSeconds: 900},
|
|
}
|
|
}
|
|
|
|
func TestClockStartStop(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-1", nil)
|
|
engine.LoadLevels(testLevels())
|
|
|
|
// Start
|
|
if err := engine.Start("op1"); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
if engine.State() != StateRunning {
|
|
t.Errorf("expected state running, got %s", engine.State())
|
|
}
|
|
|
|
// Can't start again
|
|
if err := engine.Start("op1"); err == nil {
|
|
t.Error("expected error starting already running clock")
|
|
}
|
|
|
|
// Stop
|
|
if err := engine.Stop("op1"); err != nil {
|
|
t.Fatalf("Stop failed: %v", err)
|
|
}
|
|
if engine.State() != StateStopped {
|
|
t.Errorf("expected state stopped, got %s", engine.State())
|
|
}
|
|
|
|
// Can't stop again
|
|
if err := engine.Stop("op1"); err == nil {
|
|
t.Error("expected error stopping already stopped clock")
|
|
}
|
|
}
|
|
|
|
func TestClockPauseResume(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-2", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
// Get initial remaining time
|
|
snap1 := engine.Snapshot()
|
|
initialMs := snap1.RemainingMs
|
|
|
|
// Wait a bit
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Pause
|
|
if err := engine.Pause("op1"); err != nil {
|
|
t.Fatalf("Pause failed: %v", err)
|
|
}
|
|
if engine.State() != StatePaused {
|
|
t.Errorf("expected state paused, got %s", engine.State())
|
|
}
|
|
|
|
// Verify time was deducted
|
|
snapPaused := engine.Snapshot()
|
|
if snapPaused.RemainingMs >= initialMs {
|
|
t.Error("expected remaining time to decrease after pause")
|
|
}
|
|
|
|
// Record paused remaining time
|
|
pausedMs := snapPaused.RemainingMs
|
|
|
|
// Wait while paused -- time should NOT change
|
|
time.Sleep(50 * time.Millisecond)
|
|
snapStillPaused := engine.Snapshot()
|
|
if snapStillPaused.RemainingMs != pausedMs {
|
|
t.Errorf("remaining time changed while paused: %d -> %d", pausedMs, snapStillPaused.RemainingMs)
|
|
}
|
|
|
|
// Resume
|
|
if err := engine.Resume("op1"); err != nil {
|
|
t.Fatalf("Resume failed: %v", err)
|
|
}
|
|
if engine.State() != StateRunning {
|
|
t.Errorf("expected state running, got %s", engine.State())
|
|
}
|
|
|
|
// After resume, time should start decreasing again
|
|
time.Sleep(50 * time.Millisecond)
|
|
snapAfterResume := engine.Snapshot()
|
|
if snapAfterResume.RemainingMs >= pausedMs {
|
|
t.Error("expected remaining time to decrease after resume")
|
|
}
|
|
}
|
|
|
|
func TestClockCountsDown(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-3", nil)
|
|
// Use a very short level for testing
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 1},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
// Check initial time
|
|
snap := engine.Snapshot()
|
|
if snap.RemainingMs < 900 || snap.RemainingMs > 1000 {
|
|
t.Errorf("expected ~1000ms remaining, got %dms", snap.RemainingMs)
|
|
}
|
|
|
|
// Wait 200ms and check time decreased
|
|
time.Sleep(200 * time.Millisecond)
|
|
snap2 := engine.Snapshot()
|
|
if snap2.RemainingMs >= snap.RemainingMs {
|
|
t.Error("clock did not count down")
|
|
}
|
|
}
|
|
|
|
func TestLevelAutoAdvance(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-4", nil)
|
|
// Level 0: 100ms, Level 1: 100ms
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 1},
|
|
{Position: 1, LevelType: "round", GameType: "nlhe", SmallBlind: 200, BigBlind: 400, DurationSeconds: 1},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.CurrentLevel != 0 {
|
|
t.Errorf("expected level 0, got %d", snap.CurrentLevel)
|
|
}
|
|
|
|
// Simulate ticks until level changes
|
|
for i := 0; i < 150; i++ {
|
|
time.Sleep(10 * time.Millisecond)
|
|
engine.Tick()
|
|
snap = engine.Snapshot()
|
|
if snap.CurrentLevel > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if snap.CurrentLevel != 1 {
|
|
t.Errorf("expected auto-advance to level 1, got %d", snap.CurrentLevel)
|
|
}
|
|
}
|
|
|
|
func TestAdvanceLevel(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-5", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
if err := engine.AdvanceLevel("op1"); err != nil {
|
|
t.Fatalf("AdvanceLevel failed: %v", err)
|
|
}
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.CurrentLevel != 1 {
|
|
t.Errorf("expected level 1, got %d", snap.CurrentLevel)
|
|
}
|
|
if snap.Level.LevelType != "break" {
|
|
t.Errorf("expected break level, got %s", snap.Level.LevelType)
|
|
}
|
|
// Remaining time should be the break duration
|
|
if snap.RemainingMs < 295000 || snap.RemainingMs > 305000 {
|
|
t.Errorf("expected ~300000ms for 5-min break, got %dms", snap.RemainingMs)
|
|
}
|
|
}
|
|
|
|
func TestRewindLevel(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-6", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
// Can't rewind at first level
|
|
if err := engine.RewindLevel("op1"); err == nil {
|
|
t.Error("expected error rewinding at first level")
|
|
}
|
|
|
|
// Advance then rewind
|
|
engine.AdvanceLevel("op1")
|
|
if err := engine.RewindLevel("op1"); err != nil {
|
|
t.Fatalf("RewindLevel failed: %v", err)
|
|
}
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.CurrentLevel != 0 {
|
|
t.Errorf("expected level 0 after rewind, got %d", snap.CurrentLevel)
|
|
}
|
|
// Should have full duration of level 0
|
|
if snap.RemainingMs < 895000 || snap.RemainingMs > 905000 {
|
|
t.Errorf("expected ~900000ms after rewind, got %dms", snap.RemainingMs)
|
|
}
|
|
}
|
|
|
|
func TestJumpToLevel(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-7", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
// Jump to level 2
|
|
if err := engine.JumpToLevel(2, "op1"); err != nil {
|
|
t.Fatalf("JumpToLevel failed: %v", err)
|
|
}
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.CurrentLevel != 2 {
|
|
t.Errorf("expected level 2, got %d", snap.CurrentLevel)
|
|
}
|
|
if snap.Level.SmallBlind != 20000 {
|
|
t.Errorf("expected SB 20000, got %d", snap.Level.SmallBlind)
|
|
}
|
|
|
|
// Jump out of range
|
|
if err := engine.JumpToLevel(99, "op1"); err == nil {
|
|
t.Error("expected error jumping to invalid level")
|
|
}
|
|
if err := engine.JumpToLevel(-1, "op1"); err == nil {
|
|
t.Error("expected error jumping to negative level")
|
|
}
|
|
}
|
|
|
|
func TestHandForHand(t *testing.T) {
|
|
engine := NewClockEngine("test-tournament-8", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
// Enable hand-for-hand (should pause clock)
|
|
if err := engine.SetHandForHand(true, "op1"); err != nil {
|
|
t.Fatalf("SetHandForHand failed: %v", err)
|
|
}
|
|
|
|
snap := engine.Snapshot()
|
|
if !snap.HandForHand {
|
|
t.Error("expected hand_for_hand to be true")
|
|
}
|
|
if snap.State != "paused" {
|
|
t.Errorf("expected paused state, got %s", snap.State)
|
|
}
|
|
|
|
// Disable hand-for-hand (should resume clock)
|
|
if err := engine.SetHandForHand(false, "op1"); err != nil {
|
|
t.Fatalf("SetHandForHand disable failed: %v", err)
|
|
}
|
|
|
|
snap = engine.Snapshot()
|
|
if snap.HandForHand {
|
|
t.Error("expected hand_for_hand to be false")
|
|
}
|
|
if snap.State != "running" {
|
|
t.Errorf("expected running state, got %s", snap.State)
|
|
}
|
|
}
|
|
|
|
func TestMultipleEnginesIndependent(t *testing.T) {
|
|
engine1 := NewClockEngine("tournament-a", nil)
|
|
engine2 := NewClockEngine("tournament-b", nil)
|
|
|
|
engine1.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 900},
|
|
})
|
|
engine2.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "plo", SmallBlind: 500, BigBlind: 1000, DurationSeconds: 600},
|
|
})
|
|
|
|
engine1.Start("op1")
|
|
engine2.Start("op2")
|
|
|
|
// Pause engine1 only
|
|
engine1.Pause("op1")
|
|
|
|
snap1 := engine1.Snapshot()
|
|
snap2 := engine2.Snapshot()
|
|
|
|
if snap1.State != "paused" {
|
|
t.Errorf("engine1 should be paused, got %s", snap1.State)
|
|
}
|
|
if snap2.State != "running" {
|
|
t.Errorf("engine2 should be running, got %s", snap2.State)
|
|
}
|
|
|
|
// Verify they have different game types
|
|
if snap1.Level.GameType != "nlhe" {
|
|
t.Errorf("engine1 should be nlhe, got %s", snap1.Level.GameType)
|
|
}
|
|
if snap2.Level.GameType != "plo" {
|
|
t.Errorf("engine2 should be plo, got %s", snap2.Level.GameType)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotFields(t *testing.T) {
|
|
engine := NewClockEngine("test-snapshot-9", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
snap := engine.Snapshot()
|
|
|
|
// Check all required fields
|
|
if snap.TournamentID != "test-snapshot-9" {
|
|
t.Errorf("expected tournament_id test-snapshot-9, got %s", snap.TournamentID)
|
|
}
|
|
if snap.State != "running" {
|
|
t.Errorf("expected state running, got %s", snap.State)
|
|
}
|
|
if snap.CurrentLevel != 0 {
|
|
t.Errorf("expected level 0, got %d", snap.CurrentLevel)
|
|
}
|
|
if snap.Level.LevelType != "round" {
|
|
t.Errorf("expected round level, got %s", snap.Level.LevelType)
|
|
}
|
|
if snap.NextLevel == nil {
|
|
t.Error("expected next_level to be non-nil")
|
|
}
|
|
if snap.NextLevel != nil && snap.NextLevel.LevelType != "break" {
|
|
t.Errorf("expected next_level to be break, got %s", snap.NextLevel.LevelType)
|
|
}
|
|
if snap.RemainingMs <= 0 {
|
|
t.Errorf("expected positive remaining_ms, got %d", snap.RemainingMs)
|
|
}
|
|
if snap.ServerTimeMs <= 0 {
|
|
t.Errorf("expected positive server_time_ms, got %d", snap.ServerTimeMs)
|
|
}
|
|
if snap.LevelCount != 3 {
|
|
t.Errorf("expected level_count 3, got %d", snap.LevelCount)
|
|
}
|
|
if len(snap.Warnings) != 3 {
|
|
t.Errorf("expected 3 warnings, got %d", len(snap.Warnings))
|
|
}
|
|
}
|
|
|
|
func TestTotalElapsedExcludesPaused(t *testing.T) {
|
|
engine := NewClockEngine("test-elapsed-10", nil)
|
|
engine.LoadLevels(testLevels())
|
|
engine.Start("op1")
|
|
|
|
// Run for 100ms
|
|
time.Sleep(100 * time.Millisecond)
|
|
engine.Pause("op1")
|
|
|
|
snapPaused := engine.Snapshot()
|
|
elapsedAtPause := snapPaused.TotalElapsedMs
|
|
|
|
// Stay paused for 200ms
|
|
time.Sleep(200 * time.Millisecond)
|
|
snapStillPaused := engine.Snapshot()
|
|
|
|
// Elapsed should NOT have increased while paused
|
|
if snapStillPaused.TotalElapsedMs != elapsedAtPause {
|
|
t.Errorf("elapsed time changed while paused: %d -> %d",
|
|
elapsedAtPause, snapStillPaused.TotalElapsedMs)
|
|
}
|
|
|
|
// Resume and run for another 100ms
|
|
engine.Resume("op1")
|
|
time.Sleep(100 * time.Millisecond)
|
|
snapAfterResume := engine.Snapshot()
|
|
|
|
// Elapsed should now be ~200ms (100 before pause + 100 after resume)
|
|
// NOT ~500ms (which would include the 200ms pause)
|
|
if snapAfterResume.TotalElapsedMs < 150 || snapAfterResume.TotalElapsedMs > 350 {
|
|
t.Errorf("expected total elapsed ~200ms, got %dms", snapAfterResume.TotalElapsedMs)
|
|
}
|
|
}
|
|
|
|
func TestCrashRecovery(t *testing.T) {
|
|
engine := NewClockEngine("test-recovery-11", nil)
|
|
engine.LoadLevels(testLevels())
|
|
|
|
// Simulate crash recovery from a running state
|
|
engine.RestoreState(1, 150*int64(time.Second), 750*int64(time.Second), "running", false)
|
|
|
|
snap := engine.Snapshot()
|
|
|
|
// Should be paused for safety (not running)
|
|
if snap.State != "paused" {
|
|
t.Errorf("expected paused state after crash recovery, got %s", snap.State)
|
|
}
|
|
if snap.CurrentLevel != 1 {
|
|
t.Errorf("expected level 1, got %d", snap.CurrentLevel)
|
|
}
|
|
if snap.RemainingMs != 150000 {
|
|
t.Errorf("expected 150000ms remaining, got %dms", snap.RemainingMs)
|
|
}
|
|
if snap.TotalElapsedMs != 750000 {
|
|
t.Errorf("expected 750000ms elapsed, got %dms", snap.TotalElapsedMs)
|
|
}
|
|
}
|
|
|
|
func TestNoLevelsError(t *testing.T) {
|
|
engine := NewClockEngine("test-empty-12", nil)
|
|
|
|
// Start without levels
|
|
if err := engine.Start("op1"); err == nil {
|
|
t.Error("expected error starting without levels")
|
|
}
|
|
}
|
|
|
|
func TestNextLevelNilAtLast(t *testing.T) {
|
|
engine := NewClockEngine("test-last-13", nil)
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 900},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.NextLevel != nil {
|
|
t.Error("expected nil next_level at last level")
|
|
}
|
|
}
|
|
|
|
func TestOvertimeRepeat(t *testing.T) {
|
|
engine := NewClockEngine("test-overtime-14", nil)
|
|
engine.SetOvertimeMode(OvertimeRepeat)
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 1},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
// Simulate ticks past the level duration
|
|
for i := 0; i < 150; i++ {
|
|
time.Sleep(10 * time.Millisecond)
|
|
engine.Tick()
|
|
}
|
|
|
|
snap := engine.Snapshot()
|
|
// Should still be on level 0 (repeated), still running
|
|
if snap.CurrentLevel != 0 {
|
|
t.Errorf("expected level 0 in overtime repeat, got %d", snap.CurrentLevel)
|
|
}
|
|
if snap.State != "running" {
|
|
t.Errorf("expected running in overtime repeat, got %s", snap.State)
|
|
}
|
|
// Remaining time should have been reset
|
|
if snap.RemainingMs <= 0 {
|
|
t.Error("expected positive remaining time in overtime repeat")
|
|
}
|
|
}
|
|
|
|
func TestOvertimeStop(t *testing.T) {
|
|
engine := NewClockEngine("test-overtime-stop-15", nil)
|
|
engine.SetOvertimeMode(OvertimeStop)
|
|
engine.LoadLevels([]Level{
|
|
{Position: 0, LevelType: "round", GameType: "nlhe", SmallBlind: 100, BigBlind: 200, DurationSeconds: 1},
|
|
})
|
|
engine.Start("op1")
|
|
|
|
// Simulate ticks past the level duration
|
|
for i := 0; i < 150; i++ {
|
|
time.Sleep(10 * time.Millisecond)
|
|
engine.Tick()
|
|
}
|
|
|
|
snap := engine.Snapshot()
|
|
if snap.State != "stopped" {
|
|
t.Errorf("expected stopped in overtime stop, got %s", snap.State)
|
|
}
|
|
}
|