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()) } } }