Stage 8: UI social/account/history surfaces
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs.
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
import { Builder, ByteBuffer } from 'flatbuffers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec';
|
||||
import {
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
decodeSession,
|
||||
decodeStats,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('encodes a SubmitPlayRequest the gateway can read', () => {
|
||||
@@ -77,4 +85,88 @@ describe('codec', () => {
|
||||
expect(gl.games[0].seats[0].displayName).toBe('Ann');
|
||||
expect(gl.games[0].seats[0].score).toBe(13);
|
||||
});
|
||||
|
||||
it('encodes a TargetRequest', () => {
|
||||
const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1')));
|
||||
expect(r.accountId()).toBe('a-1');
|
||||
});
|
||||
|
||||
it('decodes a StatsView', () => {
|
||||
const b = new Builder(64);
|
||||
fb.StatsView.startStatsView(b);
|
||||
fb.StatsView.addWins(b, 7);
|
||||
fb.StatsView.addLosses(b, 4);
|
||||
fb.StatsView.addDraws(b, 1);
|
||||
fb.StatsView.addMaxGamePoints(b, 420);
|
||||
fb.StatsView.addMaxWordPoints(b, 90);
|
||||
b.finish(fb.StatsView.endStatsView(b));
|
||||
expect(decodeStats(b.asUint8Array())).toEqual({
|
||||
wins: 7,
|
||||
losses: 4,
|
||||
draws: 1,
|
||||
maxGamePoints: 420,
|
||||
maxWordPoints: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a FriendList of account refs', () => {
|
||||
const b = new Builder(128);
|
||||
const id = b.createString('a-1');
|
||||
const dn = b.createString('Ann');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, id);
|
||||
fb.AccountRef.addDisplayName(b, dn);
|
||||
const ref = fb.AccountRef.endAccountRef(b);
|
||||
const vec = fb.FriendList.createFriendsVector(b, [ref]);
|
||||
fb.FriendList.startFriendList(b);
|
||||
fb.FriendList.addFriends(b, vec);
|
||||
b.finish(fb.FriendList.endFriendList(b));
|
||||
expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
|
||||
});
|
||||
|
||||
it('decodes an Invitation with inviter and invitees', () => {
|
||||
const b = new Builder(256);
|
||||
const iid = b.createString('u-1');
|
||||
const idn = b.createString('Me');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, iid);
|
||||
fb.AccountRef.addDisplayName(b, idn);
|
||||
const inviter = fb.AccountRef.endAccountRef(b);
|
||||
|
||||
const aid = b.createString('inv-1');
|
||||
const adn = b.createString('Friend');
|
||||
const resp = b.createString('pending');
|
||||
fb.InvitationInvitee.startInvitationInvitee(b);
|
||||
fb.InvitationInvitee.addAccountId(b, aid);
|
||||
fb.InvitationInvitee.addDisplayName(b, adn);
|
||||
fb.InvitationInvitee.addSeat(b, 1);
|
||||
fb.InvitationInvitee.addResponse(b, resp);
|
||||
const invitee = fb.InvitationInvitee.endInvitationInvitee(b);
|
||||
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
|
||||
|
||||
const id = b.createString('i-1');
|
||||
const variant = b.createString('english');
|
||||
const dropout = b.createString('remove');
|
||||
const status = b.createString('pending');
|
||||
const gid = b.createString('');
|
||||
fb.Invitation.startInvitation(b);
|
||||
fb.Invitation.addId(b, id);
|
||||
fb.Invitation.addInviter(b, inviter);
|
||||
fb.Invitation.addInvitees(b, invitees);
|
||||
fb.Invitation.addVariant(b, variant);
|
||||
fb.Invitation.addTurnTimeoutSecs(b, 86400);
|
||||
fb.Invitation.addHintsAllowed(b, true);
|
||||
fb.Invitation.addHintsPerPlayer(b, 1);
|
||||
fb.Invitation.addDropoutTiles(b, dropout);
|
||||
fb.Invitation.addStatus(b, status);
|
||||
fb.Invitation.addGameId(b, gid);
|
||||
b.finish(fb.Invitation.endInvitation(b));
|
||||
|
||||
const inv = decodeInvitation(b.asUint8Array());
|
||||
expect(inv.id).toBe('i-1');
|
||||
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
|
||||
expect(inv.invitees).toHaveLength(1);
|
||||
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
|
||||
expect(inv.variant).toBe('english');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user