package lobby import ( "context" "errors" "testing" "time" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/notify" ) // stubMatcher is a fake GameMatcher: it returns canned games and records the calls the // matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and // the wait-window math without a database. The DB-backed open/join/substitute logic is // covered by the integration suite. type stubMatcher struct { openGame game.Game openJoined bool openErr error openCalls int lastDeadline time.Time expired []game.OpenGame attachGame game.Game attached bool attachErr error attachedGames []uuid.UUID } func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) { s.openCalls++ s.lastDeadline = deadline return s.openGame, s.openJoined, s.openErr } func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) { if s.attachErr != nil { return game.Game{}, false, s.attachErr } if s.attached { s.attachedGames = append(s.attachedGames, gameID) } return s.attachGame, s.attached, nil } func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) { return s.expired, nil } func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) { return notify.PlayerState{}, nil } // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an // empty pool. It records the variant of the last substitution request. type fakeRobots struct { id uuid.UUID err error lastVariant engine.Variant } func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) { f.lastVariant = variant if f.err != nil { return uuid.Nil, f.err } return f.id, nil } // capturePub records every published intent. type capturePub struct{ intents []notify.Intent } func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) } // twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1 // (uuid.Nil for a still-empty opponent seat). func twoSeatGame(starter, opponent uuid.UUID) game.Game { return game.Game{ ID: uuid.New(), Variant: engine.VariantEnglish, Seats: []game.Seat{ {Seat: 0, AccountID: starter}, {Seat: 1, AccountID: opponent}, }, } } func TestEnqueueOpensGameWithoutOpponent(t *testing.T) { starter := uuid.New() m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)} pub := &capturePub{} mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) mm.SetNotifier(pub) res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue: %v", err) } if res.Matched { t.Error("opening a game must report Matched=false") } if m.openCalls != 1 { t.Errorf("OpenOrJoin calls = %d, want 1", m.openCalls) } if len(pub.intents) != 0 { t.Errorf("opening a game must not emit opponent_joined; got %d intents", len(pub.intents)) } } func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) { starter, joiner := uuid.New(), uuid.New() m := &stubMatcher{openGame: twoSeatGame(starter, joiner), openJoined: true} pub := &capturePub{} mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) mm.SetNotifier(pub) res, err := mm.Enqueue(context.Background(), joiner, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue: %v", err) } if !res.Matched { t.Error("joining a waiting game must report Matched=true") } if len(pub.intents) != 1 { t.Fatalf("joining must emit one opponent_joined; got %d", len(pub.intents)) } if got := pub.intents[0]; got.Kind != notify.KindOpponentJoined || got.UserID != starter { t.Errorf("opponent_joined = (kind %q, user %s), want (%q, starter %s)", got.Kind, got.UserID, notify.KindOpponentJoined, starter) } } func TestEnqueueDeadlineWithinWindow(t *testing.T) { base := time.Now() m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)} mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, 90*time.Second, 90*time.Second, zap.NewNop()) mm.clock = func() time.Time { return base } if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } lo, hi := base.Add(90*time.Second), base.Add(180*time.Second) if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) { t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi) } } func TestReapSubstitutesRobotAndEmits(t *testing.T) { human, robotID := uuid.New(), uuid.New() og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble} m := &stubMatcher{ expired: []game.OpenGame{og}, attachGame: twoSeatGame(human, robotID), attached: true, } robots := &fakeRobots{id: robotID} pub := &capturePub{} mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop()) mm.SetNotifier(pub) mm.Reap(context.Background(), time.Now()) if robots.lastVariant != engine.VariantRussianScrabble { t.Errorf("robot picked for %v, want the open game's variant", robots.lastVariant) } if len(m.attachedGames) != 1 || m.attachedGames[0] != og.ID { t.Errorf("attached games = %v, want [%s]", m.attachedGames, og.ID) } if len(pub.intents) != 1 || pub.intents[0].Kind != notify.KindOpponentJoined || pub.intents[0].UserID != human { t.Errorf("reap must emit opponent_joined to the human starter; got %+v", pub.intents) } } func TestReapDefersWithoutRobot(t *testing.T) { m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}} pub := &capturePub{} mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop()) mm.SetNotifier(pub) mm.Reap(context.Background(), time.Now()) if len(m.attachedGames) != 0 { t.Errorf("no robot available: must not attach; attached %v", m.attachedGames) } if len(pub.intents) != 0 { t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents)) } } func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) { m := &stubMatcher{ expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}, attached: false, // AttachRobot reports the game already filled by a human } pub := &capturePub{} mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) mm.SetNotifier(pub) mm.Reap(context.Background(), time.Now()) if len(pub.intents) != 0 { t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents)) } }