mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Closing the position no longer wins the game on the spot. The player handed a syllable that starts nothing keeps the turn they were given and loses it to the clock, the way they lose any turn they cannot answer — the win used to land before they had seen the board at all. A turn lost to a position nobody could answer is reported as the dead end it was rather than as time spent thinking. The bot is the exception, and stays one: it has no clock to spend, so the room settles its dead end the moment the search comes back empty. NoMove does that without dressing it up as a resignation the bot never chose. GameOver now carries a few of the words the position still had, filled for the losing player only — the winner was not the one who was stuck. An empty list on a loss is the other half of the message: nothing could have been played, so the panel says so instead of listing nothing.
317 lines
10 KiB
Go
317 lines
10 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: 1,
|
|
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_request_rematch": {Payload: &noituv1.ClientMessage_RequestRematch{
|
|
RequestRematch: &noituv1.RequestRematch{},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// 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: 1,
|
|
AcceptedNickname: "Người chơi ẩn danh",
|
|
}}},
|
|
"server_room_created": {Payload: &noituv1.ServerMessage_RoomCreated{RoomCreated: &noituv1.RoomCreated{
|
|
RoomCode: "K7QX",
|
|
}}},
|
|
"server_room_joined": {Payload: &noituv1.ServerMessage_RoomJoined{RoomJoined: &noituv1.RoomJoined{
|
|
RoomCode: "K7QX",
|
|
OpponentName: "Thuý",
|
|
}}},
|
|
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
|
|
OpeningWord: "hòa bình",
|
|
CurrentSyllable: "bình",
|
|
MyTurn: true,
|
|
DeadlineUnixMs: 1756998020000,
|
|
TurnSeq: 1,
|
|
TurnLimitMs: 20000,
|
|
}}},
|
|
"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,
|
|
},
|
|
CurrentSyllable: "yên",
|
|
MyTurn: true,
|
|
DeadlineUnixMs: 1756998040000,
|
|
TurnSeq: 2,
|
|
MyScore: 3,
|
|
OpponentScore: 5,
|
|
ChainLength: 2,
|
|
}}},
|
|
"server_move_rejected": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{
|
|
Reason: noituv1.RejectReason_REJECT_REASON_WRONG_LINK,
|
|
Word: "cà phê",
|
|
TurnSeq: 2,
|
|
}}},
|
|
"server_game_over": {Payload: &noituv1.ServerMessage_GameOver{GameOver: &noituv1.GameOver{
|
|
IWon: false,
|
|
Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE,
|
|
MyScore: 7,
|
|
ChainLength: 11,
|
|
// 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_opponent_left": {Payload: &noituv1.ServerMessage_OpponentLeft{OpponentLeft: &noituv1.OpponentLeft{
|
|
CanReconnect: true,
|
|
GraceMs: 30000,
|
|
}}},
|
|
"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_rematch_state": {Payload: &noituv1.ServerMessage_RematchState{
|
|
RematchState: &noituv1.RematchState{
|
|
IAccepted: true,
|
|
OpponentAccepted: false,
|
|
ExpiresInMs: 27500,
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// 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))]
|
|
}
|