package teamspeak import ( "encoding/json" "reflect" "sort" "testing" ) // jsonFieldNames 通过反射取出 struct 上实际生效的 JSON 字段名。 // encoding/json 只认 tag,所以这是运行时的事实来源。 // 无 tag 的字段退化为 Go 字段名(与 encoding/json 行为一致)。 func jsonFieldNames(t reflect.Type) map[string]string { if t.Kind() == reflect.Ptr { t = t.Elem() } out := make(map[string]string, t.NumField()) for i := 0; i < t.NumField(); i++ { f := t.Field(i) name := f.Name if tag, ok := f.Tag.Lookup("json"); ok { name = tag // 去掉 ",omitempty" 之类的选项 for j := 0; j < len(name); j++ { if name[j] == ',' { name = name[:j] break } } } out[f.Name] = name } return out } // assertFields 断言「契约常量」与「struct 实际 tag」完全一致。 // declared 的 key 是 Go 字段名,value 是 contract.go 里的常量值。 // // 这正是文档所说「改动 bridge.go 任一字段名会导致测试失败」的机制。 func assertFields(t *testing.T, typeName string, sample any, declared map[string]string) { t.Helper() actual := jsonFieldNames(reflect.TypeOf(sample)) // 反向索引:tag 名 → Go 字段名,用于给出可读的报错 actualByTag := make(map[string]string, len(actual)) for goName, tagName := range actual { actualByTag[tagName] = goName } for goName, wantTag := range declared { gotTag, exists := actual[goName] if !exists { t.Errorf("%s: 契约声明了字段 %q,但 struct 中没有该字段(契约已过期)", typeName, goName) continue } if gotTag != wantTag { t.Errorf("%s.%s: JSON 字段名漂移:契约常量=%q,实际 tag=%q\n"+ " 改 tag 就必须同步 contract.go 与 docs/bridge-contract.md,并考虑递增 BridgeContractVersion", typeName, goName, wantTag, gotTag) } } // 反向检查:struct 中新增了字段却没登记进契约 var missing []string for goName, tagName := range actual { if _, ok := declared[goName]; !ok { missing = append(missing, goName+"(json:"+tagName+")") } } if len(missing) > 0 { sort.Strings(missing) t.Errorf("%s: struct 存在未登记进 contract.go 的字段:%v\n"+ " 新增字段需要在 contract.go 中声明,否则 Kotlin 侧的静默失效模式无人看守", typeName, missing) } } // ─── B 类契约:有 Kotlin 消费方,必须逐字段对齐 ──────────────── func TestChannelJSON_ContractStable(t *testing.T) { assertFields(t, "channelJSON", channelJSON{}, map[string]string{ "ID": FieldChannelID, "Name": FieldChannelName, "ParentID": FieldChannelParentID, "Description": FieldChannelDescription, "IsPassword": FieldChannelIsPassword, }) } func TestClientJSON_ContractStable(t *testing.T) { assertFields(t, "clientJSON", clientJSON{}, map[string]string{ "ID": FieldClientID, "Nickname": FieldClientNickname, "UID": FieldClientUID, "ChannelID": FieldClientChannelID, "ServerGroups": FieldClientServerGroups, "IsSelf": FieldClientIsSelf, }) } func TestServerInfoJSON_ContractStable(t *testing.T) { assertFields(t, "serverInfoJSON", serverInfoJSON{}, map[string]string{ "Name": FieldServerName, "WelcomeMessage": FieldServerWelcomeMessage, "MaxClients": FieldServerMaxClients, "ClientsOnline": FieldServerClientsOnline, "ChannelsOnline": FieldServerChannelsOnline, "Uptime": FieldServerUptime, "Version": FieldServerVersion, "Platform": FieldServerPlatform, "Created": FieldServerCreated, "IconID": FieldServerIconID, "DefaultServerGroup": FieldServerDefaultServerGroup, "DefaultChannelGroup": FieldServerDefaultChannelGroup, }) } // ─── C 类契约:当前无消费方,但形状必须可验证 ────────────────── func TestChannelDetailedJSON_ContractStable(t *testing.T) { // channelDetailedJSON 内嵌了 channelJSON 的五个字段(id/name/parentId/description/isPassword), // 这里只登记它自己的字段,公共字段由 TestChannelJSON_ContractStable 覆盖。 assertFields(t, "channelDetailedJSON", channelDetailedJSON{}, map[string]string{ "ID": FieldChannelID, "Name": FieldChannelName, "ParentID": FieldChannelParentID, "Topic": FieldChannelDetailTopic, "Order": FieldChannelDetailOrder, "Codec": FieldChannelDetailCodec, "CodecQuality": FieldChannelDetailCodecQuality, "NeededTalkPower": FieldChannelDetailNeededTalkPower, "MaxClients": FieldChannelDetailMaxClients, "MaxFamilyClients": FieldChannelDetailMaxFamilyClients, "IsMaxClientsUnlimited": FieldChannelDetailIsMaxClientsUnlimited, "IsMaxFamilyClientsUnlimited": FieldChannelDetailIsMaxFamilyClientsUnlimited, "IsPermanent": FieldChannelDetailIsPermanent, "IsSemiPermanent": FieldChannelDetailIsSemiPermanent, "IsDefault": FieldChannelDetailIsDefault, "IsPassword": FieldChannelIsPassword, "IsOrdered": FieldChannelDetailIsOrdered, "IconID": FieldChannelDetailIconID, "NeededModifyPower": FieldChannelDetailNeededModifyPower, }) } func TestInitialSyncJSON_ContractStable(t *testing.T) { assertFields(t, "initialSyncJSON", initialSyncJSON{}, map[string]string{ "Channels": FieldSyncChannels, "Clients": FieldSyncClients, "SelfID": FieldSyncSelfID, "SelfChannelID": FieldSyncSelfChannelID, "Server": FieldSyncServer, }) } // ─── 往返测试:真实样本 → JSON → 字段名断言 ──────────────────── // // 上面的反射测试保证「常量 == tag」,这里保证「tag == 实际序列化输出」, // 两者合起来才能覆盖 encoding/json 的全部行为(如 omitempty、内嵌 struct)。 func TestChannelJSON_RoundTrip(t *testing.T) { in := channelJSON{ ID: "42", Name: "大厅", ParentID: "0", Description: "desc", IsPassword: true, } raw, err := json.Marshal(in) if err != nil { t.Fatalf("marshal: %v", err) } var asMap map[string]json.RawMessage if err := json.Unmarshal(raw, &asMap); err != nil { t.Fatalf("unmarshal to map: %v", err) } want := []string{ FieldChannelID, FieldChannelName, FieldChannelParentID, FieldChannelDescription, FieldChannelIsPassword, } for _, k := range want { if _, ok := asMap[k]; !ok { t.Errorf("序列化结果缺少字段 %q;实际 JSON=%s", k, raw) } } // 值必须真的能往返(父频道 ID 是字符串而非数字,Kotlin 侧按 String 解析) var back channelJSON if err := json.Unmarshal(raw, &back); err != nil { t.Fatalf("unmarshal back: %v", err) } if back != in { t.Errorf("往返后数据不一致:in=%+v out=%+v", in, back) } } // TestChannelJSON_IDIsStringType 钉住一个曾经的真实失效模式: // Kotlin 的 ChannelInfo.id / parentId 是 String,Go 侧若改成数值类型, // Kotlin 会在运行时抛序列化异常(而不是编译失败)。 func TestChannelJSON_IDIsStringType(t *testing.T) { typ := reflect.TypeOf(channelJSON{}) for _, fieldName := range []string{"ID", "ParentID"} { f, ok := typ.FieldByName(fieldName) if !ok { t.Fatalf("channelJSON 缺少字段 %s", fieldName) } if f.Type.Kind() != reflect.String { t.Errorf("channelJSON.%s 必须是 string(Kotlin ChannelInfo 按 String 解析),实际为 %s", fieldName, f.Type.Kind()) } } } // TestClientJSON_ChannelIDIsStringType 同上:ClientInfo.channelId 是 String。 func TestClientJSON_ChannelIDIsStringType(t *testing.T) { f, ok := reflect.TypeOf(clientJSON{}).FieldByName("ChannelID") if !ok { t.Fatal("clientJSON 缺少 ChannelID 字段") } if f.Type.Kind() != reflect.String { t.Errorf("clientJSON.ChannelID 必须是 string(Kotlin ClientInfo.channelId 按 String 解析),实际为 %s", f.Type.Kind()) } } // TestContractVersion_NonEmpty 版本号是对外承诺,不能为空。 func TestContractVersion_NonEmpty(t *testing.T) { if GetContractVersion() == "" { t.Fatal("BridgeContractVersion 不能为空——Kotlin 启动时会校验它") } if GetContractVersion() != BridgeContractVersion { t.Fatalf("GetContractVersion()=%q 与常量 %q 不一致", GetContractVersion(), BridgeContractVersion) } } // TestTargetModeValues 发送/接收能力不对称依赖这三个数值稳定。 func TestTargetModeValues(t *testing.T) { if TargetModePrivate != 1 || TargetModeChannel != 2 || TargetModeServer != 3 { t.Fatalf("targetMode 取值被改动:private=%d channel=%d server=%d(必须为 1/2/3)", TargetModePrivate, TargetModeChannel, TargetModeServer) } }