41a642ef97
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback. - pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS. - backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size. - gateway: MoveResult transcode carries rack+bag_len. - ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false. - docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
222 lines
8.0 KiB
Go
222 lines
8.0 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 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)
|
|
// The pre-R4 summary scalars repeat the move.
|
|
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
|
|
t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d",
|
|
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
|
|
}
|
|
// The R4 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 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)
|
|
}
|
|
}
|