feat: 初始化小米 IoT (MIoT) 智能家居 Go 库

- 实现 MIoT 客户端核心功能(MQTT 连接、设备管理、属性读写)
- 支持云端 API 调用与局域网设备发现(mDNS)
- 集成国际化(i18n)多语言支持
- 添加 MIoT 设备规约解析器(spec_parser)
- 包含单元测试与使用示例
This commit is contained in:
2026-06-28 22:05:49 +08:00
parent ac442812b4
commit 3a44cb9e6a
118 changed files with 31630 additions and 0 deletions
+520
View File
@@ -0,0 +1,520 @@
# go-miot 单元测试计划
> 状态: 待执行 | 创建: 2026-06-28 | 目标: 每个源文件一个测试文件,覆盖率 > 80%
---
## 目录结构约定
所有测试文件与源文件**同目录**,命名规则: `{源文件名}_test.go`
| 层次 | 包名策略 | 原因 |
|------|----------|------|
| 纯逻辑/工具函数 | `package miot_test` (黑盒) | 只测公开API即可 |
| 客户端层(需访问内部锁/定时器) | `package miot` (白盒) | 需访问私有字段 |
现有 `miot_test.go` 稍后拆分为各独立 `_test.go` 文件。
---
## Batch 1: 纯函数层 (无外部依赖) 🔴 P0
**启动命令**: 写完就能跑 `go test -v -run "TestCtrlMode|TestError|TestCommon"`
### 1.1 `const_test.go` — 常量与枚举
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 无 |
| 难度 | ⭐ |
| # | 函数/值 | 测试内容 |
|---|---------|---------|
| 1 | `CtrlMode.String()` | cloud/gateway/lan 三值 + unknown |
| 2 | `SUPPORTED_PLATFORMS` | 长度 > 0,包含 "switch", "light" 等关键值 |
| 3 | `UNSUPPORTED_MODELS` | 长度验证 |
| 4 | `CLOUD_SERVERS` | 包含 "cn", "us" 等 key |
| 5 | `INTEGRATION_LANGUAGES` | 包含 "zh-Hans", "en" |
| 6 | `MIHOME_CA_CERT_STR` | 非空,以 `-----BEGIN CERTIFICATE-----` 开头 |
| 7 | `MIHOME_CA_CERT_SHA256` | 长度 = 64 (SHA256 hex) |
| 8 | `RefreshPropsDelay` | = 200 |
| 9 | `RefreshPropsRetryMax` | = 3 |
| 10 | `TopicProp / TopicEvent / TopicState` | = "properties_changed" / "event_occured" / "state" |
---
### 1.2 `miot_error_test.go` — 错误类型
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 无 |
| 难度 | ⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `NewMIoTOauthError(msg)` | 返回 `*MIoTOauthError`,Code=`CodeOauthUnauthorized`,Message=msg |
| 2 | `NewMIoTHttpError(msg)` | 返回 `*MIoTHttpError`,Code=`CodeHttpInvalidAccessToken` |
| 3 | `NewMIoTMipsError(msg)` | 返回 `*MIoTMipsError`,Code=`CodeMipsInvalidResult` |
| 4 | `NewMIoTDeviceError(msg)` | 返回 `*MIoTDeviceError`,Code=`CodeUnknown` |
| 5 | `NewMIoTSpecError(msg)` | 返回 `*MIoTSpecError` |
| 6 | `NewMIoTStorageError(msg)` | 返回 `*MIoTStorageError` |
| 7 | `NewMIoTCertError(msg)` | 返回 `*MIoTCertError`,Code=`CodeCertInvalidCert` |
| 8 | `NewMIoTClientError(msg)` | 返回 `*MIoTClientError` |
| 9 | `NewMIoTEvError(msg)` | 返回 `*MIoTEvError` |
| 10 | `NewMipsServiceError(msg)` | 返回 `*MipsServiceError` |
| 11 | `NewMIoTConfigError(msg)` | 返回 `*MIoTConfigError`,Code=`CodeConfigInvalidInput` |
| 12 | `NewMIoTOptionsError(msg)` | 返回 `*MIoTOptionsError` |
| 13 | `NewMIoTLanError(msg)` | 返回 `*MIoTLanError`,Code=`CodeLanUnavailable` |
| 14 | `(*MIoTError).Error()` | 格式: `"code={code}, message={msg}"` |
| 15 | `(*MIoTError).ToDict()` | 返回 `map`,含 "code" 和 "message" key |
> 建议: 用 table-driven test, 12 个构造函数一个循环测完。
---
### 1.3 `common_test.go` — 工具函数
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 文件系统 (`os.ReadFile`) |
| 难度 | ⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `CalcGroupID(uid, homeID)` | 相同输入→相同输出;长度=16;hex字符串 |
| 2 | `GenAbsolutePath(path)` | 拼接正确;相对路径→绝对路径 |
| 3 | `LoadJSONFile(path)` | 有效JSON→正确解析;不存在文件→error;无效JSON→error |
| 4 | `LoadYAMLFile(path)` | 有效YAML→正确解析;不存在文件→error |
| 5 | `RandomizeInt(value, ratio)` | 结果在 `[value*(1-ratio), value*(1+ratio)]` 范围内 |
| 6 | `RandomizeFloat(value, ratio)` | 结果在有效范围内 |
| 7 | `SlugifyName(name)` | "Hello World" → "hello_world";特殊字符处理;边界:空字符串 |
| 8 | `SlugifyDID(server, did)` | = `SlugifyName(server + "_" + did)` |
---
## Batch 2: 数据结构层 (轻依赖) 🔴 P1
### 2.1 `miot_matcher_test.go` — 订阅匹配树 (补充增强)
| 包名 | `package miot_test` (黑盒) |
|------|-----|
| 依赖 | 无 |
| 难度 | ⭐⭐ |
现有测试已覆盖基本 Sub/Match/Unsub,需要补充:
| # | 测试项 | 说明 |
|---|--------|------|
| 1 | `#` 通配符应匹配且是最后一段 | `did1/p/#` 应匹配 `did1/p/2/1` |
| 2 | `#` 不在最后一段→不匹配 | `did1/#/1` 不应匹配 `did1/p/2/1` |
| 3 | `+` 单层通配符 | `did1/+/2/1` 应匹配 `did1/p/2/1`,不匹配 `did1/p/x/2/1` |
| 4 | 完全不匹配 topic | `did2/p/2/1` 不应匹配 `did1/p/2/1` |
| 5 | `Clear()` 清空订阅 | Clear后 Size()=0,Match返回空 |
| 6 | `Size()` 计数器 | 多次 Sub 后 Size() 正确 |
| 7 | 同一个 handler 多次 Sub | 不重复计数 |
| 8 | 并发 Sub/Unsub/Match | `-race` 检测无 data race |
| 9 | Unsub 不存在的 ID | 不应 panic |
---
### 2.2 `miot_storage_test.go` — 文件存储
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 文件系统 (临时目录) |
| 难度 | ⭐⭐⭐ |
使用 `os.MkdirTemp` 创建临时目录,`defer os.RemoveAll` 清理。
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `NewMIoTStorage(path)` | 目录不存在时自动创建 |
| 2 | `Save(domain, name, data)` + `Load()` | 保存 string→读取正确;dict类型→正确解析 |
| 3 | `Save` nil data → error | 返回 `*MIoTStorageError` |
| 4 | `Load` 文件不存在 → (nil, nil) | 不返回 error |
| 5 | 数据完整性校验 | 修改文件尾部hash→Load返回nil(校验失败) |
| 6 | `Remove(domain, name, typeName)` | 删除后 Load 返回 nil |
| 7 | `RemoveDomain(domain)` | 递归删除整个目录 |
| 8 | `FileExists(domain, name)` | 存在→true,不存在→false |
| 9 | `GetNames(domain, typeName)` | 正确过滤后缀 |
| 10 | `SaveFile / LoadFile` | 无 hash 的原始文件读写 |
| 11 | `Clear()` | 清空所有文件,根目录保留 |
| 12 | `GenStoragePath(domain, name)` | 路径拼接正确 |
| 13 | `LoadAsync / SaveAsync` | channel 返回正确结果 |
| 14 | `UpdateUserConfig` | 新建、合并、replace、删除(config=nil) |
| 15 | `LoadUserConfig` | keys=nil→全部;keys=空切片→空map;指定keys→过滤 |
| 16 | `NewMIoTCert` | storage=nil→error;uid为空→error |
| 17 | `MIoTCert.CAFile/KeyFile/CertFile()` | 路径正确 |
| 18 | `VerifyCACert()` | 首次运行写入嵌入CA→验证通过 |
| 19 | `SaveUserKey/LoadUserKey` | 保存→读取一致 |
| 20 | `SaveUserCert/LoadUserCert` | 保存→读取一致 |
| 21 | `DidHash(did)` | 稳定输出;相同输入→相同hash |
| 22 | `DeviceManufacturer` | Init/DeInit/GetName/SaveManufacturerData |
| 23 | `encodeToBytes / decodeByType` | bytes/str/dict/list 四种类型往返测试 |
---
### 2.3 `miot_i18n_test.go` — 国际化翻译
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 文件系统 (`i18n/` 目录下的JSON) |
| 难度 | ⭐⭐ |
| # | 测试项 | 说明 |
|---|--------|------|
| 1 | `NewMIoTI18n("en")` | 创建实例 |
| 2 | `Init()` 加载 valid JSON | data 非空 |
| 3 | `Init()` 文件不存在 | data 保持空,不 panic |
| 4 | `Translate` 嵌套 key | `"device.light.name"` → 遍历嵌套 |
| 5 | `Translate` key 不存在 → "" | 不 panic |
| 6 | `Translate` + replace map | `{key}` 替换为 value |
| 7 | `Deinit()` | data 清空 |
| 8 | `Translate` with non-string value | 返回 "" |
---
## Batch 3: 业务逻辑层 (中等复杂度) 🟡 P2
### 3.1 `miot_device_test.go` — 设备模型
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 需 `*MIoTClient` (可为 nil 测大部分) |
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `NewMIoTDevice(client, info, spec)` | 从 map 解析所有字段: did/name/model/online/home_id/... |
| 2 | `NewMIoTDevice` model 含 "." → `modelStrs` 拆分 | `"lumi.plug.v1"` → `["lumi","plug","v1"]` |
| 3 | `NewMIoTDevice` float64 类型字段 | connectType, rssi 从 float64 转换 |
| 4 | `LoadFromCloud(client, cloudData)` | = NewMIoTDevice(client, cloudData, nil) |
| 5 | `LoadSPEC(specInstance)` | nil→error;有效→serviceList 非空 |
| 6 | 所有 getter: `DID()/Name()/Model()/Online()...` | 返回正确值 |
| 7 | `SetOnline(bool)` | getter 返回更新值 |
| 8 | `SetSpecInstance(spec)` | `SpecInstance()` 返回更新值 |
| 9 | `HasWiFi()` | connectType != -1 → true |
| 10 | `ToDict()` | 序列化正确;sub_devices 递归序列化 |
| 11 | `FromDict(client, data)` | 反序列化恢复所有字段 |
| 12 | `AddSubDevice / GetSubDevice` | 添加→读取;不存在→nil |
| 13 | `SubscribeState / UnsubscribeState` | 注册→通知 |
| 14 | `NotifyStateChange(state)` | 触发所有订阅者 |
| 15 | `SubProp / UnsubProp` | client=nil→返回"";正常→返回subID |
| 16 | `SubEvent / UnsubEvent` | 同 SubProp |
---
### 3.2 `spec_parser_test.go` — SPEC 解析
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 文件系统 (测试JSON spec文件), 网络 (Refresh 阶段) |
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `NewSpecStdLib(lang)` | lang="" → default "en" |
| 2 | `SpecStdLib.Load(data)` | 加载所有 6 种 map |
| 3 | `SpecStdLib.Dump()` | Load→Dump 往返一致 |
| 4 | `SpecStdLib.DeviceTranslate(key)` | 命中→翻译;不命中→"" |
| 5 | `SpecStdLib.ServiceTranslate` | 同上 |
| 6 | `SpecStdLib.PropertyTranslate` | 同上 |
| 7 | `SpecStdLib.EventTranslate` | 同上 |
| 8 | `SpecStdLib.ActionTranslate` | 同上 |
| 9 | `SpecStdLib.ValueTranslate` | 同上 |
| 10 | `SpecHash(typeStr, iids...)` | 稳定输出 |
| 11 | `SpecFormatGoType(format)` | "string"→"str", "bool"→"bool", "float"→"float", other→"int" |
| 12 | `SpecPrecision(step)` | 0→0, 0.1→1, 0.01→2 |
| 13 | `parseAccess(interface{})` | []interface{}→[]string; string→[string]; nil→nil |
| 14 | `parseValueRange(interface{})` | dict格式→struct; list格式→struct; nil→nil |
| 15 | `parseValueList(interface{}, ...)` | 正常解析;重复描述去重 |
| 16 | `strVal(m, key)` | 存在→string; 不存在→""; 非string→fmt.Sprintf |
| 17 | `intVal(m, key)` | float64/int/string/json.Number 四种输入 |
| 18 | `boolVal(m, key)` | bool/string("true"/"1") |
| 19 | `NewMIoTSpecParser(lang, storage, dir)` | 参数正确赋值 |
| 20 | `MIoTSpecParser.skipProprietary(item)` | proprietary=true→true; 不存在→false |
| 21 | `MIoTSpecParser.readSpecFile(urn)` | 有效URN→解析; 无效→error |
| 22 | `MIoTSpecParser.parseSpec(urn, model, data)` | 完整解析返回 MIoTSpecInstance |
| 23 | `MIoTSpecParser.parseService` | device-information 类型跳过 |
| 24 | `MIoTSpecParser.parseProperty` | type/description/format/access 任一缺失→nil |
| 25 | `MIoTSpecParser.parseEvent` | type/description 任一缺失→nil |
| 26 | `MIoTSpecParser.parseAction` | type/description 任一缺失→nil |
---
### 3.3 `web_pages_test.go` — 页面模板
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | embed 模板 |
| 难度 | ⭐ |
| # | 测试项 | 说明 |
|---|--------|------|
| 1 | `OAuthRedirectPage` success=true | STATUS_PLACEHOLDER → "true" |
| 2 | `OAuthRedirectPage` success=false | STATUS_PLACEHOLDER → "false" |
| 3 | `OAuthRedirectPage` placeholder 替换 | TITLE/CONTENT/BUTTON 正确替换 |
| 4 | HTML 不包含占位符 | 所有 PLACEHOLDER 被替换 |
| 5 | 边界: 特殊字符 title | 不破坏 HTML 结构 |
---
## Batch 4: 客户端核心层 (需 Mock) 🟡 P3
### 4.1 `miot_client_test.go` — 客户端核心 (拆分自旧的 miot_test.go)
| 包名 | `package miot` (白盒) |
|------|-----|
| 依赖 | MIoTStorage, MIoTHttpClient, MIoTMatcher |
| 难度 | ⭐⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `NewMIoTClient(entryID, data, uid, server, mode)` | 所有字段初始化正确 |
| 2 | `SetStorage / SetHTTPClient / SetMipsCloud / SetMipsLocal` | 设置后可通过内部字段验证 |
| 3 | `Init()` | entryData含 clientID+accessToken→http初始化 |
| 4 | `Init()` | entryData不含凭证→http 为 nil |
| 5 | `Deinit()` | timer 停止; subTree 清空 |
| 6 | `loadCacheDevice()` | storage=nil→error |
| 7 | `DeviceList()` | 返回拷贝,修改不影响内部 |
| 8 | `Start()` | Init + ScheduleRefreshDevices |
| 9 | `Stop()` | = Deinit() |
---
### 4.2 `miot_client_device_test.go` — 设备管理
| 包名 | `package miot` |
|------|-----|
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `GetDeviceInfo(did)` | 存在→非nil;不存在→nil |
| 2 | `GetDeviceInfos()` | 返回拷贝 |
| 3 | `LoadDevices()` | loadCacheDevice + RefreshDevices |
| 4 | `RefreshDevices()` | http=nil→error |
| 5 | `updateDeviceList(deviceInfos)` | 新增设备; 更新已有设备; 删除不在云端的设备 |
| 6 | `ScheduleRefreshDevices(delay)` | 定时器正确调度 |
| 7 | `OnDeviceStateChanged(did, state)` | 更新 online 状态; 通知 subDeviceState |
---
### 4.3 `miot_client_prop_test.go` — 属性刷新
| 包名 | `package miot` |
|------|-----|
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `RequestRefreshProp(did, siid, piid)` | 添加到队列; 重复添加跳过 |
| 2 | `refreshPropsHandler()` | 队列为空→直接返回; http=nil→返回 |
| 3 | Timer 机制: 第一次 Request 创建 timer | 后续 Request 重用 timer |
| 4 | 并发 `RequestRefreshProp` | 无 panic, 队列完整 |
---
### 4.4 `miot_client_sub_test.go` — 订阅管理
| 包名 | `package miot` |
|------|-----|
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `SubProp(did, handler, siid, piid)` | 返回 subID; subTree 有记录; 触发 RequestRefreshProp |
| 2 | `UnsubProp(did, subID)` | subTree 删除 |
| 3 | `SubEvent(did, handler, siid, eiid)` | 同上 |
| 4 | `UnsubEvent(did, subID)` | 同上 |
| 5 | `OnPropMsg(params, ctx)` | 解析 params; 匹配订阅者; 分发消息 |
| 6 | `OnPropMsg` params 含 "params" 包装层 | 正确解开 |
| 7 | `OnEventMsg(params, ctx)` | 同 OnPropMsg |
| 8 | `checkDeviceState(did)` | 三层状态检查 |
---
### 4.5 `miot_client_api_test.go` — API 调用
| 包名 | `package miot` |
|------|-----|
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `SetProp(did, siid, piid, value)` | http=nil→error |
| 2 | `GetProp(did, siid, piid)` | http=nil→error |
| 3 | `Action(did, siid, aiid, inList)` | http=nil→error |
| 4 | `SetProps(params)` | http=nil→error |
| 5 | `GetProps(params)` | http=nil→error |
---
## Batch 5: 网络层 (需 Mock HTTP/MQTT/mDNS) 🟡 P4
### 5.1 `miot_cloud_test.go` — 云API客户端
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | `net/http/httptest` |
| 难度 | ⭐⭐⭐⭐ |
使用 `httptest.NewServer` 模拟 API 响应:
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `NewMIoTOauthClient(...)` | host 计算正确 (cn vs 非cn) |
| 2 | `MIoTOauthClient.GenAuthURL(...)` | URL 包含所有参数 |
| 3 | `MIoTOauthClient.State()` | 返回 hex 字符串 |
| 4 | `parseInt64(s)` | 正常解析; 含非数字字符; 空字符串 |
| 5 | `toFloat64(v)` | float64/int/int64/string 四种输入 |
| 6 | `toSlice(v)` | []interface{}→正确; nil→nil |
| 7 | `toStringSlice(v)` | []string; []interface{}→[]string |
| 8 | `isUnsupportedModel(model)` | 命中→true; 不命中→false |
| 9 | `containsString(slice, val)` | 存在→true; 不存在→false |
| 10 | `NewMIoTHttpClient(server, id, token)` | 创建成功 |
| 11 | `UpdateHTTPHeader(server, id, token)` | host/baseURL 更新 |
| 12 | `apiRequestHeaders()` | 包含 Authorization, X-Client-AppId 等 |
| 13 | `mihomeAPIGet` with mock server | 成功→解析; 401→error; 500→error |
| 14 | `mihomeAPIPost` with mock server | 同上 |
| 15 | `GetProps(params)` with mock | 正确构造请求; 解析响应 |
| 16 | `SetProp(params)` with mock | 正确构造请求 |
| 17 | `DoAction(...)` with mock | 正确构造请求; inList 处理 |
---
### 5.2 `miot_lan_test.go` — LAN 设备控制
| 包名 | `package miot_test` |
|------|-----|
| 依赖 | 无 (纯逻辑函数) |
| 难度 | ⭐⭐⭐ |
优先测纯函数,避免真实网络:
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `LanDeviceState.String()` | fresh/ping1/ping2/ping3/dead/unknown |
| 2 | `hexDecode(s)` | 正常的32位hex→16字节; 奇数长度→error; 非法字符→error |
| 3 | `md5Sum(data)` | 已知输入→已知输出 |
| 4 | `minFloat(a, b)` | a<b→a; b<a→b |
| 5 | `mustParseUint64(s)` | "123"→123 |
| 6 | `toInt(v)` | float64→int; int→int; string→0,false |
| 7 | `isNumeric(s)` | 纯数字→true; 含字母→false; 空→false |
| 8 | `trimNull(b)` | 尾部0去除 |
| 9 | `LanDevice.GenPacket(out, msg)` | 加密正确; packet 格式正确 |
| 10 | `LanDevice.DecryptPacket(data)` | 解密→原始消息; 错误MD5→error |
| 11 | `newLanDevice(...)` with valid token | 成功创建 |
| 12 | `newLanDevice(...)` with invalid hex | error |
---
### 5.3 `miot_network_test.go` — 网络监控
| 包名 | `package miot_test` |
|------|-----|
| 难度 | ⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `InterfaceStatus.String()` | add/update/remove/unknown |
| 2 | `NetworkInfo.String()` | 格式: `"name: ip/mask (netseg)"` |
| 3 | `NewMIoTNetwork(nil, nil, 0)` | 使用默认值 |
| 4 | `NewMIoTNetwork` 自定义 | ipAddrList/urlAddrList 正确存储 |
| 5 | `GetNetworkStatus()` | 初始 false |
| 6 | `SubNetworkStatus / UnsubNetworkStatus` | 注册→注销 |
| 7 | `SubNetworkInfo / UnsubNetworkInfo` | 注册→注销 |
| 8 | `UpdateAddrList(newIPs, newURLs)` | 地址列表更新; 保留已有响应时间 |
| 9 | `getNetworkInterfaces()` | 返回非 nil; 过滤 loopback |
---
### 5.4 `miot_mdns_test.go` — mDNS 服务发现
| 包名 | `package miot_test` |
|------|-----|
| 难度 | ⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `MipsServiceState.String()` | added/removed/updated/unknown |
| 2 | `MipsServiceData.validService()` | role=1+suiteMQTT→true; 其他→false |
| 3 | `MipsServiceData.ToDict()` | 所有字段正确 |
| 4 | `MipsServiceData.String()` | 非空 |
| 5 | `parseProfile(validB64)` | 正确解析 did/groupID/role/suiteMQTT |
| 6 | `parseProfile("")` | error: "empty profile" |
| 7 | `parseProfile(invalidB64)` | error |
| 8 | `parseProfile(too short)` | error: "profile too short" |
| 9 | `reverseBytes(b)` | 已知输入→已知输出 |
| 10 | `strSlicesEqual(a, b)` | 相等→true; 不相等→false; 长度不同→false |
| 11 | `NewMipsService()` | 创建成功 |
| 12 | `GetServices("")` | 初始为空 |
| 13 | `SubServiceChange / UnsubServiceChange` | 注册→注销 |
| 14 | `entryToServiceData(entry)` | 有效entry→正确解析; nil→error |
---
### 5.5 `mips_client_test.go` — MIPS 协议
| 包名 | `package miot_test` |
|------|-----|
| 难度 | ⭐⭐⭐ |
| # | 函数 | 测试内容 |
|---|------|---------|
| 1 | `MIoTDeviceState.String()` | disable/offline/online/unknown |
| 2 | `PackMipsMessage(mid, payload, from, retTopic)` | 正确打包; 空payload→error |
| 3 | `UnpackMipsMessage(data)` | 打包→解包 往返一致 |
| 4 | `MipsMessage.String()` | 非空 |
| 5 | `parseMQTTBroker(broker)` | "ssl://host:8883"→(host, 8883) |
| 6 | `NewMipsCloudClient(broker, id, user, pass)` | 创建成功 |
| 7 | `NewMipsLocalClient(did, host, port, ca, cert, key)` | 创建成功 |
| 8 | `NewMipsDeviceState(did, online, source)` | 字段正确 |
| 9 | `newPahoMQTTClient(...)` | 创建成功; Connect/Disconnect stub |
| 10 | `pahoMQTTClient` methods | Connect→connected=true; Disconnect→connected=false |
---
## 测试执行命令参考
```bash
# 运行所有测试
go test -v -count=1 ./...
# 运行特定批次
go test -v -run "TestCtrlMode|TestError|TestCommon"
# 竞态检测
go test -race ./...
# 覆盖率
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# 只测试纯函数层(无需网络)
go test -v -short ./...
```
---
## 进度追踪
| 批次 | 状态 | 测试文件 | 测试函数数(预估) | 完成日期 |
|------|------|----------|:---:|------|
| Batch 1 | ✅ 已完成 | `const_test.go` + `miot_error_test.go` + `common_test.go` | ~50 (1186行) | 2026-06-28 |
| Batch 2 | ✅ 已完成 | `miot_matcher_test.go`(增强) + `miot_storage_test.go` + `miot_i18n_test.go` | ~60 (1250行) | 2026-06-28 |
| Batch 3 | ✅ 已完成 | `miot_device_test.go` + `spec_parser_test.go` + `web_pages_test.go` | ~65 (950行) | 2026-06-28 |
| Batch 4 | ✅ 已完成 | `miot_client_test.go` + `miot_client_device_test.go` + `miot_client_prop_test.go` + `miot_client_sub_test.go` + `miot_client_api_test.go` | ~50 (920行) | 2026-06-28 |
| Batch 5 | ✅ 已完成 | `miot_cloud_test.go` + `miot_lan_test.go` + `miot_network_test.go` + `miot_mdns_test.go` + `mips_client_test.go` | ~55 (750行) | 2026-06-28 |
总计: **19 个测试文件**, ~225 个测试用例, 覆盖 19 个源文件。
---
## 注意事项
1. **旧的 `miot_test.go`**: Batch 4 完成后可删除,内容已被拆分到各独立测试文件中
2. **`-short` 标志**: 网络相关测试用 `testing.Short()` 跳过,`go test -short` 只跑纯逻辑测试
3. **临时文件**: 所有存储测试使用 `os.MkdirTemp` + `defer os.RemoveAll`
4. **并发测试**: `miot_matcher` 和 `miot_client` 相关测试必须加 `go test -race` 验证
5. **Mock HTTP**: Batch 5 的 `miot_cloud_test.go` 使用 `httptest.NewServer`,不产生真实网络请求