170 lines
5.4 KiB
Go
170 lines
5.4 KiB
Go
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())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|