# 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