mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-17 02:14:22 +00:00
feat(proto): add protobuf wire contract and cross-language codegen
proto/noitu/v1/game.proto is the single source of truth for every WebSocket message. buf generates Go types into server/gen and JavaScript types into web/src/lib/proto; both trees are committed so building needs no codegen toolchain. The Go suite emits binary fixtures into proto/testdata and the JavaScript suite decodes the same bytes, so the two generated clients are checked against one artifact rather than against each other's assumptions. CI lints the schema, rejects breaking changes against main, and fails when the committed generated trees drift from the schema. game.NumRejectReasons and game.NumEndReasons let the mapping tests prove every engine reason has a wire value without guessing where the enum ends.
This commit is contained in:
1 parent
ca01145d06
commit
48b3b3c8ac
42 files changed
+6102
-31
No files matched your search
@@ -0,0 +1,112 @@
|
||||
// Package wsapi is the transport layer: it translates between the game engine
|
||||
// and the generated protobuf wire types, and (from phase 5 on) owns the
|
||||
// WebSocket sessions and rooms built on top of them.
|
||||
//
|
||||
// The engine's reason enums and the wire enums are deliberately distinct
|
||||
// types. Renumbering an internal constant must never silently change what a
|
||||
// deployed client decodes, so every crossing goes through an explicit switch
|
||||
// here rather than a cast.
|
||||
package wsapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
)
|
||||
|
||||
// ProtocolVersion is the version this build speaks, sent in Welcome and
|
||||
// expected in Hello.
|
||||
//
|
||||
// It is a whole-protocol number, not a per-message one: the wire contract only
|
||||
// ever grows by addition, so a client and server that agree on this integer
|
||||
// agree on everything they both know how to decode. Bump it when a change
|
||||
// would make an older client misread a frame rather than merely ignore an
|
||||
// unknown field.
|
||||
const ProtocolVersion uint32 = 1
|
||||
|
||||
// RejectReason maps an engine rejection onto the wire enum.
|
||||
//
|
||||
// game.ReasonNone means the move was accepted and so has no wire counterpart;
|
||||
// it maps to UNSPECIFIED, which callers must not put in a MoveRejected. Any
|
||||
// other unmapped value is a bug — a reason was added to the engine without
|
||||
// extending the contract — so it is logged rather than quietly flattened.
|
||||
func RejectReason(r game.RejectReason) noituv1.RejectReason {
|
||||
switch r {
|
||||
case game.ReasonNone:
|
||||
return noituv1.RejectReason_REJECT_REASON_UNSPECIFIED
|
||||
case game.ReasonNotYourTurn:
|
||||
return noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN
|
||||
case game.ReasonTooFewSyllables:
|
||||
return noituv1.RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES
|
||||
case game.ReasonNotInDictionary:
|
||||
return noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY
|
||||
case game.ReasonWrongLink:
|
||||
return noituv1.RejectReason_REJECT_REASON_WRONG_LINK
|
||||
case game.ReasonAlreadyUsed:
|
||||
return noituv1.RejectReason_REJECT_REASON_ALREADY_USED
|
||||
case game.ReasonTimeout:
|
||||
return noituv1.RejectReason_REJECT_REASON_TIMEOUT
|
||||
case game.ReasonGameOver:
|
||||
return noituv1.RejectReason_REJECT_REASON_GAME_OVER
|
||||
}
|
||||
log.Printf("wsapi: no wire mapping for game.RejectReason(%d) %q", int(r), r)
|
||||
return noituv1.RejectReason_REJECT_REASON_UNSPECIFIED
|
||||
}
|
||||
|
||||
// EndReason maps an engine end condition onto the wire enum.
|
||||
//
|
||||
// GAME_END_REASON_OPPONENT_LEFT has no engine counterpart on purpose: a player
|
||||
// disconnecting is a transport event, not a rule, so the room emits that value
|
||||
// directly and the engine never learns about it.
|
||||
func EndReason(r game.EndReason) noituv1.GameEndReason {
|
||||
switch r {
|
||||
case game.EndNone:
|
||||
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
||||
case game.EndTimeout:
|
||||
return noituv1.GameEndReason_GAME_END_REASON_TIMEOUT
|
||||
case game.EndNoLegalMove:
|
||||
return noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE
|
||||
case game.EndResigned:
|
||||
return noituv1.GameEndReason_GAME_END_REASON_RESIGNED
|
||||
}
|
||||
log.Printf("wsapi: no wire mapping for game.EndReason(%d) %q", int(r), r)
|
||||
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
||||
}
|
||||
|
||||
// Difficulty maps a wire difficulty onto a bot strategy selector.
|
||||
//
|
||||
// This one runs client to server, so an unrecognized value is untrusted input
|
||||
// rather than a bug: it is not logged, and the caller decides what to do with
|
||||
// the false.
|
||||
func Difficulty(d noituv1.Difficulty) (bot.Difficulty, bool) {
|
||||
switch d {
|
||||
case noituv1.Difficulty_DIFFICULTY_EASY:
|
||||
return bot.Easy, true
|
||||
case noituv1.Difficulty_DIFFICULTY_MEDIUM:
|
||||
return bot.Medium, true
|
||||
case noituv1.Difficulty_DIFFICULTY_HARD:
|
||||
return bot.Hard, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// There is deliberately no server-to-client difficulty mapping: no message in
|
||||
// the schema carries a Difficulty, so a client learns the difficulty only from
|
||||
// the one it asked for.
|
||||
|
||||
// PlayedWord renders an accepted move for one recipient.
|
||||
//
|
||||
// byMe is the caller's business: the same move is sent to both players and
|
||||
// only this flag differs, so the room serializes one message per player rather
|
||||
// than broadcasting a single shared frame.
|
||||
func PlayedWord(m game.Move, byMe bool) *noituv1.PlayedWord {
|
||||
return &noituv1.PlayedWord{
|
||||
Word: m.Word,
|
||||
Typed: m.Typed,
|
||||
ByMe: byMe,
|
||||
Points: uint32(m.Points),
|
||||
Syllables: uint32(m.Syllables),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package wsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
// engineRejectReasons enumerates the engine's constants from its own count
|
||||
// sentinel.
|
||||
//
|
||||
// The sentinel is what makes this honest. Walking until String returns
|
||||
// "unknown" would be circular — a reason added without a String case would
|
||||
// shorten the range under test and every assertion below would still pass on
|
||||
// a value that has no mapping.
|
||||
func engineRejectReasons() []game.RejectReason {
|
||||
out := make([]game.RejectReason, 0, game.NumRejectReasons)
|
||||
for i := game.RejectReason(0); i < game.NumRejectReasons; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func engineEndReasons() []game.EndReason {
|
||||
out := make([]game.EndReason, 0, game.NumEndReasons)
|
||||
for i := game.EndReason(0); i < game.NumEndReasons; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestEveryEngineReasonIsNamed catches the other half of the same problem: a
|
||||
// constant added inside the block but not to String. Such a value maps and
|
||||
// logs as "unknown", which is unreadable in exactly the situation the log
|
||||
// exists for.
|
||||
func TestEveryEngineReasonIsNamed(t *testing.T) {
|
||||
for _, r := range engineRejectReasons() {
|
||||
if r.String() == "unknown" {
|
||||
t.Errorf("game.RejectReason(%d) has no String case", int(r))
|
||||
}
|
||||
}
|
||||
for _, r := range engineEndReasons() {
|
||||
if r.String() == "unknown" {
|
||||
t.Errorf("game.EndReason(%d) has no String case", int(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectReasonMappingIsExhaustive fails if the engine gains a rejection
|
||||
// with no wire counterpart. Only ReasonNone is allowed to land on UNSPECIFIED:
|
||||
// it means "accepted", so it never travels inside a MoveRejected.
|
||||
func TestRejectReasonMappingIsExhaustive(t *testing.T) {
|
||||
reasons := engineRejectReasons()
|
||||
if len(reasons) < 2 {
|
||||
t.Fatalf("enumerated %d engine reject reasons, expected the full set", len(reasons))
|
||||
}
|
||||
|
||||
seen := make(map[noituv1.RejectReason]game.RejectReason, len(reasons))
|
||||
for _, r := range reasons {
|
||||
got := RejectReason(r)
|
||||
if r == game.ReasonNone {
|
||||
if got != noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
|
||||
t.Errorf("ReasonNone should map to UNSPECIFIED, got %v", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
|
||||
t.Errorf("game.RejectReason(%d) %q has no wire mapping", int(r), r)
|
||||
continue
|
||||
}
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Errorf("%v is produced by both %q and %q; a wire value must identify one reason", got, prev, r)
|
||||
}
|
||||
seen[got] = r
|
||||
}
|
||||
|
||||
// Every wire value must also be reachable. An unreachable one is a contract
|
||||
// the server can never honour, which a client would be right to handle.
|
||||
for _, v := range enumValues(noituv1.RejectReason_REJECT_REASON_UNSPECIFIED.Descriptor()) {
|
||||
w := noituv1.RejectReason(v)
|
||||
if w == noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[w]; !ok {
|
||||
t.Errorf("wire value %v is unreachable: no engine reason maps to it", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndReasonMappingIsExhaustive mirrors the reject-reason walk.
|
||||
// GAME_END_REASON_OPPONENT_LEFT is the one deliberate gap: a disconnect is a
|
||||
// transport event the engine has no concept of, so the room emits it directly.
|
||||
func TestEndReasonMappingIsExhaustive(t *testing.T) {
|
||||
seen := make(map[noituv1.GameEndReason]game.EndReason)
|
||||
for _, r := range engineEndReasons() {
|
||||
got := EndReason(r)
|
||||
if r == game.EndNone {
|
||||
if got != noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED {
|
||||
t.Errorf("EndNone should map to UNSPECIFIED, got %v", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED {
|
||||
t.Errorf("game.EndReason(%d) %q has no wire mapping", int(r), r)
|
||||
continue
|
||||
}
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Errorf("%v is produced by both %q and %q", got, prev, r)
|
||||
}
|
||||
seen[got] = r
|
||||
}
|
||||
|
||||
transportOnly := map[noituv1.GameEndReason]bool{
|
||||
noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT: true,
|
||||
}
|
||||
for _, v := range enumValues(noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED.Descriptor()) {
|
||||
w := noituv1.GameEndReason(v)
|
||||
if w == noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED || transportOnly[w] {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[w]; !ok {
|
||||
t.Errorf("wire value %v is unreachable: no engine reason maps to it", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDifficultyMapping checks each level and, more importantly, that an
|
||||
// unknown wire difficulty is refused rather than defaulted. Difficulty arrives
|
||||
// from the client, so silently treating garbage as Easy would let a malformed
|
||||
// frame choose the opponent.
|
||||
func TestDifficultyMapping(t *testing.T) {
|
||||
want := map[noituv1.Difficulty]bot.Difficulty{
|
||||
noituv1.Difficulty_DIFFICULTY_EASY: bot.Easy,
|
||||
noituv1.Difficulty_DIFFICULTY_MEDIUM: bot.Medium,
|
||||
noituv1.Difficulty_DIFFICULTY_HARD: bot.Hard,
|
||||
}
|
||||
for wire, expect := range want {
|
||||
got, ok := Difficulty(wire)
|
||||
if !ok || got != expect {
|
||||
t.Errorf("Difficulty(%v) = (%v, %v), want (%v, true)", wire, got, ok, expect)
|
||||
}
|
||||
}
|
||||
|
||||
// Every declared level must be reachable, so adding one to the schema
|
||||
// without teaching the server about it fails here rather than at runtime.
|
||||
for _, v := range enumValues(noituv1.Difficulty_DIFFICULTY_UNSPECIFIED.Descriptor()) {
|
||||
d := noituv1.Difficulty(v)
|
||||
if d == noituv1.Difficulty_DIFFICULTY_UNSPECIFIED {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[d]; !ok {
|
||||
t.Errorf("wire difficulty %v has no bot strategy", d)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bad := range []noituv1.Difficulty{
|
||||
noituv1.Difficulty_DIFFICULTY_UNSPECIFIED,
|
||||
noituv1.Difficulty(99),
|
||||
} {
|
||||
if _, ok := Difficulty(bad); ok {
|
||||
t.Errorf("Difficulty(%v) accepted an unknown wire value", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayedWordKeepsTypedInput guards the correction-visibility rule: the UI
|
||||
// has to be able to show that canonicalization changed the player's text, so
|
||||
// the raw input must survive onto the wire.
|
||||
func TestPlayedWordKeepsTypedInput(t *testing.T) {
|
||||
m := game.Move{Word: "hòa bình", Typed: "hoà bình", Syllables: 2, Points: 2}
|
||||
|
||||
mine := PlayedWord(m, true)
|
||||
if mine.GetWord() != "hòa bình" || mine.GetTyped() != "hoà bình" {
|
||||
t.Errorf("canonical/typed pair lost: word=%q typed=%q", mine.GetWord(), mine.GetTyped())
|
||||
}
|
||||
if !mine.GetByMe() || mine.GetSyllables() != 2 || mine.GetPoints() != 2 {
|
||||
t.Errorf("unexpected rendering: %+v", mine)
|
||||
}
|
||||
if theirs := PlayedWord(m, false); theirs.GetByMe() {
|
||||
t.Error("by_me must be false for the opponent's copy of the same move")
|
||||
}
|
||||
}
|
||||
|
||||
func enumValues(d protoreflect.EnumDescriptor) []protoreflect.EnumNumber {
|
||||
vals := d.Values()
|
||||
out := make([]protoreflect.EnumNumber, 0, vals.Len())
|
||||
for i := 0; i < vals.Len(); i++ {
|
||||
out = append(out, vals.Get(i).Number())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}}},
|
||||
"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,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
// 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))]
|
||||
}
|
||||
Reference in new issue
Block a user