Files
sansenandClaude Fable 5 8716b391f6 工程改进:跨语言契约固化、日志脱敏、状态模型收敛
依据 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>
2026-09-10 14:58:15 +08:00

246 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}