// Package miot provides white-box tests for miot_client_device.go device management. package miot import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) // ============================================================================ // GetDeviceInfo // ============================================================================ func TestGetDeviceInfo_Exists(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did1"] = map[string]interface{}{ "did": "did1", "name": "Test Device", } c.deviceListMu.Unlock() info := c.GetDeviceInfo("did1") if info == nil { t.Fatal("GetDeviceInfo should return non-nil for existing device") } if info["name"] != "Test Device" { t.Errorf("name = %v, want 'Test Device'", info["name"]) } } func TestGetDeviceInfo_NotExists(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() info := c.GetDeviceInfo("nonexistent") if info != nil { t.Errorf("GetDeviceInfo should return nil for non-existent, got %v", info) } } // ============================================================================ // GetDeviceInfos // ============================================================================ func TestGetDeviceInfos(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did1"] = map[string]interface{}{"name": "dev1"} c.deviceListCache["did2"] = map[string]interface{}{"name": "dev2"} c.deviceListMu.Unlock() infos := c.GetDeviceInfos() if len(infos) != 2 { t.Errorf("GetDeviceInfos len = %d, want 2", len(infos)) } if infos["did1"]["name"] != "dev1" { t.Errorf("did1 name = %v", infos["did1"]["name"]) } // Should be a copy infos["new"] = map[string]interface{}{} if _, ok := c.GetDeviceInfos()["new"]; ok { t.Error("GetDeviceInfos should return independent copies") } } // ============================================================================ // RefreshDevices — no HTTP client // ============================================================================ func TestRefreshDevices_NoHTTP(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() err := c.RefreshDevices(nil) if err == nil { t.Error("RefreshDevices without HTTP client should error") } } // ============================================================================ // Helper functions // ============================================================================ func TestGetConnectType(t *testing.T) { tests := []struct { info map[string]interface{} want int }{ {map[string]interface{}{"connect_type": float64(23)}, 23}, {map[string]interface{}{"connect_type": float64(8)}, 8}, {map[string]interface{}{}, 0}, {nil, 0}, } for _, tt := range tests { got := getConnectType(tt.info) if got != tt.want { t.Errorf("getConnectType(%v) = %d, want %d", tt.info, got, tt.want) } } } func TestGetOnline(t *testing.T) { tests := []struct { info map[string]interface{} want bool }{ {map[string]interface{}{"online": true}, true}, {map[string]interface{}{"online": false}, false}, {map[string]interface{}{}, false}, {nil, false}, } for _, tt := range tests { got := getOnline(tt.info) if got != tt.want { t.Errorf("getOnline(%v) = %v, want %v", tt.info, got, tt.want) } } } func TestIsSharedDevice(t *testing.T) { tests := []struct { info map[string]interface{} want bool }{ {map[string]interface{}{"owner": map[string]interface{}{"userid": "123"}}, true}, {map[string]interface{}{"owner": map[string]interface{}{"nickname": "user"}}, false}, {map[string]interface{}{}, false}, {map[string]interface{}{"owner": "not_a_map"}, false}, } for _, tt := range tests { got := isSharedDevice(tt.info) if got != tt.want { t.Errorf("isSharedDevice(%v) = %v, want %v", tt.info, got, tt.want) } } } func TestCloneMap(t *testing.T) { orig := map[string]interface{}{"a": "1", "b": 2} clone := cloneMap(orig) if clone["a"] != "1" || clone["b"] != 2 { t.Error("cloneMap should preserve values") } clone["a"] = "changed" if orig["a"] != "1" { t.Error("cloneMap should create independent copy") } } func TestAggregateDeviceState(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did1"] = map[string]interface{}{"did": "did1", "online": false} // All sources offline c.deviceListCloud["did1"] = map[string]interface{}{"online": false} c.aggregateDeviceState("did1") if c.deviceListCache["did1"]["online"].(bool) { t.Error("should be offline when all sources offline") } // Cloud online c.deviceListCloud["did1"] = map[string]interface{}{"online": true} c.aggregateDeviceState("did1") if !c.deviceListCache["did1"]["online"].(bool) { t.Error("should be online when cloud is online") } // Gateway online c.deviceListCloud["did1"] = map[string]interface{}{"online": false} c.deviceListGateway["did1"] = map[string]interface{}{"online": true} c.aggregateDeviceState("did1") if !c.deviceListCache["did1"]["online"].(bool) { t.Error("should be online when gateway is online") } // LAN online c.deviceListGateway["did1"] = map[string]interface{}{"online": false} c.deviceListLan["did1"] = map[string]interface{}{"online": true} c.aggregateDeviceState("did1") if !c.deviceListCache["did1"]["online"].(bool) { t.Error("should be online when LAN is online") } c.deviceListMu.Unlock() } // ============================================================================ // ScheduleRefreshDevices // ============================================================================ func TestScheduleRefreshDevices_SetsTimer(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.ScheduleRefreshDevices(100 * time.Millisecond) c.mu.Lock() timer := c.refreshCloudDevicesTimer c.mu.Unlock() if timer == nil { t.Error("refreshCloudDevicesTimer should be set") } // Cancel to avoid actual refresh if timer != nil { timer.Stop() } } func TestScheduleRefreshDevices_ReplacesTimer(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() firstTimer := time.NewTimer(time.Hour) c.refreshCloudDevicesTimer = firstTimer c.ScheduleRefreshDevices(100 * time.Millisecond) c.mu.Lock() secondTimer := c.refreshCloudDevicesTimer c.mu.Unlock() if secondTimer == firstTimer { t.Error("ScheduleRefreshDevices should replace existing timer") } if secondTimer != nil { secondTimer.Stop() } } // ============================================================================ // OnDeviceStateChanged // ============================================================================ func TestOnDeviceStateChanged_UpdatesCache(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did1"] = map[string]interface{}{ "did": "did1", "online": false, } c.deviceListMu.Unlock() c.OnDeviceStateChanged("did1", map[string]interface{}{ "online": true, }) c.deviceListMu.RLock() online := c.deviceListCache["did1"]["online"] c.deviceListMu.RUnlock() if online != true { t.Errorf("online should be true, got %v", online) } } func TestOnDeviceStateChanged_NonExistentDevice(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() // Should not panic for non-existent device c.OnDeviceStateChanged("nonexistent", map[string]interface{}{ "online": true, }) } // ============================================================================ // GetDeviceInfo nil check // ============================================================================ func TestGetDeviceInfo_EmptyCache(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() // Empty cache info := c.GetDeviceInfo("anything") if info != nil { t.Errorf("GetDeviceInfo on empty cache should return nil, got %v", info) } } // ============================================================================ // 米家删除设备必须从缓存中剔除 // ============================================================================ // TestPruneRemovedDevices_RemovesAbsentDevice 云端不再返回的设备要从三源 + 缓存里彻底剔除。 func TestPruneRemovedDevices_RemovesAbsentDevice(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "home_id": "h1", "online": true} c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": true} c.deviceListCloud["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": true} c.deviceListGateway["did-gone"] = map[string]interface{}{"did": "did-gone", "online": false} c.deviceListLan["did-gone"] = map[string]interface{}{"did": "did-gone", "online": false} removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ "did-keep": {"did": "did-keep", "home_id": "h1", "online": true}, }, nil) if len(removed) != 1 || removed[0] != "did-gone" { t.Fatalf("removed = %v, want [did-gone]", removed) } if _, ok := c.deviceListCache["did-gone"]; ok { t.Error("deviceListCache 应剔除已删设备") } if _, ok := c.deviceListCloud["did-gone"]; ok { t.Error("deviceListCloud 应剔除已删设备") } if _, ok := c.deviceListGateway["did-gone"]; ok { t.Error("deviceListGateway 应剔除已删设备") } if _, ok := c.deviceListLan["did-gone"]; ok { t.Error("deviceListLan 应剔除已删设备") } if _, ok := c.deviceListCache["did-keep"]; !ok { t.Error("云端仍存在的设备不应被剔除") } c.deviceListMu.Unlock() } // TestPruneRemovedDevices_StartupCacheOnly 回归:重启后 deviceListCloud 尚为空, // 只遍历它(旧实现)会漏掉「停机期间在米家被删除」的设备,必须遍历缓存。 func TestPruneRemovedDevices_StartupCacheOnly(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "home_id": "h1", "online": false} c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": false} removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ "did-keep": {"did": "did-keep", "home_id": "h1", "online": true}, }, nil) if len(removed) != 1 || removed[0] != "did-gone" { t.Fatalf("removed = %v, want [did-gone]", removed) } if _, ok := c.deviceListCache["did-gone"]; ok { t.Error("从 .dict 恢复的已删设备应被剔除") } c.deviceListMu.Unlock() } // TestPruneRemovedDevices_KeepsDeviceReportedOnlineByGatewayOrLan 网关/局域网仍在线则保留。 func TestPruneRemovedDevices_KeepsDeviceReportedOnlineByGatewayOrLan(t *testing.T) { for _, tc := range []string{"gateway", "lan"} { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) c.deviceListMu.Lock() c.deviceListCache["did-x"] = map[string]interface{}{"did": "did-x", "home_id": "h1", "online": true} if tc == "gateway" { c.deviceListGateway["did-x"] = map[string]interface{}{"online": true, "push_available": true} } else { c.deviceListLan["did-x"] = map[string]interface{}{"online": true, "push_available": true} } removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ "did-other": {"did": "did-other", "home_id": "h1", "online": true}, }, nil) if len(removed) != 0 { t.Errorf("%s 仍在线时不应剔除,removed = %v", tc, removed) } if _, ok := c.deviceListCache["did-x"]; !ok { t.Errorf("%s 仍在线时缓存应保留", tc) } c.deviceListMu.Unlock() c.Stop() } } // TestPruneRemovedDevices_HomeScope 只刷新部分家庭时,不碰其它家庭与无 home 的共享设备。 func TestPruneRemovedDevices_HomeScope(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did-h1"] = map[string]interface{}{"did": "did-h1", "home_id": "h1", "online": true} c.deviceListCache["did-h2"] = map[string]interface{}{"did": "did-h2", "home_id": "h2", "online": true} c.deviceListCache["did-shared"] = map[string]interface{}{"did": "did-shared", "home_id": "", "online": true} // 本次只刷新了 h1:h2 与共享设备不受影响 removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ "did-h1": {"did": "did-h1", "home_id": "h1", "online": true}, }, map[string]struct{}{"h1": {}}) if len(removed) != 0 { t.Fatalf("家庭作用域内不应剔除,removed = %v", removed) } if _, ok := c.deviceListCache["did-h2"]; !ok { t.Error("其它家庭的设备不应被剔除") } if _, ok := c.deviceListCache["did-shared"]; !ok { t.Error("无 home_id 的共享设备不应被剔除") } // 全量刷新(scope 为空)时,h2 与共享设备都要按云端列表剔除 removed = c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ "did-h1": {"did": "did-h1", "home_id": "h1", "online": true}, }, nil) if len(removed) != 2 { t.Fatalf("全量刷新应剔除 2 台,removed = %v", removed) } if _, ok := c.deviceListCache["did-h2"]; ok { t.Error("did-h2 应被剔除") } if _, ok := c.deviceListCache["did-shared"]; ok { t.Error("did-shared 应被剔除") } c.deviceListMu.Unlock() } // TestPruneRemovedDevices_KeepsSubDevice 子设备(xxx.s1)被归并到父设备,不参与删除判定。 func TestPruneRemovedDevices_KeepsSubDevice(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["gateway1.s1"] = map[string]interface{}{"did": "gateway1.s1", "home_id": "h1", "online": true} removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ "gateway1": {"did": "gateway1", "home_id": "h1", "online": true}, }, nil) if len(removed) != 0 { t.Fatalf("子设备不应被剔除,removed = %v", removed) } if _, ok := c.deviceListCache["gateway1.s1"]; !ok { t.Error("子设备应保留在缓存中") } c.deviceListMu.Unlock() } // TestPruneRemovedDevices_EmptyCloudListSkipsRemoval 云端返回空列表视为接口异常,不清库。 func TestPruneRemovedDevices_EmptyCloudListSkipsRemoval(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.deviceListMu.Lock() c.deviceListCache["did1"] = map[string]interface{}{"did": "did1", "home_id": "h1", "online": true} removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{}, nil) if len(removed) != 0 { t.Errorf("空云列表不应剔除任何设备,removed = %v", removed) } if _, ok := c.deviceListCache["did1"]; !ok { t.Error("空云列表时缓存应保持原样") } c.deviceListMu.Unlock() } // TestRefreshDevices_PrunesDeletedDevice 端到端:打桩云端接口,云端只剩 1 台设备, // 缓存里的另一台(米家已删除)必须从 DeviceList() 中消失。 func TestRefreshDevices_PrunesDeletedDevice(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case strings.Contains(r.URL.Path, "/homeroom/gethome"): _ = json.NewEncoder(w).Encode(map[string]interface{}{ "code": float64(0), "result": map[string]interface{}{ "homelist": []interface{}{ map[string]interface{}{ "id": float64(100), "name": "网吧2", "uid": float64(7), "roomlist": []interface{}{ map[string]interface{}{ "id": float64(200), "name": "客厅", "dids": []interface{}{"did-keep"}, }, }, }, }, }, }) case strings.Contains(r.URL.Path, "/home/device_list_page"): _ = json.NewEncoder(w).Encode(map[string]interface{}{ "code": float64(0), "result": map[string]interface{}{ "list": []interface{}{ map[string]interface{}{ "did": "did-keep", "name": "保留设备", "spec_type": "urn:miot-spec-v2:device:plug:0000A001:x:1", "model": "cuco.plug.v3", "isOnline": true, }, }, }, }) default: http.NotFound(w, r) } })) defer server.Close() httpCli, err := NewMIoTHttpClient("cn", "cid", "token") if err != nil { t.Fatal(err) } defer httpCli.Close() httpCli.baseURL = server.URL c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() c.SetHTTPClient(httpCli) // 预置缓存(等价于 .dict 恢复):云端已删除的 did-gone 仍在缓存里 c.deviceListMu.Lock() c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "name": "保留设备", "home_id": "100", "online": false} c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "name": "已删设备", "home_id": "100", "online": true} c.deviceListCloud["did-gone"] = map[string]interface{}{"did": "did-gone", "name": "已删设备", "home_id": "100", "online": true} c.deviceListMu.Unlock() if err = c.RefreshDevices(nil); err != nil { t.Fatalf("RefreshDevices: %v", err) } list := c.DeviceList() if _, ok := list["did-gone"]; ok { t.Error("DeviceList() 不应再返回米家已删除的设备") } if _, ok := list["did-keep"]; !ok { t.Error("DeviceList() 应保留云端仍存在的设备") } }