The connector called ten endpoints of the read surface the map lists, and the charger card showed four views. Everything else an EV charger can reach is now a capability too: the sessions and the history, the savings, the sharing, the binding, the group, the Wi-Fi, the firmware and its update log, the tamper records, the site's own detail, price, networks and energy — plus the vehicle catalogue, dynamic pricing, the currencies and the notification views. Thirty-eight endpoints, one action each. The two message views are GET, so the request path grew a GET half that shares the login retry with the POST one. The per-charger fan-out asks all of them, six at a time rather than one after another, and a charger that belongs to a site brings that site's four views with it once the by-serial lookup has found it. A view that answers with nothing now says so instead of vanishing: the station record is empty for a standalone charger because it has no station, which is an answer worth reading. And "source 0" in the OCPP box carries the address the account's endpoint list gives it. Anker's account-level writes stay out, as do the endpoints whose payloads were only ever read out of the app package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
3.6 KiB
Go
98 lines
3.6 KiB
Go
package ankersolix
|
|
|
|
import "testing"
|
|
|
|
// The site views are only worth asking for once the by-serial lookup has found a
|
|
// site, and that lookup names it differently depending on which shape answered.
|
|
func TestSiteIDFromReadsWhicheverKeyTheLookupUsed(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
attrs map[string]string
|
|
want string
|
|
}{
|
|
{"snake case", map[string]string{"site_id": "s-1"}, "s-1"},
|
|
{"camel case", map[string]string{"siteId": "s-2"}, "s-2"},
|
|
{"nested", map[string]string{"site_info.site_id": "s-3"}, "s-3"},
|
|
{"standalone charger", map[string]string{"device_sn": "AT1"}, ""},
|
|
{"blank is not an answer", map[string]string{"site_id": " "}, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
views := []chargerDetailView{{ID: "ocpp", Attrs: map[string]string{"site_id": "wrong"}}, {ID: "siteBySn", Attrs: tc.attrs}}
|
|
if got := siteIDFrom(views); got != tc.want {
|
|
t.Fatalf("siteIDFrom = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// "source 0" is a number until the account's endpoint list gives it an address.
|
|
func TestNoteOcppSourceNamesTheBackend(t *testing.T) {
|
|
views := []chargerDetailView{
|
|
{ID: "ocpp", Attrs: map[string]string{"source": "1"}},
|
|
{ID: "ocppEndpoints", Attrs: map[string]string{
|
|
"list[0].source": "0", "list[0].url": "wss://ocpp.anker.example/0",
|
|
"list[1].source": "1", "list[1].url": "wss://ocpp.anker.example/1",
|
|
}},
|
|
}
|
|
noteOcppSource(views)
|
|
if want := "source 1 = wss://ocpp.anker.example/1"; views[0].Note != want {
|
|
t.Fatalf("note = %q, want %q", views[0].Note, want)
|
|
}
|
|
}
|
|
|
|
// A number the list does not carry stays a number: a guessed backend would be
|
|
// worse than saying nothing.
|
|
func TestNoteOcppSourceSaysNothingWhenTheListCannotAnswer(t *testing.T) {
|
|
views := []chargerDetailView{
|
|
{ID: "ocpp", Attrs: map[string]string{"source": "7"}},
|
|
{ID: "ocppEndpoints", Attrs: map[string]string{"list[0].source": "0", "list[0].url": "wss://x/0"}},
|
|
}
|
|
noteOcppSource(views)
|
|
if views[0].Note != "" {
|
|
t.Fatalf("note = %q, want none", views[0].Note)
|
|
}
|
|
}
|
|
|
|
// The entry's own address, not a neighbour's.
|
|
func TestEndpointForSourceStaysWithinItsEntry(t *testing.T) {
|
|
endpoints := map[string]string{
|
|
"list[0].source": "0", "list[0].endpoint": "wss://a",
|
|
"list[1].source": "2", "list[1].endpoint": "wss://b",
|
|
}
|
|
if got := endpointForSource(endpoints, "2"); got != "wss://b" {
|
|
t.Fatalf("endpointForSource = %q, want wss://b", got)
|
|
}
|
|
if got := endpointForSource(endpoints, "3"); got != "" {
|
|
t.Fatalf("endpointForSource = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// The multi-serial views accept either spelling, and reject neither silently.
|
|
func TestSerialList(t *testing.T) {
|
|
if got := serialList(invokeParams{SN: "AT1"}); len(got) != 1 || got[0] != "AT1" {
|
|
t.Fatalf("single serial = %v", got)
|
|
}
|
|
got := serialList(invokeParams{SN: "AT1", SNs: []string{"AT2", " ", "AT3"}})
|
|
if len(got) != 2 || got[0] != "AT2" || got[1] != "AT3" {
|
|
t.Fatalf("several serials = %v", got)
|
|
}
|
|
if got := serialList(invokeParams{}); len(got) != 0 {
|
|
t.Fatalf("nothing given = %v", got)
|
|
}
|
|
}
|
|
|
|
// The paged and multi-view endpoints get a sane ask when the caller does not
|
|
// care, rather than a zero the cloud reads as "no page at all".
|
|
func TestPagingAndViewDefaults(t *testing.T) {
|
|
if pageNum(0) != 1 || pageNum(3) != 3 {
|
|
t.Fatalf("pageNum: %d %d", pageNum(0), pageNum(3))
|
|
}
|
|
if pageSize(0) != 20 || pageSize(500) != 20 || pageSize(50) != 50 {
|
|
t.Fatalf("pageSize: %d %d %d", pageSize(0), pageSize(500), pageSize(50))
|
|
}
|
|
if upgradeRecordView(0) != 1 || upgradeRecordView(4) != 1 || upgradeRecordView(2) != 2 {
|
|
t.Fatalf("upgradeRecordView: %d %d %d", upgradeRecordView(0), upgradeRecordView(4), upgradeRecordView(2))
|
|
}
|
|
}
|