Files
noitu/server/internal/wsapi/wire_test.go
T
tiennm99 865222d2cd feat(proto): a dead-end claim, a word report, quick-match and a scored breakdown
Four client messages and two server ones, all additive. ClaimDeadEnd lets
the player to act say the syllable has no answer instead of waiting out
the clock; the server verifies. ReportWord files a refused word for the
maintainers and is acknowledged with WordReported. QuickMatch and
CancelQuickMatch join and leave a pairing queue, answered by
QuickMatchStatus. PlayedWord gains parts, the named terms that sum to
its points, and MoveRejected a suggestion for a word that differs from a
real one by diacritics alone. Generated code and cross-language fixtures
regenerated.
2026-09-21 01:20:22 +07:00

397 lines
14 KiB
Go

package wsapi
import (
"flag"
"os"
"path/filepath"
"sort"
"testing"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
// -update rewrites the committed cross-language fixtures. The JS suite decodes
// the same bytes, so regenerating them is the deliberate act of changing the
// wire contract, not a side effect of running the Go tests.
var update = flag.Bool("update", false, "rewrite the cross-language fixtures in proto/testdata")
const fixtureDir = "../../../proto/testdata"
// clientVariants covers every arm of ClientMessage.payload.
//
// The values are not minimal on purpose: Vietnamese text with diacritics, a
// large turn_seq and a real millisecond timestamp all exercise encodings a
// zero value would skip — UTF-8, varint width, and int64, which the JavaScript
// runtime surfaces as a bigint.
func clientVariants() map[string]*noituv1.ClientMessage {
return map[string]*noituv1.ClientMessage{
"client_hello": {Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: ProtocolVersion,
ResumeToken: "r-8f2c",
Nickname: "Người chơi ẩn danh",
}}},
"client_start_bot_game": {Payload: &noituv1.ClientMessage_StartBotGame{StartBotGame: &noituv1.StartBotGame{
Difficulty: noituv1.Difficulty_DIFFICULTY_HARD,
}}},
"client_create_room": {Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}},
"client_join_room": {Payload: &noituv1.ClientMessage_JoinRoom{JoinRoom: &noituv1.JoinRoom{
RoomCode: "K7QX",
}}},
"client_submit_word": {Payload: &noituv1.ClientMessage_SubmitWord{SubmitWord: &noituv1.SubmitWord{
Word: "hoà bình",
TurnSeq: 4242,
}}},
"client_resign": {Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}},
"client_ping": {Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{
ClientTimeMs: 1756998000123,
}}},
"client_set_ready": {Payload: &noituv1.ClientMessage_SetReady{SetReady: &noituv1.SetReady{
Ready: true,
}}},
"client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
"client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{
PlayerId: "p3",
}}},
"client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
"client_send_chat": {Payload: &noituv1.ClientMessage_SendChat{SendChat: &noituv1.SendChat{
Text: "Chào bạn, ván này khó thật!",
}}},
"client_claim_dead_end": {Payload: &noituv1.ClientMessage_ClaimDeadEnd{ClaimDeadEnd: &noituv1.ClaimDeadEnd{}}},
"client_report_word": {Payload: &noituv1.ClientMessage_ReportWord{ReportWord: &noituv1.ReportWord{
Word: "bình tâm",
}}},
"client_quick_match": {Payload: &noituv1.ClientMessage_QuickMatch{QuickMatch: &noituv1.QuickMatch{}}},
"client_cancel_quick_match": {Payload: &noituv1.ClientMessage_CancelQuickMatch{CancelQuickMatch: &noituv1.CancelQuickMatch{}}},
}
}
// serverVariants covers every arm of ServerMessage.payload.
func serverVariants() map[string]*noituv1.ServerMessage {
return map[string]*noituv1.ServerMessage{
"server_welcome": {Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{
SessionId: "s-1a2b3c",
ResumeToken: "r-8f2c",
ProtocolVersion: ProtocolVersion,
AcceptedNickname: "Người chơi ẩn danh",
}}},
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
OpeningWord: "hòa bình",
OpeningMeanings: []*noituv1.Sense{{Pos: "danh từ", Gloss: "Tình trạng không có chiến tranh."}},
CurrentSyllable: "bình",
MyTurn: true,
DeadlineUnixMs: 1756998020000,
TurnSeq: 1,
TurnLimitMs: 30000,
Players: fixtureScores(),
TurnPlayerId: "p1",
}}},
"server_turn_update": {Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: &noituv1.TurnUpdate{
Played: &noituv1.PlayedWord{
Word: "bình yên",
Typed: "binh yên",
ByMe: false,
Points: 2,
Syllables: 2,
PlayerId: "p2",
Meanings: []*noituv1.Sense{
{Pos: "tính từ", Gloss: "Yên lành, không có gì xáo động."},
{Pos: "", Gloss: "Xem bình an."},
},
// The parts sum to points, which the client relies on when it
// draws them beside the total.
Parts: []*noituv1.PointPart{
{Kind: noituv1.PointKind_POINT_KIND_BASE, Value: 1},
{Kind: noituv1.PointKind_POINT_KIND_SPEED, Value: 1},
},
},
CurrentSyllable: "yên",
MyTurn: true,
DeadlineUnixMs: 1756998040000,
TurnSeq: 2,
ChainLength: 2,
Players: fixtureScores(),
TurnPlayerId: "p3",
}}},
"server_move_rejected": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{
Reason: noituv1.RejectReason_REJECT_REASON_WRONG_LINK,
Word: "cà phê",
TurnSeq: 2,
}}},
"server_move_rejected_near_miss": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{
Reason: noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY,
Word: "binh yen",
TurnSeq: 2,
Suggestion: "bình yên",
}}},
"server_word_reported": {Payload: &noituv1.ServerMessage_WordReported{WordReported: &noituv1.WordReported{
Word: "bình tâm",
}}},
"server_quick_match_status": {Payload: &noituv1.ServerMessage_QuickMatchStatus{QuickMatchStatus: &noituv1.QuickMatchStatus{
Queued: true,
}}},
"server_game_over": {Payload: &noituv1.ServerMessage_GameOver{GameOver: &noituv1.GameOver{
IWon: false,
Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE,
ChainLength: 11,
// Ranked, so a fixture that quietly reordered the table would show
// up as ranks that no longer match their positions.
Standings: []*noituv1.PlayerScore{
{PlayerId: "p2", Name: "Khách mời", Score: 42, Connected: true, Rank: 1},
{PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Eliminated: true, Connected: true, Rank: 2},
{PlayerId: "p3", Name: "Khách 2", Score: 3, Eliminated: true, Rank: 3},
},
}}},
"server_player_eliminated": {Payload: &noituv1.ServerMessage_PlayerEliminated{
PlayerEliminated: &noituv1.PlayerEliminated{
PlayerId: "p1",
Name: "Người chơi",
IsMe: true,
Reason: noituv1.GameEndReason_GAME_END_REASON_TIMEOUT,
// A repeated string of Vietnamese words: the one field in the
// contract whose encoding is neither a scalar nor a submessage.
Suggestions: []string{"sinh viên", "sinh sôi"},
},
}},
"server_error": {Payload: &noituv1.ServerMessage_Error{Error: &noituv1.ServerError{
Code: "room_not_found",
Message: "room_not_found",
}}},
"server_pong": {Payload: &noituv1.ServerMessage_Pong{Pong: &noituv1.Pong{
ClientTimeMs: 1756998000123,
ServerTimeMs: 1756998000456,
}}},
// Asymmetric on purpose: equal booleans would not catch the two fields
// being swapped, which is exactly the mistake that shows one player
// their opponent's answer as their own.
"server_chat_message": {Payload: &noituv1.ServerMessage_ChatMessage{ChatMessage: &noituv1.ChatMessage{
FromMe: false,
PlayerId: "p2",
Author: "Khách mời",
Text: "Tiếng “sinh” khó nối lắm.",
SentUnixMs: 1756998000123,
}}},
// Two sides of a conversation plus a line whose author has left, which
// is the one combination that exercises the repeated field, both
// from_me values, and the empty-author convention at once.
"server_chat_history": {Payload: &noituv1.ServerMessage_ChatHistory{ChatHistory: &noituv1.ChatHistory{
Messages: []*noituv1.ChatMessage{
{FromMe: true, PlayerId: "p1", Author: "Người chơi", Text: "Bắt đầu nhé", SentUnixMs: 1756998000000},
{FromMe: false, PlayerId: "p2", Author: "Khách mời", Text: "Sẵn sàng", SentUnixMs: 1756998000456},
// A seat that has been vacated carries neither a name nor an id.
{FromMe: false, Author: "", Text: "Tôi phải đi", SentUnixMs: 1756998000789},
},
}}},
// An owner looking at two guests, one ready and here and one ready but
// away. Every boolean in a slot is load-bearing in at least one row,
// and no two rows agree on all of them.
"server_room_state": {Payload: &noituv1.ServerMessage_RoomState{RoomState: &noituv1.RoomState{
RoomCode: "K7QX",
CanStart: true,
MaxPlayers: 4,
MinPlayers: 2,
GraceMs: 30000,
Players: []*noituv1.PlayerSlot{
{PlayerId: "p1", Name: "Người chơi", IsMe: true, IsOwner: true, Connected: true, Wins: 2},
{PlayerId: "p2", Name: "Khách mời", Ready: true, Connected: true, Wins: 1},
{PlayerId: "p3", Name: "Khách 2", Ready: true},
},
}}},
}
}
// fixtureScores is the players table both in-game messages carry. Three seats
// in three different states, so a fixture that dropped or reordered a row
// cannot still decode to something plausible.
func fixtureScores() []*noituv1.PlayerScore {
return []*noituv1.PlayerScore{
{PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Connected: true},
{PlayerId: "p2", Name: "Khách mời", Score: 42, Eliminated: true, Connected: true},
{PlayerId: "p3", Name: "Khách 2", Score: 3},
}
}
// TestRoundTripEveryVariant marshals and unmarshals each oneof arm. Equality
// alone is not enough: an empty arm such as CreateRoom encodes to a payload of
// zero bytes, so checking that the case survived is what proves the arm is
// distinguishable on the wire at all.
func TestRoundTripEveryVariant(t *testing.T) {
for name, msg := range clientVariants() {
t.Run(name, func(t *testing.T) {
var got noituv1.ClientMessage
roundTrip(t, msg, &got)
if got.GetPayload() == nil {
t.Fatal("payload case lost in round trip")
}
})
}
for name, msg := range serverVariants() {
t.Run(name, func(t *testing.T) {
var got noituv1.ServerMessage
roundTrip(t, msg, &got)
if got.GetPayload() == nil {
t.Fatal("payload case lost in round trip")
}
})
}
}
// TestVariantTablesCoverEveryOneofArm keeps the tables above honest: adding a
// message to either oneof without adding a case here fails the tests rather
// than shipping an untested arm.
func TestVariantTablesCoverEveryOneofArm(t *testing.T) {
client := make([]proto.Message, 0, len(clientVariants()))
for _, m := range clientVariants() {
client = append(client, m)
}
server := make([]proto.Message, 0, len(serverVariants()))
for _, m := range serverVariants() {
server = append(server, m)
}
assertCoversOneof(t, "ClientMessage", client)
assertCoversOneof(t, "ServerMessage", server)
}
// TestCrossLanguageFixtures asserts the committed bytes still decode to the
// messages above. The JavaScript suite reads the same files, so the two
// languages are checked against one artifact rather than against each other's
// assumptions.
func TestCrossLanguageFixtures(t *testing.T) {
if *update {
writeFixtures(t)
return
}
want := allVariants()
files := mustGlob(t)
if len(files) != len(want) {
t.Errorf("fixture count %d does not match variant count %d; run: go test ./internal/wsapi -update", len(files), len(want))
}
for _, f := range files {
name := stem(f)
expect, ok := want[name]
if !ok {
t.Errorf("stale fixture %s has no matching variant", f)
continue
}
delete(want, name)
raw, err := os.ReadFile(f)
if err != nil {
t.Errorf("read %s: %v", f, err)
continue
}
got := expect.ProtoReflect().New().Interface()
if err := proto.Unmarshal(raw, got); err != nil {
t.Errorf("decode %s: %v", f, err)
continue
}
if !proto.Equal(got, expect) {
t.Errorf("%s decoded to %v, want %v", f, got, expect)
}
}
for name := range want {
t.Errorf("no fixture for variant %q; run: go test ./internal/wsapi -update", name)
}
}
func writeFixtures(t *testing.T) {
t.Helper()
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
t.Fatalf("create fixture dir: %v", err)
}
// Remove first, so a renamed variant leaves no orphan behind for the JS
// suite to decode against a schema that no longer describes it.
for _, f := range mustGlob(t) {
if err := os.Remove(f); err != nil {
t.Fatalf("remove %s: %v", f, err)
}
}
all := allVariants()
for name, m := range all {
raw, err := proto.Marshal(m)
if err != nil {
t.Fatalf("marshal %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(fixtureDir, name+".bin"), raw, 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
t.Logf("wrote %d fixtures to %s", len(all), fixtureDir)
}
func allVariants() map[string]proto.Message {
all := map[string]proto.Message{}
for name, m := range clientVariants() {
all[name] = m
}
for name, m := range serverVariants() {
all[name] = m
}
return all
}
func roundTrip(t *testing.T, in, out proto.Message) {
t.Helper()
raw, err := proto.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := proto.Unmarshal(raw, out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !proto.Equal(out, in) {
t.Fatalf("round trip changed the message:\n got %v\nwant %v", out, in)
}
}
// assertCoversOneof reads which arm each sample actually set, rather than
// trusting a list of names written alongside the table. A hardcoded list is
// the thing most likely to be updated in lockstep with the table and so to
// agree with it while both drift away from the schema.
func assertCoversOneof(t *testing.T, msg string, samples []proto.Message) {
t.Helper()
if len(samples) == 0 {
t.Fatalf("%s has no samples", msg)
}
covered := map[protoreflect.Name]bool{}
for _, s := range samples {
m := s.ProtoReflect()
oneof := m.Descriptor().Oneofs().ByName("payload")
if oneof == nil {
t.Fatalf("%s has no payload oneof", msg)
}
set := m.WhichOneof(oneof)
if set == nil {
t.Errorf("%s sample %v sets no payload arm", msg, s)
continue
}
covered[set.Name()] = true
}
fields := samples[0].ProtoReflect().Descriptor().Oneofs().ByName("payload").Fields()
for i := 0; i < fields.Len(); i++ {
if f := fields.Get(i).Name(); !covered[f] {
t.Errorf("%s.payload arm %q is not in the variant table", msg, f)
}
}
}
func mustGlob(t *testing.T) []string {
t.Helper()
files, err := filepath.Glob(filepath.Join(fixtureDir, "*.bin"))
if err != nil {
t.Fatalf("glob fixtures: %v", err)
}
sort.Strings(files)
return files
}
func stem(path string) string {
base := filepath.Base(path)
return base[:len(base)-len(filepath.Ext(base))]
}