mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-06 16:16:48 +00:00
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.
397 lines
14 KiB
Go
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))]
|
|
}
|