Files
ts-mobile-go/go/teamspeak/contract_golden_test.go
T
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

170 lines
5.4 KiB
Go
Raw 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"
"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())
}
}
}