package web import ( "path/filepath" "testing" "time" "pete/internal/storage" ) // fakeGame is a tableGame that records whether its clock ever fired. It lets the // runtime tests exercise the lock discipline and the version guard without a real // engine — the thing under test is the clock, not the cards. type fakeGame struct { fired int } func (g *fakeGame) name() string { return "fake" } func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) { g.fired++ // Act: clear the deadline and bump the hand, as a real settle would. return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil } func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil } // clockTestServer stands up a Server with just the table machinery wired and a // fresh DB. Enough to drive the clock, nothing else. func clockTestServer(t *testing.T, g tableGame) *Server { t.Helper() // Reset the storage singleton onto a temp DB. if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil { t.Fatal(err) } t.Cleanup(func() { storage.Close() }) s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}} return s } func openClockTable(t *testing.T, id string, deadline int64) storage.Table { t.Helper() tbl := storage.Table{ ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`), Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline, } seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}} if err := storage.OpenTable(tbl, seats); err != nil { t.Fatal(err) } return tbl } func TestClock_ActsOnExpiredTable(t *testing.T) { g := &fakeGame{} s := clockTestServer(t, g) openClockTable(t, "t1", time.Now().Unix()-5) // already expired s.tickClock() if g.fired != 1 { t.Fatalf("clock should have fired once, got %d", g.fired) } after, _, err := storage.LoadTable("t1") if err != nil { t.Fatal(err) } if after.Phase != "handover" || after.Deadline != 0 { t.Errorf("table should have advanced: %+v", after) } } func TestClock_IgnoresFutureDeadlines(t *testing.T) { g := &fakeGame{} s := clockTestServer(t, g) openClockTable(t, "t1", time.Now().Unix()+60) s.tickClock() if g.fired != 0 { t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired) } } // TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns // on. A move lands in the same tick the clock's scan found the table expired. The // move bumps the version; the clock, acting on its stale scan, must see the new // version and step aside rather than acting a second time. func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) { g := &fakeGame{} s := clockTestServer(t, g) tbl := openClockTable(t, "t1", time.Now().Unix()-5) // The scan saw version 0. due, err := storage.DueTables(time.Now().Unix()) if err != nil { t.Fatal(err) } if len(due) != 1 { t.Fatalf("want 1 due table, got %d", len(due)) } // A real move lands first, bumping the version and setting a fresh deadline for // the next player. tbl.State = []byte(`{"moved":true}`) tbl.Deadline = time.Now().Unix() + 30 if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil { t.Fatal(err) } // Now the clock acts on its stale scan (version 0). It must not fire. s.runClockTable(due[0]) if g.fired != 0 { t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired) } after, _, _ := storage.LoadTable("t1") if string(after.State) != `{"moved":true}` { t.Errorf("the real move should stand, got %s", after.State) } } func TestClock_PublishesToWatchers(t *testing.T) { g := &fakeGame{} s := clockTestServer(t, g) openClockTable(t, "t1", time.Now().Unix()-5) ch, done := s.hub.subscribe("t1") defer done() s.tickClock() select { case f := <-ch: if f.Version == 0 { t.Errorf("frame should carry the bumped version, got %d", f.Version) } default: t.Fatal("a watcher should have received a frame after the clock acted") } }