357 lines
9.3 KiB
Go
357 lines
9.3 KiB
Go
package charger
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/charger/easee"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/evcc-io/evcc/util/request"
|
|
"github.com/jarcoal/httpmock"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// Helper function to create a payload
|
|
func createPayload(id easee.ObservationID, timestamp time.Time, dataType easee.DataType, value string) json.RawMessage {
|
|
payload := easee.Observation{
|
|
ID: id,
|
|
Timestamp: timestamp,
|
|
DataType: dataType,
|
|
Value: value,
|
|
}
|
|
out, _ := json.Marshal(payload)
|
|
return json.RawMessage(out)
|
|
}
|
|
|
|
func newEasee() *Easee {
|
|
log := util.NewLogger("easee")
|
|
e := Easee{
|
|
Helper: request.NewHelper(log),
|
|
obsTime: make(map[easee.ObservationID]time.Time),
|
|
log: log,
|
|
startDone: func() {},
|
|
cmdC: make(chan easee.SignalRCommandResponse),
|
|
obsC: make(chan easee.Observation),
|
|
}
|
|
e.Client.Timeout = 500 * time.Millisecond //aggressive timeout to accelerate testing
|
|
return &e
|
|
}
|
|
|
|
func TestProductUpdate_IgnoreOutdatedProductUpdate(t *testing.T) {
|
|
e := newEasee()
|
|
|
|
// Test default init
|
|
assert.Equal(t, time.Time{}, e.obsTime[easee.CHARGER_OP_MODE])
|
|
|
|
// Test case 1: Normal update
|
|
now := time.Now().UTC().Truncate(0) //truncate removes sub nanos
|
|
e.ProductUpdate(createPayload(easee.CHARGER_OP_MODE, now, easee.Integer, "2"))
|
|
|
|
assert.Equal(t, now, e.obsTime[easee.CHARGER_OP_MODE])
|
|
assert.Equal(t, 2, e.opMode)
|
|
|
|
// Test case 2: Outdated update
|
|
e.ProductUpdate(createPayload(easee.CHARGER_OP_MODE, now.Add(-5*time.Second), easee.Integer, "1"))
|
|
|
|
assert.Equal(t, now, e.obsTime[easee.CHARGER_OP_MODE])
|
|
assert.Equal(t, 2, e.opMode)
|
|
}
|
|
|
|
func TestProductUpdate_IgnoreZeroSessionEnergy(t *testing.T) {
|
|
e := newEasee()
|
|
|
|
now := time.Now().UTC().Truncate(0)
|
|
e.ProductUpdate(createPayload(easee.SESSION_ENERGY, now, easee.Double, "20"))
|
|
|
|
assert.Equal(t, now, e.obsTime[easee.SESSION_ENERGY])
|
|
assert.Equal(t, float64(20), e.sessionEnergy)
|
|
|
|
t2 := time.Now().UTC().Truncate(0)
|
|
e.ProductUpdate(createPayload(easee.SESSION_ENERGY, t2, easee.Double, "0.0"))
|
|
|
|
//expect observation timestamp updated, value however not
|
|
assert.Equal(t, t2, e.obsTime[easee.SESSION_ENERGY])
|
|
assert.Equal(t, float64(20), e.sessionEnergy)
|
|
}
|
|
|
|
func TestProductUpdate_LifetimeEnergyAndSessionStartEnergy(t *testing.T) {
|
|
e := newEasee()
|
|
|
|
assert.False(t, e.optionalStatePresent())
|
|
|
|
now := time.Now().UTC().Truncate(0) //truncate removes sub nanos
|
|
|
|
tc := []struct {
|
|
obsId easee.ObservationID
|
|
dataType easee.DataType
|
|
value string
|
|
expectInitState bool
|
|
}{
|
|
{easee.TOTAL_POWER, easee.Double, "11.0", false},
|
|
{easee.SESSION_ENERGY, easee.Double, "22.0", false},
|
|
{easee.LIFETIME_ENERGY, easee.Double, "1000.0", true},
|
|
}
|
|
|
|
for _, tc := range tc {
|
|
t.Logf("%+v", tc)
|
|
|
|
e.ProductUpdate(createPayload(tc.obsId, now, tc.dataType, tc.value))
|
|
assert.Equal(t, tc.expectInitState, e.optionalStatePresent())
|
|
}
|
|
}
|
|
|
|
// TestInExpectedOpMode tests the inExpectedOpMode function with different scenarios
|
|
func TestInExpectedOpMode(t *testing.T) {
|
|
tc := []struct {
|
|
opMode int
|
|
enable bool
|
|
expect bool
|
|
}{
|
|
{easee.ModeDisconnected, false, false},
|
|
{easee.ModeAwaitingAuthentication, false, true},
|
|
{easee.ModeAwaitingStart, false, true},
|
|
{easee.ModeOffline, false, false},
|
|
|
|
//enable cases
|
|
{easee.ModeAwaitingAuthentication, true, false},
|
|
{easee.ModeOffline, true, false},
|
|
{easee.ModeCharging, true, true},
|
|
{easee.ModeCompleted, true, true},
|
|
{easee.ModeAwaitingStart, true, true},
|
|
{easee.ModeReadyToCharge, true, true},
|
|
}
|
|
for _, tc := range tc {
|
|
t.Logf("%+v", tc)
|
|
|
|
e := newEasee()
|
|
e.opMode = tc.opMode
|
|
res := e.inExpectedOpMode(tc.enable)
|
|
assert.Equal(t, tc.expect, res)
|
|
}
|
|
}
|
|
|
|
func TestEasee_waitForTickResponse(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
expectedTick int64
|
|
cmdCValue *easee.SignalRCommandResponse
|
|
expectedErr error
|
|
}{
|
|
{
|
|
name: "Success - Tick Found",
|
|
expectedTick: 123,
|
|
cmdCValue: &easee.SignalRCommandResponse{Ticks: 123, WasAccepted: true},
|
|
expectedErr: nil,
|
|
},
|
|
{
|
|
name: "Success - Tick Found, but Rejected",
|
|
expectedTick: 456,
|
|
cmdCValue: &easee.SignalRCommandResponse{Ticks: 456, WasAccepted: false},
|
|
expectedErr: fmt.Errorf("command rejected: %d", 456),
|
|
},
|
|
{
|
|
name: "Timeout",
|
|
expectedTick: 789,
|
|
expectedErr: api.ErrTimeout,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Logf("%+v", tc)
|
|
|
|
e := newEasee()
|
|
|
|
// Set up the command channel for test and Easee to share
|
|
cmdC := make(chan easee.SignalRCommandResponse, 1) // make it buffered for ease of testing
|
|
e.cmdC = cmdC
|
|
|
|
if tc.cmdCValue != nil {
|
|
cmdC <- *tc.cmdCValue
|
|
}
|
|
|
|
err := e.waitForTickResponse(tc.expectedTick)
|
|
|
|
// Assert the result
|
|
if tc.expectedErr != nil {
|
|
assert.EqualError(t, err, tc.expectedErr.Error())
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEasee_postJsonAndWait(t *testing.T) {
|
|
const chargerID string = "TESTTEST"
|
|
const ticks int64 = 638798974487432600
|
|
|
|
settingsUri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, chargerID)
|
|
commandUri := fmt.Sprintf("%s/chargers/%s/commands/resume_charging", easee.API, chargerID)
|
|
|
|
settingsReply := fmt.Sprintf("{\"device\":\"%s\",\"commandId\":48,\"ticks\":%d}", chargerID, ticks)
|
|
|
|
cmdResponse := easee.SignalRCommandResponse{
|
|
WasAccepted: true,
|
|
Ticks: ticks,
|
|
}
|
|
|
|
testCases := []struct {
|
|
uri string
|
|
httpRc int
|
|
respBody string
|
|
cmdResp *easee.SignalRCommandResponse
|
|
noop bool
|
|
err error
|
|
}{
|
|
{settingsUri, 200, "", nil, false, nil}, //sync reply
|
|
{settingsUri, 202, "[]", nil, true, nil}, //noop reply
|
|
{settingsUri, 202, "[" + settingsReply + "]", nil, false, api.ErrTimeout}, //timeout
|
|
{commandUri, 202, "{}", nil, true, nil}, //noop command reply
|
|
{commandUri, 202, settingsReply, &cmdResponse, false, nil}, //command reply
|
|
{commandUri, 400, "", nil, false, fmt.Errorf("invalid status: %d", 400)}, //unexpected result
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Logf("%+v", tc)
|
|
|
|
e := newEasee()
|
|
|
|
httpmock.ActivateNonDefault(e.Client)
|
|
httpmock.RegisterResponder(http.MethodPost, tc.uri,
|
|
httpmock.NewStringResponder(tc.httpRc, tc.respBody))
|
|
|
|
if tc.cmdResp != nil {
|
|
go func() {
|
|
e.cmdC <- *tc.cmdResp
|
|
}()
|
|
}
|
|
|
|
noop, err := e.postJSONAndWait(tc.uri, nil)
|
|
|
|
assert.Equal(t, tc.noop, noop)
|
|
assert.Equal(t, tc.err, err)
|
|
|
|
httpmock.Reset()
|
|
}
|
|
}
|
|
|
|
func TestEasee_waitForChargerEnabledState(t *testing.T) {
|
|
testCases := []struct {
|
|
expEnabled bool
|
|
updateState bool
|
|
sendObs bool
|
|
expectErr error
|
|
}{
|
|
{false, false, false, nil}, // short circuit, already in target state
|
|
{true, true, true, nil}, // normal flow
|
|
{true, false, false, api.ErrTimeout}, // missing state change
|
|
{true, true, false, nil}, // late landing state change (transition without Obs)
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Logf("%+v", tc)
|
|
|
|
e := newEasee()
|
|
e.opMode = easee.ModeAwaitingAuthentication
|
|
|
|
if tc.updateState { // simulate state changes
|
|
go func() {
|
|
e.opMode = easee.ModeCharging // transition to charging
|
|
if tc.sendObs {
|
|
e.obsC <- easee.Observation{
|
|
ID: easee.CHARGER_OP_MODE,
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
err := e.waitForChargerEnabledState(tc.expEnabled)
|
|
|
|
if tc.expectErr != nil {
|
|
assert.EqualError(t, err, tc.expectErr.Error())
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEasee_waitForDynamicChargerCurrent(t *testing.T) {
|
|
testCases := []struct {
|
|
expectedDcc float64
|
|
updateState bool
|
|
sendObs bool
|
|
expectErr error
|
|
}{
|
|
{6, false, false, nil}, // short circuit, already at target dcc
|
|
{32, true, true, nil}, // normal flow
|
|
{32, false, false, api.ErrTimeout}, // missing state change
|
|
{32, true, false, nil}, // late landing state change (transition without Obs)
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Logf("%+v", tc)
|
|
|
|
e := newEasee()
|
|
e.dynamicChargerCurrent = 6
|
|
|
|
if tc.updateState { // simulate state changes
|
|
go func() {
|
|
e.dynamicChargerCurrent = 32 // transition to 32A
|
|
if tc.sendObs {
|
|
e.obsC <- easee.Observation{
|
|
ID: easee.DYNAMIC_CHARGER_CURRENT,
|
|
DataType: easee.Double,
|
|
Value: "32",
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
err := e.waitForDynamicChargerCurrent(tc.expectedDcc)
|
|
|
|
if tc.expectErr != nil {
|
|
assert.EqualError(t, err, tc.expectErr.Error())
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEasee_MaxCurrent(t *testing.T) {
|
|
testCases := []struct {
|
|
targetCurrent int64
|
|
expectCurrent float64
|
|
}{
|
|
{6, 6}, // short circuit
|
|
{32, 16}, // target above max
|
|
{10, 10}, // normal case
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Logf("%+v", tc)
|
|
|
|
e := newEasee()
|
|
e.charger = "CHARGERID"
|
|
e.maxChargerCurrent = 16
|
|
e.dynamicChargerCurrent = 6
|
|
|
|
uriPattern := fmt.Sprintf("=~%s.*", easee.API)
|
|
|
|
//register mock NoOp reply, suffices for this test case
|
|
httpmock.ActivateNonDefault(e.Client)
|
|
httpmock.RegisterResponder(http.MethodPost, uriPattern,
|
|
httpmock.NewStringResponder(202, "[]"))
|
|
|
|
err := e.MaxCurrent(tc.targetCurrent)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tc.expectCurrent, e.current)
|
|
//TODO this fails, either current or dynamicChargerCurrent need to go
|
|
//assert.Equal(t, e.current, e.dynamicChargerCurrent)
|
|
}
|
|
}
|