246 lines
8.9 KiB
Go
246 lines
8.9 KiB
Go
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)
|
|||
|
|
}
|
|||
|
|
}
|