工程改进:跨语言契约固化、日志脱敏、状态模型收敛
依据 docs/项目工程改进方案.md 实施(第五梯队 CI/协作除外)。
安全性
- Release 剥离 Log.v/d/i/w(-assumenosideeffects),保留 Log.e
- 全量频道/成员 dump、消息正文、发送者 UID 改为 BuildConfig.DEBUG 保护
(实测 release dex 中 "Channel: id=" / "Client: id=" / selfId= 等均为 0 命中)
构建产物体积
- .gitignore 补 *.a(保留 .opus/lib 四个 ABI 预编译库)、.opus/install、
app/libs/*.aar、dnn/torch
- git rm --cached 除名 AAR、.gradle、.opus/{build,install}、dnn/torch
跟踪体积 50MB → 17.4MB,工作区文件不受影响
跨语言契约(本方案核心)
- 新增 go/teamspeak/contract.go:字段名常量 + BridgeContractVersion 单一事实来源
- 新增契约测试:反射断言常量与 struct tag 一致;Go/Kotlin 共用 golden 样本
- 新增 BridgeContract.kt:启动校验 AAR 契约版本,不匹配则阻止连接
- 修正文档:channel_order 是前驱频道 ID(链表指针)而非排序权重,
按它数值排序会打乱频道树;ChannelInfo.order 在 Go 侧不存在
状态模型
- ConnectionState 增 Idle 取代 null 编码,connectionState 不再可空
- applyClients 改为按频道差分更新,避免全量刷新导致频道树整体重组
- 频道数据 freshness 统一由 Repository 维护,修复 ViewModel 与 Repository
两份时间戳不同步导致的重复刷新
UI
- 修复 collectAsState() 在参数位置调用导致的 isSelf 快照失效
- 消除 9 处 !! 断言(ChannelListScreen)
- 硬编码 24.5/14.5dp 与 depth*24 收敛到 UiTokens.Spacing,缩进加 4 层上限
测试
- Kotlin 测试 7 → 106;Go 契约测试新增 24 个用例
- 各覆盖 InputValidator、消息送达确认、僵尸会话过滤、频道差分、
频道顺序语义、错误分类
其他修复
- classifyError 提取为纯函数并补测试;修复 too many clones (id=521)
未识别导致英文原文直接暴露给用户
版本号提升至 1.0.13(versionCode 13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user