felt/internal/clock/engine_test.go
Mikkel Georgsen ae90d9bfae feat(01-04): add clock warnings, API routes, tests, and server wiring
- 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>
2026-03-01 03:56:23 +01:00

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)
}
}