package server import ( "context" "encoding/json" "testing" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // blockingMeter is an api.Meter whose CurrentPower blocks until the test ends. type blockingMeter struct { done chan struct{} } func (m blockingMeter) CurrentPower() (float64, error) { <-m.done return 0, nil } // TestInstanceBoundedByContext ensures testInstance returns promptly when a // getter blocks, bounded by the context deadline rather than the getter itself. func TestInstanceBoundedByContext(t *testing.T) { done := make(chan struct{}) defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() testInstance(ctx, blockingMeter{done: done}) require.Less(t, time.Since(start), 500*time.Millisecond, "testInstance must not wait for blocking getter") } // slowPowerMeter blocks on CurrentPower but returns TotalEnergy immediately. type slowPowerMeter struct { done chan struct{} } func (m slowPowerMeter) CurrentPower() (float64, error) { <-m.done return 0, nil } func (m slowPowerMeter) TotalEnergy() (float64, error) { return 42, nil } // TestInstanceParallelProbes ensures a responsive getter returns even while a // sibling blocks; sequential probing would starve energy behind power. func TestInstanceParallelProbes(t *testing.T) { done := make(chan struct{}) defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() res := testInstance(ctx, slowPowerMeter{done: done}) require.Contains(t, res, "energy", "fast getter must return despite a blocking sibling") require.NotContains(t, res, "power", "blocking getter must be abandoned") } // asleepVehicle returns api.ErrAsleep from its getters. type asleepVehicle struct{} func (v asleepVehicle) Soc() (float64, error) { return 0, api.ErrAsleep } func (v asleepVehicle) Range() (int64, error) { return 0, api.ErrAsleep } // TestInstanceAsleep ensures asleep is flagged as state, not as error. func TestInstanceAsleep(t *testing.T) { res := testInstance(context.Background(), asleepVehicle{}) assert.Equal(t, testResult{Value: 0.0, Asleep: true}, res["soc"]) assert.Equal(t, testResult{Value: int64(0), Asleep: true}, res["range"]) } func TestConfigReqUnmarshal(t *testing.T) { var req configReq require.NoError(t, json.Unmarshal([]byte(`{ "type": "template", "deviceTitle": "bar", "template": "foo", "deviceProduct": "baz", "property": 1} `), &req)) assert.Equal(t, config.Properties{ Type: "template", Title: "bar", Product: "baz", }, req.Properties) assert.Equal(t, map[string]any{ "template": "foo", "property": 1.0, }, req.Other) } func TestConfigReqMarshalToMap(t *testing.T) { res, err := propsToMap(config.Properties{ Type: "type", Title: "title", Product: "product", }) require.NoError(t, err) assert.Equal(t, map[string]any{ "deviceTitle": "title", "deviceProduct": "product", }, res) // Disable=false is omitted (zero value) res, err = propsToMap(config.Properties{ Type: "type", Title: "title", }) require.NoError(t, err) assert.NotContains(t, res, "deviceDisable") // Disable=true is included res, err = propsToMap(config.Properties{ Type: "type", Disable: true, }) require.NoError(t, err) assert.Equal(t, map[string]any{ "deviceDisable": true, }, res) } type testStruct struct { Field1 string Field2 int } type testStructWithBool struct { Field1 string Field2 int Field3 bool } func TestMergeMaskedAny(t *testing.T) { tests := []struct { old any new, expected *testStruct }{ { old: &testStruct{"oldValue1", 24}, new: &testStruct{"newValue1", 42}, expected: &testStruct{"newValue1", 42}, }, { old: &testStruct{"oldValue1", 24}, new: &testStruct{masked, 42}, expected: &testStruct{"oldValue1", 42}, }, } for _, tc := range tests { require.NoError(t, mergeMaskedAny(tc.old, tc.new)) assert.Equal(t, tc.expected, tc.new) } // Test boolean field handling boolTests := []struct { old any new, expected *testStructWithBool }{ { // Boolean false should not be overwritten by true old: &testStructWithBool{"oldValue", 24, true}, new: &testStructWithBool{"newValue", 42, false}, expected: &testStructWithBool{"newValue", 42, false}, }, { // Boolean true should be preserved old: &testStructWithBool{"oldValue", 24, false}, new: &testStructWithBool{"newValue", 42, true}, expected: &testStructWithBool{"newValue", 42, true}, }, { // Masked string should be restored, boolean should not be merged old: &testStructWithBool{"oldValue", 24, true}, new: &testStructWithBool{masked, 42, false}, expected: &testStructWithBool{"oldValue", 42, false}, }, } for _, tc := range boolTests { require.NoError(t, mergeMaskedAny(tc.old, tc.new)) assert.Equal(t, tc.expected, tc.new) } } func TestSquashedMergeMaskedAny(t *testing.T) { old := globalconfig.Mqtt{ Config: mqtt.Config{ Broker: "host", User: "user", }, Topic: "test", } { new := old new.User = masked require.NoError(t, mergeMaskedAny(old, &new)) assert.Equal(t, "user", new.User) } { new := old new.User = "new" require.NoError(t, mergeMaskedAny(old, &new)) assert.Equal(t, "new", new.User) } } func TestMergeMaskedFiltersBehavior(t *testing.T) { conf := map[string]any{ "template": "demo-meter", "power": 200.0, } old := map[string]any{ "template": "demo-meter", "power": 100.0, "outdatedField": "old-value", } result, err := mergeMasked(templates.Meter, conf, old) require.NoError(t, err) assert.Equal(t, 200.0, result["power"]) assert.Equal(t, "demo-meter", result["template"]) assert.NotContains(t, result, "outdatedField") } func TestConfigHasCriticalPlugin(t *testing.T) { tc := []struct { name string yaml string want bool }{ {"script top level", "power:\n source: script\n cmd: echo 1", true}, {"script case insensitive", "power:\n Source: SCRIPT\n cmd: echo 1", true}, {"script nested in calc", "power:\n source: calc\n add:\n - source: const\n value: 1\n - source: script\n cmd: echo 1", true}, {"script nested in sequence set", "power:\n source: sequence\n set:\n - source: script\n cmd: echo 1", true}, {"script nested in js transformation", "power:\n source: js\n script: x\n in:\n - name: x\n type: float\n source: script\n cmd: echo 1", true}, {"js without script", "power:\n source: js\n script: \"x = 1\"", false}, {"go without script", "power:\n source: go\n script: \"return 1\"", false}, {"http without script", "power:\n source: http\n uri: http://localhost", false}, {"plain template", "power: 100", false}, {"script in yaml list", "- name: main\n getmaxcurrent:\n source: script\n cmd: echo 1", true}, {"benign yaml list", "- name: main\n maxcurrent: 16", false}, } for _, tc := range tc { t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.want, configHasCriticalPlugin(configReq{Yaml: tc.yaml})) }) } // non-yaml custom config carried in Other assert.True(t, configHasCriticalPlugin(configReq{ Other: map[string]any{"power": map[string]any{"source": "script", "cmd": "echo 1"}}, })) assert.False(t, configHasCriticalPlugin(configReq{ Other: map[string]any{"template": "tesla"}, })) } func TestFilterValidTemplateParams(t *testing.T) { conf := map[string]any{ "template": "generic", "usage": "grid", "capacity": 50.0, "power": 100.0, "outdatedField": "should-be-removed", } result := filterValidTemplateParams(&templates.Template{ Params: []templates.Param{ {Name: "usage"}, {Name: "power"}, {Name: "capacity"}, }, }, conf) assert.Equal(t, "generic", result["template"], "template") assert.Equal(t, "grid", result["usage"], "usage") assert.NotContains(t, result, "outdatedField") }