Files
ts-mobile-go/go/teamspeak/contract_golden_test.go
T

170 lines
5.4 KiB
Go
Raw Normal View History

package teamspeak
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"testing"
)
// goldenDir 是 Go 与 Kotlin **共用**的契约样本目录。
//
// 这不是普通测试夹具,而是跨语言契约的共享事实载体:
// - Go 侧(本文件):断言样本的字段集合与 struct tag 完全一致
// - Kotlin 侧(BridgeContractTest):断言同一样本能被数据类正确反序列化
//
// 于是字段漂移的传播链被钉死:
// 改 Go tag → 本文件失败 → 必须更新样本 → Kotlin 测试随之失败。
// 任何一环被跳过都会留下一个红灯,不存在「静默降级」的路径。
const goldenDir = "../../android/app/src/test/resources/contract"
func readGolden(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join(goldenDir, name)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取契约样本失败 %s: %v\n"+
"(该目录是 Go 与 Kotlin 共用的契约样本,缺失即契约失守)", path, err)
}
return raw
}
// keySetsEqual 比较两组字段名是否完全相同(顺序无关)。
func keySetsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
sort.Strings(a)
sort.Strings(b)
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// assertGoldenMatchesType 断言样本 JSON 的字段集合 == struct 的 tag 集合。
//
// 两个方向都查:
// - 样本多出字段 → 样本过期,或有人改了 tag 却没同步
// - 样本缺少字段 → struct 新增了字段,必须补进样本(否则 Kotlin 侧拿默认值)
func assertGoldenMatchesType(t *testing.T, sampleName string, raw []byte, target any, objectIsArray bool) {
t.Helper()
typ := reflect.TypeOf(target)
var keys []string
if objectIsArray {
var items []map[string]json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
t.Fatalf("%s 不是合法 JSON 数组: %v", sampleName, err)
}
if len(items) == 0 {
t.Fatalf("%s 样本为空数组,无法校验契约", sampleName)
}
for k := range items[0] {
keys = append(keys, k)
}
} else {
var item map[string]json.RawMessage
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("%s 不是合法 JSON 对象: %v", sampleName, err)
}
for k := range item {
keys = append(keys, k)
}
}
want := make([]string, 0, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
if name, ok := jsonFieldNames(typ)[typ.Field(i).Name]; ok {
want = append(want, name)
}
}
if !keySetsEqual(keys, want) {
sort.Strings(keys)
sort.Strings(want)
t.Errorf("%s 契约漂移:\n 样本字段 = %v\n struct tag = %v\n"+
" 两者必须一致——样本是 Kotlin 侧的唯一解析依据。",
sampleName, keys, want)
}
}
func TestGolden_Channels(t *testing.T) {
assertGoldenMatchesType(t, "channels.json", readGolden(t, "channels.json"), channelJSON{}, true)
}
func TestGolden_Clients(t *testing.T) {
assertGoldenMatchesType(t, "clients.json", readGolden(t, "clients.json"), clientJSON{}, true)
}
func TestGolden_ServerInfo(t *testing.T) {
assertGoldenMatchesType(t, "server_info.json", readGolden(t, "server_info.json"), serverInfoJSON{}, false)
}
// TestGolden_ChannelsRoundTrip 样本必须能被 Go 自己解析回结构体,
// 且关键字段值正确——保证样本描述的是「Go 真的会发出来的东西」。
func TestGolden_ChannelsRoundTrip(t *testing.T) {
var got []channelJSON
if err := json.Unmarshal(readGolden(t, "channels.json"), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got) != 2 {
t.Fatalf("期望 2 个频道,实际 %d", len(got))
}
if got[0].ID != "1" || got[0].Name != "大厅" || got[0].ParentID != "0" {
t.Errorf("频道[0] 解析异常: %+v", got[0])
}
if !got[1].IsPassword {
t.Errorf("频道[1] 应为加密频道: %+v", got[1])
}
}
func TestGolden_ClientsRoundTrip(t *testing.T) {
var got []clientJSON
if err := json.Unmarshal(readGolden(t, "clients.json"), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got) != 3 {
t.Fatalf("期望 3 个客户端,实际 %d", len(got))
}
if !got[0].IsSelf || got[0].UID != "abc123==" || got[0].ChannelID != "1" {
t.Errorf("客户端[0] 解析异常: %+v", got[0])
}
if len(got[2].ServerGroups) != 2 {
t.Errorf("客户端[2] 应有 2 个服务器组: %+v", got[2].ServerGroups)
}
}
// TestGolden_ServerInfoTypes 钉住 Kotlin 侧按 String 解析的字段。
// uptime/created 在 Go 侧是 string(数值被格式化过),
// 若改成数值类型,Kotlin 的 String 字段会静默变空。
func TestGolden_ServerInfoTypes(t *testing.T) {
var got serverInfoJSON
if err := json.Unmarshal(readGolden(t, "server_info.json"), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Name != "My Server" || got.ClientsOnline != 3 || got.MaxClients != 32 {
t.Errorf("服务器信息解析异常: %+v", got)
}
if got.Uptime != "3600" || got.Created != "1700000000" {
t.Errorf("uptime/created 必须保持字符串形态: uptime=%q created=%q", got.Uptime, got.Created)
}
typ := reflect.TypeOf(serverInfoJSON{})
for _, name := range []string{"Uptime", "Created", "IconID"} {
f, ok := typ.FieldByName(name)
if !ok {
t.Fatalf("serverInfoJSON 缺少字段 %s", name)
}
if f.Type.Kind() != reflect.String {
t.Errorf("serverInfoJSON.%s 必须是 string(Kotlin ServerInfo 按 String 解析),实际 %s",
name, f.Type.Kind())
}
}
}