Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/chargers_test.go
T
tajniak81andClaude Opus 5 2425a8d3d6 A field we have no name for is still a field it sent
Two cards on the Charging page were answering with a fraction of what the charger
and the account actually report, and in both the losses happened quietly, in a
parse that kept the fields it recognised and dropped the rest on the floor.

Charger information asked three account-wide views and kept fourteen fields.
A charger registered on its own is absent from the site view, which is the only
one of the three carrying state, charge power and OCPP status — so exactly the
charger that stands alone got the column of dashes, and nothing said why. The
per-charger station record, get_evcharger_station_info, is what the mobile app
opens when you tap a charger, and it is the one view that answers for a charger
outside a station; it is now the fourth view, asked per charger, a failure there
costing that charger's row and no more. Alongside it, every field each of the
four views sent is kept as attrs, under the cloud's own key, nested objects
joined with a dot and arrays carrying their index. First view to answer a key
wins, which is the rule the named fields already merged by. Two hundred keys and
two hundred and forty runes per value keep a station record with a session list
from becoming the whole card.

Charger readings lost data twice over. The frame decoder skipped any field byte
its per-message map could not name, and a message type with no map decoded to
nothing at all; those fields are now kept under the message and the byte they
arrived in — 0410.c9 — decoded but unscaled, because a factor is half of a
meaning and we do not have the other half. Then the projection read forty-odd
names into typed fields and dropped the remainder: sessionStartedAt, the
per-phase session energies, the three touch modes, the load-balance monitor and
its meter flag, the solar monitor. Those land in extra, and the list maintains
itself — the four accessors note every key they read, extra is what is left, and
a field modelled later stops appearing there without anyone remembering to
remove it.

Keeping unnamed fields had one consequence worth guarding. An unmapped message
now decodes to something rather than nothing, and ingest stamped settingsAt for
anything that was not telemetry — the timestamp a control command waits on to
say the charger acknowledged it. A frame we cannot read is not an
acknowledgement, so the stamp is now conditional on the message type being one
we map, while its fields are kept either way.

Both cards show the remainder as what it is: the service's own key, no unit, no
translation, no renaming, under a heading that says whose words these are. The
blocks appear only when there is something in them, so a Modbus charger's
readings card and a charger the cloud says nothing more about are unchanged.
Naming one of these fields is a later commit, made from evidence; inventing a
label for it today would only make a guess look settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:58:15 +02:00

237 lines
9.1 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)
}
}
// Everything a view sent is kept, not only the fields the merge has a name for:
// nested objects and arrays arrive under the path the cloud nested them at, and
// a field that says nothing leaves no row behind.
func TestAttrsKeepEveryFieldTheCloudSent(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage",
"wifi_online":true,"rssi":-52,"grid":{"tariff":"day"},"tags":["a","b"],"blank":" ","absent":null}]}}`))
attrs := inv.list()[0].Attrs
want := map[string]string{
"evChargerSn": "EVSN1", "evChargerName": "Garage", "wifi_online": "true",
"rssi": "-52", "grid.tariff": "day", "tags[0]": "a", "tags[1]": "b",
}
for k, v := range want {
if attrs[k] != v {
t.Fatalf("attrs[%q] = %q, want %q", k, attrs[k], v)
}
}
for _, k := range []string{"blank", "absent"} {
if _, ok := attrs[k]; ok {
t.Fatalf("attrs[%q] = %q; a field that says nothing must not make a row", k, attrs[k])
}
}
// A second view fills gaps without overwriting what the first one answered.
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","rssi":-70,"bt_mac":"AA:BB"}]}}`))
attrs = inv.list()[0].Attrs
if attrs["rssi"] != "-52" {
t.Fatalf("rssi = %q, want the first view's -52", attrs["rssi"])
}
if attrs["bt_mac"] != "AA:BB" {
t.Fatalf("bt_mac = %q, want AA:BB", attrs["bt_mac"])
}
}
// The station record is the only view that answers for a charger standing
// outside a station, so it fills the live fields the list views left empty —
// without claiming the charger is registered anywhere new.
func TestAddStationFillsGapsWithoutAddingASource(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
inv.addStation("EVSN1", []byte(`{"code":0,"data":{"evChargerSn":"EVSN1","evChargerStatus":2,"power":"7.4",
"ocpp_connect_status":2,"station_name":"Home-DK","device_sw_version":"v1.0.6.1","plug_temperature":41}}`))
c := inv.list()[0]
if c.StatusDesc != stateCharging || c.Power != "7.4" {
t.Fatalf("station view did not fill the live fields: %+v", c)
}
if c.OcppStatusDesc != "connected" || c.SiteName != "Home-DK" || c.Firmware != "v1.0.6.1" {
t.Fatalf("station view did not fill the named fields: %+v", c)
}
if c.Attrs["plug_temperature"] != "41" {
t.Fatalf("plug_temperature = %q, want 41", c.Attrs["plug_temperature"])
}
if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
t.Fatalf("sources = %v; the station record is a view, not a registration", c.Sources)
}
// A serial no view found is not a charger this pass may invent.
inv.addStation("EVSN9", []byte(`{"code":0,"data":{"evChargerSn":"EVSN9"}}`))
if len(inv.list()) != 1 {
t.Fatalf("got %d chargers, want 1", len(inv.list()))
}
}
func TestRawRecordsDescendsToANestedList(t *testing.T) {
body := []byte(`{"data":{"charging_pile_info":{"charging_pile_list":[{"device_sn":"EVSN1","power":"7.4"}]}}}`)
got := rawRecords(body, "charging_pile_info", "charging_pile_list")
if len(got) != 1 || got[0]["power"] != "7.4" {
t.Fatalf("rawRecords = %v", got)
}
if rawRecords(body, "charging_pile_info") != nil {
t.Fatal("an object where a list was asked for should yield nothing")
}
if rawRecords([]byte(`{"data":[]}`), "missing") != nil {
t.Fatal("a data that is not an object should yield nothing")
}
}