package ankersolix import "testing" // A charger that sits in a station is absent from the standalone list even // though the account count includes it — the case that made a good login look // like an account with no chargers. func TestStandaloneListMissesStationChargers(t *testing.T) { body := []byte(`{"code":0,"data":{"evChargers":[],"userBindEvChargersCount":2}}`) inv := newInventory() inv.addStandalone(body) if got := len(inv.list()); got != 0 { t.Fatalf("standalone view: got %d chargers, want 0", got) } if n, ok := countChargers(body); !ok || n != 2 { t.Fatalf("countChargers = %d, %v; want 2, true", n, ok) } // The site view is where those two actually live. scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[ {"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2}, {"device_sn":"EVSN2","device_name":"Carport","operating_state":0}]}}}`) inv.addStates(parseScenePiles(scene, "site-1"), "Home") list := inv.list() if len(list) != 2 { t.Fatalf("after site view: got %d chargers, want 2", len(list)) } if list[0].SN != "EVSN1" || list[0].SiteID != "site-1" || list[0].SiteName != "Home" { t.Fatalf("first charger = %+v", list[0]) } if list[0].StatusDesc != stateCharging || list[0].Power != "7.4" { t.Fatalf("status/power not carried over: %+v", list[0]) } if list[0].OcppStatusDesc != "connected" { t.Fatalf("ocpp status = %q, want connected", list[0].OcppStatusDesc) } } func TestAddStandaloneReadsHesFieldNames(t *testing.T) { body := []byte(`{"code":0,"data":{"evChargers":[ {"evChargerSn":"EVSN1","evChargerName":"Garage","evChargerStatus":2,"wifi_online":true}, {"evChargerName":"nameless"}]}}`) inv := newInventory() inv.addStandalone(body) list := inv.list() if len(list) != 1 { t.Fatalf("got %d chargers, want 1 (the serial-less one is skipped)", len(list)) } c := list[0] if c.SN != "EVSN1" || c.Name != "Garage" || c.StatusDesc != stateCharging { t.Fatalf("charger = %+v", c) } if c.Online == nil || !*c.Online { t.Fatalf("online = %v, want true", c.Online) } if len(c.Sources) != 1 || c.Sources[0] != "standalone" { t.Fatalf("sources = %v", c.Sources) } } // Bound devices discover EV chargers by product family and enrich chargers the // other views already found — without dragging in the rest of the account. func TestAddBoundFiltersAndEnriches(t *testing.T) { inv := newInventory() scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[ {"device_sn":"EVSN1","operating_state":0}]}}}`) inv.addStates(parseScenePiles(scene, "site-1"), "Home") bound := []byte(`{"code":0,"data":{"data":[ {"device_sn":"EVSN1","device_pn":"A5191","device_name":"Garage","device_sw_version":"1.2.3","wifi_online":true}, {"device_sn":"EVSN9","device_pn":"a5191","alias_name":"Carport"}, {"device_sn":"SOLAR1","device_pn":"A17C0","device_name":"Solarbank"}]}}`) inv.addBound(bound) list := inv.list() if len(list) != 2 { t.Fatalf("got %d chargers, want 2 (the solarbank is not one)", len(list)) } if list[0].Name != "Garage" || list[0].Firmware != "1.2.3" || list[0].Model != "A5191" { t.Fatalf("enriched charger = %+v", list[0]) } if len(list[0].Sources) != 2 || list[0].Sources[1] != "bound" { t.Fatalf("sources = %v, want [site bound]", list[0].Sources) } if list[1].SN != "EVSN9" || list[1].Name != "Carport" { t.Fatalf("discovered charger = %+v", list[1]) } } // The site view knows the live state; the standalone view saw the charger // first. Merging must not let the later view blank what the earlier one knew. func TestMergeKeepsFirstKnownValues(t *testing.T) { inv := newInventory() inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`)) inv.addStates(parseScenePiles([]byte(`{"data":{"charging_pile_info":{"charging_pile_list":[ {"device_sn":"EVSN1","operating_state":2,"power":"7.4"}]}}}`), "site-1"), "Home") list := inv.list() if len(list) != 1 { t.Fatalf("got %d chargers, want 1", len(list)) } c := list[0] if c.Name != "Garage" { t.Fatalf("name = %q, want Garage (the nameless site record must not clear it)", c.Name) } if c.StatusDesc != stateCharging || c.Power != "7.4" || c.SiteID != "site-1" { t.Fatalf("site view did not fill the gaps: %+v", c) } } func TestDataListCandidateKeys(t *testing.T) { body := []byte(`{"data":{"evChargers":null,"chargerList":[{"sn":"A"},{"sn":"B"},"junk"]}}`) got := dataList(body, "evChargers", "chargerList") if len(got) != 2 { t.Fatalf("got %d objects, want 2 (the non-object is dropped)", len(got)) } if dataList([]byte(`not json`), "data") != nil { t.Fatal("malformed body should yield no list") } if dataList([]byte(`{"data":{}}`), "missing") != nil { t.Fatal("absent key should yield no list") } } func TestPickers(t *testing.T) { m := map[string]any{"a": " x ", "b": "", "n": float64(7), "ns": "42", "t": true, "z": float64(0)} if got := pickString(m, "b", "a"); got != "x" { t.Fatalf("pickString = %q, want x", got) } if got := pickString(m, "n"); got != "7" { t.Fatalf("pickString(number) = %q, want 7", got) } if got := pickString(m, "missing"); got != "" { t.Fatalf("pickString(missing) = %q, want empty", got) } if n, ok := pickInt(m, "missing", "ns"); !ok || n != 42 { t.Fatalf("pickInt = %d, %v; want 42, true", n, ok) } if b, ok := pickBool(m, "t"); !ok || !b { t.Fatalf("pickBool = %v, %v; want true, true", b, ok) } if b, ok := pickBool(m, "z"); !ok || b { t.Fatalf("pickBool(0) = %v, %v; want false, true", b, ok) } if _, ok := pickBool(m, "a"); ok { t.Fatal("a non-boolean string must not read as a bool") } } func TestFillString(t *testing.T) { s := "" fillString(&s, " first ") fillString(&s, "second") if s != "first" { t.Fatalf("s = %q, want first", s) } }