Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/chargers_test.go
T
tajniak81andClaude Opus 5 e138fad3f4 Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:13:36 +02:00

158 lines
5.7 KiB
Go

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)
}
}