Files
scrabble-game/backend/internal/notify/notify_test.go
T
Ilia Denisov 12d128f1cc
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
feat(nudge): name the sender; blink lobby cards; re-animate toasts
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:

- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);

falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.

Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.

Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.

Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
2026-06-16 19:09:24 +02:00

249 lines
8.9 KiB
Go

package notify_test
import (
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
fb "scrabble/pkg/fbs/scrabblefb"
)
func TestHubDeliversToSubscriber(t *testing.T) {
h := notify.NewHub(4)
ch, cancel := h.Subscribe()
defer cancel()
want := notify.Intent{UserID: uuid.New(), Kind: notify.KindYourTurn, Payload: []byte{1, 2, 3}}
h.Publish(want)
select {
case got := <-ch:
if got.Kind != want.Kind || got.UserID != want.UserID {
t.Fatalf("delivered %+v, want %+v", got, want)
}
case <-time.After(time.Second):
t.Fatal("no delivery within timeout")
}
}
func TestHubDropsWhenSubscriberBufferFull(t *testing.T) {
h := notify.NewHub(1)
ch, cancel := h.Subscribe()
defer cancel()
in := notify.Intent{UserID: uuid.New(), Kind: notify.KindNudge}
// Buffer holds one; the second and third are dropped, and Publish must not block.
h.Publish(in, in, in)
if got := len(ch); got != 1 {
t.Fatalf("buffered %d intents, want 1 (rest dropped)", got)
}
}
func TestHubUnsubscribeClosesChannel(t *testing.T) {
h := notify.NewHub(2)
ch, cancel := h.Subscribe()
cancel()
if _, ok := <-ch; ok {
t.Fatal("channel should be closed after unsubscribe")
}
// Publishing after unsubscribe must be safe (no panic, no delivery).
h.Publish(notify.Intent{Kind: notify.KindMatchFound})
}
func TestNopPublisherDiscards(t *testing.T) {
var p notify.Publisher = notify.Nop{}
p.Publish(notify.Intent{Kind: notify.KindYourTurn}) // must not panic
}
func TestYourTurnPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95", 7)
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsYourTurnEvent(in.Payload, 0)
if got := string(ev.GameId()); got != gid.String() {
t.Fatalf("game id = %q, want %q", got, gid)
}
if got := ev.DeadlineUnix(); got != 1717000000 {
t.Fatalf("deadline = %d, want 1717000000", got)
}
if got := ev.MoveCount(); got != 7 {
t.Fatalf("move_count = %d, want 7", got)
}
if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" ||
string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" {
t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q",
ev.OpponentName(), ev.LastAction(), ev.LastWord(), ev.ScoreLine())
}
}
func TestNudgePayloadRoundTrips(t *testing.T) {
uid, gid, from := uuid.New(), uuid.New(), uuid.New()
in := notify.Nudge(uid, gid, from, "Ann")
if in.UserID != uid || in.Kind != notify.KindNudge || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsNudgeEvent(in.Payload, 0)
if string(ev.GameId()) != gid.String() || string(ev.FromUserId()) != from.String() || string(ev.SenderName()) != "Ann" {
t.Fatalf("nudge fields wrong: game=%q from=%q name=%q", ev.GameId(), ev.FromUserId(), ev.SenderName())
}
}
func TestGameOverPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}}
in := notify.GameOver(uid, gid, "won", "120:95:80", summary)
if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsGameOverEvent(in.Payload, 0)
if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" {
t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine())
}
g := ev.Game(nil)
if g == nil || string(g.Id()) != gid.String() || g.MoveCount() != 18 || g.SeatsLength() != 1 {
t.Fatalf("final game summary wrong: %+v", g)
}
}
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
in := notify.OpponentMoved(uid, gid, move, summary, 42)
if in.Kind != notify.KindOpponentMoved {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
if string(ev.GameId()) != gid.String() {
t.Fatalf("game id = %q", ev.GameId())
}
// The delta: the move, the post-move summary and the bag size.
if ev.BagLen() != 42 {
t.Fatalf("bag_len = %d, want 42", ev.BagLen())
}
m := ev.Move(nil)
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
t.Fatalf("move wrong: %+v", m)
}
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
}
}
func TestMatchFoundCarriesInitialState(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
state := notify.PlayerState{
Game: notify.GameSummary{ID: gid.String(), Variant: "scrabble_en", Seats: []notify.SeatStanding{{Seat: 0, DisplayName: "Ann"}}},
Seat: 0,
Rack: []int{0, 1, 2, 255},
BagLen: 86,
}
in := notify.MatchFound(uid, gid, state)
if in.UserID != uid || in.Kind != notify.KindMatchFound {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsMatchFoundEvent(in.Payload, 0)
if string(ev.GameId()) != gid.String() {
t.Fatalf("game id = %q", ev.GameId())
}
st := ev.State(nil)
if st == nil || st.Seat() != 0 || st.BagLen() != 86 || st.RackLength() != 4 || st.Rack(3) != 255 {
t.Fatalf("initial state wrong: %+v", st)
}
if g := st.Game(nil); g == nil || string(g.Variant()) != "scrabble_en" {
t.Fatalf("state game wrong: %+v", st.Game(nil))
}
}
func TestNotificationInvitationCarriesInvitation(t *testing.T) {
uid := uuid.New()
inv := notify.InvitationSummary{
ID: "inv-1",
Inviter: notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"},
Invitees: []notify.InvitationInvitee{{AccountID: "b-1", DisplayName: "Bob", Seat: 1, Response: "pending"}},
Variant: "erudit_ru",
TurnTimeoutSecs: 86400,
Status: "pending",
}
in := notify.NotificationInvitation(uid, inv)
if in.Kind != notify.KindNotification {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if string(ev.Kind()) != notify.NotifyInvitation {
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitation)
}
got := ev.Invitation(nil)
if got == nil || string(got.Id()) != "inv-1" || string(got.Variant()) != "erudit_ru" || got.InviteesLength() != 1 {
t.Fatalf("invitation wrong: %+v", got)
}
var iv fb.InvitationInvitee
if !got.Invitees(&iv, 0) || string(iv.DisplayName()) != "Bob" || iv.Seat() != 1 {
t.Fatalf("invitee wrong")
}
if inviter := got.Inviter(nil); inviter == nil || string(inviter.DisplayName()) != "Ann" {
t.Fatalf("inviter wrong")
}
}
func TestNotificationInvitationUpdateCarriesTerminalInvitation(t *testing.T) {
uid := uuid.New()
inv := notify.InvitationSummary{ID: "inv-9", Variant: "scrabble_en", Status: "declined"}
in := notify.NotificationInvitationUpdate(uid, inv)
if in.Kind != notify.KindNotification {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if string(ev.Kind()) != notify.NotifyInvitationUpdate {
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitationUpdate)
}
got := ev.Invitation(nil)
if got == nil || string(got.Id()) != "inv-9" || string(got.Status()) != "declined" {
t.Fatalf("invitation wrong: %+v", got)
}
}
func TestNotificationAccountCarriesAccount(t *testing.T) {
uid := uuid.New()
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if string(ev.Kind()) != notify.NotifyFriendRequest {
t.Fatalf("sub-kind = %q", ev.Kind())
}
acc := ev.Account(nil)
if acc == nil || string(acc.AccountId()) != "a-1" || string(acc.DisplayName()) != "Ann" {
t.Fatalf("account wrong: %+v", acc)
}
}
func TestChatMessagePayloadRoundTrips(t *testing.T) {
uid, gid, sid := uuid.New(), uuid.New(), uuid.New()
in := notify.ChatMessage(uid, gid, sid, "msg-1", "message", "hi", time.Unix(1717000001, 0))
if in.Kind != notify.KindChatMessage {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsChatMessage(in.Payload, 0)
if string(ev.Id()) != "msg-1" || string(ev.SenderId()) != sid.String() || string(ev.Body()) != "hi" || ev.CreatedAtUnix() != 1717000001 {
t.Fatalf("decoded wrong chat message: %+v", ev)
}
}
func TestNotificationPayloadRoundTrips(t *testing.T) {
uid := uuid.New()
in := notify.Notification(uid, notify.NotifyFriendRequest)
if in.UserID != uid || in.Kind != notify.KindNotification || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if got := string(ev.Kind()); got != notify.NotifyFriendRequest {
t.Fatalf("notification sub-kind = %q, want %q", got, notify.NotifyFriendRequest)
}
}