add testing, reverse engineered / generated from OpenDTU source to improve compatibility testing and detecting regressions early.
This commit is contained in:
parent
6b496a39ee
commit
99959726c8
12 changed files with 2888 additions and 0 deletions
500
opendtu_compatibility_test.go
Normal file
500
opendtu_compatibility_test.go
Normal file
|
@ -0,0 +1,500 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenDTUCompatibility_FullStructure validates that our Go structs
|
||||
// can parse the exact JSON structure produced by OpenDTU WebSocket
|
||||
// based on opendtu/src/WebApi_ws_live.cpp
|
||||
func TestOpenDTUCompatibility_FullStructure(t *testing.T) {
|
||||
data, err := loadTestData("livedata_producing.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test data: %v", err)
|
||||
}
|
||||
|
||||
var liveData LiveData
|
||||
err = json.Unmarshal(data, &liveData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal LiveData: %v", err)
|
||||
}
|
||||
|
||||
// Validate top-level structure
|
||||
if len(liveData.Inverters) == 0 {
|
||||
t.Fatal("Expected at least one inverter")
|
||||
}
|
||||
|
||||
// Test first inverter completely
|
||||
inv := liveData.Inverters[0]
|
||||
|
||||
// Validate all root-level inverter fields (from generateInverterCommonJsonResponse)
|
||||
t.Run("InverterCommonFields", func(t *testing.T) {
|
||||
if inv.Serial == "" {
|
||||
t.Error("serial field missing or empty")
|
||||
}
|
||||
if inv.Name == "" {
|
||||
t.Error("name field missing or empty")
|
||||
}
|
||||
// order field (int, can be 0)
|
||||
if inv.Order < 0 {
|
||||
t.Error("order field has invalid value")
|
||||
}
|
||||
// data_age field (int, can be 0)
|
||||
if inv.DataAge < 0 {
|
||||
t.Error("data_age field has invalid value")
|
||||
}
|
||||
// data_age_ms field (int, can be 0)
|
||||
if inv.DataAgeMs < 0 {
|
||||
t.Error("data_age_ms field has invalid value")
|
||||
}
|
||||
// poll_enabled field (bool)
|
||||
_ = inv.PollEnabled
|
||||
// reachable field (bool)
|
||||
_ = inv.Reachable
|
||||
// producing field (bool)
|
||||
_ = inv.Producing
|
||||
// limit_relative field (float64)
|
||||
if inv.LimitRelative < 0 {
|
||||
t.Error("limit_relative field has invalid value")
|
||||
}
|
||||
// limit_absolute field (float64, can be -1)
|
||||
_ = inv.LimitAbsolute
|
||||
})
|
||||
|
||||
// Validate radio_stats structure (from generateInverterCommonJsonResponse lines 157-163)
|
||||
t.Run("RadioStats", func(t *testing.T) {
|
||||
rs := inv.RadioStats
|
||||
// All fields from OpenDTU RadioStats
|
||||
if rs.TxRequest < 0 {
|
||||
t.Error("radio_stats.tx_request has invalid value")
|
||||
}
|
||||
if rs.TxReRequest < 0 {
|
||||
t.Error("radio_stats.tx_re_request has invalid value")
|
||||
}
|
||||
if rs.RxSuccess < 0 {
|
||||
t.Error("radio_stats.rx_success has invalid value")
|
||||
}
|
||||
if rs.RxFailNothing < 0 {
|
||||
t.Error("radio_stats.rx_fail_nothing has invalid value")
|
||||
}
|
||||
if rs.RxFailPartial < 0 {
|
||||
t.Error("radio_stats.rx_fail_partial has invalid value")
|
||||
}
|
||||
if rs.RxFailCorrupt < 0 {
|
||||
t.Error("radio_stats.rx_fail_corrupt has invalid value")
|
||||
}
|
||||
// rssi can be negative (signal strength)
|
||||
_ = rs.RSSI
|
||||
})
|
||||
|
||||
// Validate AC channel structure (string-keyed map from WebApi_ws_live.cpp line 224)
|
||||
t.Run("AC_Structure", func(t *testing.T) {
|
||||
if len(inv.AC) == 0 {
|
||||
t.Fatal("AC map is empty")
|
||||
}
|
||||
|
||||
// OpenDTU uses string keys: "0", "1", etc.
|
||||
ac0, exists := inv.AC["0"]
|
||||
if !exists {
|
||||
t.Fatal("AC[\"0\"] does not exist - OpenDTU uses string-keyed maps")
|
||||
}
|
||||
|
||||
// Validate AC fields (from addField calls in WebApi_ws_live.cpp)
|
||||
// Lines 184-188: FLD_PAC, FLD_UAC, FLD_IAC, FLD_F, FLD_PF, FLD_Q
|
||||
validateVUD(t, "AC.Power", ac0.Power)
|
||||
validateVUD(t, "AC.Voltage", ac0.Voltage)
|
||||
validateVUD(t, "AC.Current", ac0.Current)
|
||||
validateVUD(t, "AC.Frequency", ac0.Frequency)
|
||||
validateVUD(t, "AC.PowerFactor", ac0.PowerFactor)
|
||||
validateVUD(t, "AC.ReactivePower", ac0.ReactivePower)
|
||||
})
|
||||
|
||||
// Validate DC channel structure (string-keyed map with name field)
|
||||
t.Run("DC_Structure", func(t *testing.T) {
|
||||
if len(inv.DC) == 0 {
|
||||
t.Fatal("DC map is empty")
|
||||
}
|
||||
|
||||
// OpenDTU uses string keys: "0", "1", etc.
|
||||
dc0, exists := inv.DC["0"]
|
||||
if !exists {
|
||||
t.Fatal("DC[\"0\"] does not exist - OpenDTU uses string-keyed maps")
|
||||
}
|
||||
|
||||
// Validate DC name field (from WebApi_ws_live.cpp line 182)
|
||||
if dc0.Name.U == "" {
|
||||
t.Error("DC channel name is empty")
|
||||
}
|
||||
|
||||
// Validate DC fields (from addField calls)
|
||||
// Lines 184-200: FLD_PDC, FLD_UDC, FLD_IDC, FLD_YD, FLD_YT, FLD_IRR
|
||||
validateVUD(t, "DC.Power", dc0.Power)
|
||||
validateVUD(t, "DC.Voltage", dc0.Voltage)
|
||||
validateVUD(t, "DC.Current", dc0.Current)
|
||||
validateVUD(t, "DC.YieldDay", dc0.YieldDay)
|
||||
validateVUD(t, "DC.YieldTotal", dc0.YieldTotal)
|
||||
|
||||
// Irradiation has special max field (line 201-203)
|
||||
if dc0.Irradiation.V < 0 || dc0.Irradiation.V > 100 {
|
||||
t.Errorf("DC.Irradiation.V out of range: %v", dc0.Irradiation.V)
|
||||
}
|
||||
if dc0.Irradiation.Max <= 0 {
|
||||
t.Error("DC.Irradiation.max field missing or invalid")
|
||||
}
|
||||
})
|
||||
|
||||
// Validate INV channel structure
|
||||
t.Run("INV_Structure", func(t *testing.T) {
|
||||
if len(inv.INV) == 0 {
|
||||
t.Fatal("INV map is empty")
|
||||
}
|
||||
|
||||
// OpenDTU uses string keys: "0"
|
||||
inv0, exists := inv.INV["0"]
|
||||
if !exists {
|
||||
t.Fatal("INV[\"0\"] does not exist - OpenDTU uses string-keyed maps")
|
||||
}
|
||||
|
||||
// Validate INV fields (from addField calls)
|
||||
// Lines 184-200: FLD_T, FLD_EFF, FLD_PDC (as "Power DC"), FLD_YD, FLD_YT
|
||||
validateVUD(t, "INV.Temperature", inv0.Temperature)
|
||||
validateVUD(t, "INV.Efficiency", inv0.Efficiency)
|
||||
validateVUD(t, "INV.PowerDC", inv0.PowerDC)
|
||||
validateVUD(t, "INV.YieldDay", inv0.YieldDay)
|
||||
validateVUD(t, "INV.YieldTotal", inv0.YieldTotal)
|
||||
})
|
||||
|
||||
// Validate events field (from WebApi_ws_live.cpp lines 206-210)
|
||||
t.Run("Events", func(t *testing.T) {
|
||||
// events can be -1 if not available, or >= 0
|
||||
if inv.Events < -1 {
|
||||
t.Errorf("events field has invalid value: %d", inv.Events)
|
||||
}
|
||||
})
|
||||
|
||||
// Validate total structure (from generateCommonJsonResponse lines 128-130)
|
||||
t.Run("Total", func(t *testing.T) {
|
||||
validateVUD(t, "Total.Power", liveData.Total.Power)
|
||||
validateVUD(t, "Total.YieldDay", liveData.Total.YieldDay)
|
||||
validateVUD(t, "Total.YieldTotal", liveData.Total.YieldTotal)
|
||||
})
|
||||
|
||||
// Validate hints structure (from generateCommonJsonResponse lines 132-138)
|
||||
t.Run("Hints", func(t *testing.T) {
|
||||
// All fields are boolean
|
||||
_ = liveData.Hints.TimeSync
|
||||
_ = liveData.Hints.RadioProblem
|
||||
_ = liveData.Hints.DefaultPassword
|
||||
_ = liveData.Hints.PinMappingIssue
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenDTUCompatibility_StringKeyedMaps verifies that AC/DC/INV are string-keyed maps
|
||||
// This is critical because OpenDTU C++ code uses String(channel) conversion (line 224)
|
||||
func TestOpenDTUCompatibility_StringKeyedMaps(t *testing.T) {
|
||||
data, err := loadTestData("livedata_producing.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test data: %v", err)
|
||||
}
|
||||
|
||||
var liveData LiveData
|
||||
err = json.Unmarshal(data, &liveData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal LiveData: %v", err)
|
||||
}
|
||||
|
||||
inv := liveData.Inverters[0]
|
||||
|
||||
// Test that maps use string keys "0", "1", NOT integer indices
|
||||
t.Run("AC_StringKeys", func(t *testing.T) {
|
||||
if _, exists := inv.AC["0"]; !exists {
|
||||
t.Error("AC must use string key \"0\" not integer 0")
|
||||
}
|
||||
// Should NOT be accessible as integer
|
||||
if len(inv.AC) > 0 {
|
||||
// This is correct - we're using map[string]
|
||||
t.Log("AC correctly uses map[string]InverterAC")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DC_StringKeys", func(t *testing.T) {
|
||||
if _, exists := inv.DC["0"]; !exists {
|
||||
t.Error("DC must use string key \"0\" not integer 0")
|
||||
}
|
||||
if _, exists := inv.DC["1"]; len(inv.DC) > 1 && !exists {
|
||||
t.Error("DC must use string key \"1\" not integer 1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("INV_StringKeys", func(t *testing.T) {
|
||||
if _, exists := inv.INV["0"]; !exists {
|
||||
t.Error("INV must use string key \"0\" not integer 0")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenDTUCompatibility_MultipleInverters validates proper handling of inverter arrays
|
||||
// OpenDTU creates an array of inverter objects (WebApi_ws_live.cpp line 95)
|
||||
func TestOpenDTUCompatibility_MultipleInverters(t *testing.T) {
|
||||
data, err := loadTestData("livedata_producing.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test data: %v", err)
|
||||
}
|
||||
|
||||
var liveData LiveData
|
||||
err = json.Unmarshal(data, &liveData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal LiveData: %v", err)
|
||||
}
|
||||
|
||||
if len(liveData.Inverters) < 2 {
|
||||
t.Skip("Test requires at least 2 inverters")
|
||||
}
|
||||
|
||||
// Verify each inverter has unique serial and proper order
|
||||
t.Run("UniqueSerials", func(t *testing.T) {
|
||||
serials := make(map[string]bool)
|
||||
for _, inv := range liveData.Inverters {
|
||||
if serials[inv.Serial] {
|
||||
t.Errorf("Duplicate serial found: %s", inv.Serial)
|
||||
}
|
||||
serials[inv.Serial] = true
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ProperOrdering", func(t *testing.T) {
|
||||
for i, inv := range liveData.Inverters {
|
||||
if inv.Order != i {
|
||||
t.Logf("Warning: inverter at index %d has order %d (may be intentional)", i, inv.Order)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenDTUCompatibility_NightMode validates zero-production scenario
|
||||
// Tests that the structure is valid even when inverters are not producing
|
||||
func TestOpenDTUCompatibility_NightMode(t *testing.T) {
|
||||
data, err := loadTestData("livedata_night.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load night test data: %v", err)
|
||||
}
|
||||
|
||||
var liveData LiveData
|
||||
err = json.Unmarshal(data, &liveData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal night LiveData: %v", err)
|
||||
}
|
||||
|
||||
if len(liveData.Inverters) == 0 {
|
||||
t.Fatal("Expected at least one inverter in night data")
|
||||
}
|
||||
|
||||
inv := liveData.Inverters[0]
|
||||
|
||||
// During night, inverters should not be producing
|
||||
if inv.Producing {
|
||||
t.Error("Inverter should not be producing at night")
|
||||
}
|
||||
|
||||
// Data age should be higher (older data)
|
||||
if inv.DataAge == 0 {
|
||||
t.Error("Expected non-zero data_age at night (stale data)")
|
||||
}
|
||||
|
||||
// Total power should be zero or near-zero
|
||||
if liveData.Total.Power.V > 1.0 {
|
||||
t.Errorf("Expected near-zero total power at night, got %v W", liveData.Total.Power.V)
|
||||
}
|
||||
|
||||
// Structure should still be valid
|
||||
if len(inv.AC) == 0 {
|
||||
t.Error("AC structure should exist even at night")
|
||||
}
|
||||
if len(inv.DC) == 0 {
|
||||
t.Error("DC structure should exist even at night")
|
||||
}
|
||||
if len(inv.INV) == 0 {
|
||||
t.Error("INV structure should exist even at night")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenDTUCompatibility_FieldNames validates exact field naming from OpenDTU
|
||||
// Field names come from getChannelFieldName in OpenDTU C++ code
|
||||
func TestOpenDTUCompatibility_FieldNames(t *testing.T) {
|
||||
data, err := loadTestData("livedata_producing.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test data: %v", err)
|
||||
}
|
||||
|
||||
// Parse as generic JSON to inspect field names
|
||||
var jsonData map[string]interface{}
|
||||
err = json.Unmarshal(data, &jsonData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal as generic JSON: %v", err)
|
||||
}
|
||||
|
||||
inverters := jsonData["inverters"].([]interface{})
|
||||
inv := inverters[0].(map[string]interface{})
|
||||
|
||||
// Check root inverter field names (exact names from C++ code)
|
||||
expectedRootFields := []string{
|
||||
"serial", "name", "order", "data_age", "data_age_ms",
|
||||
"poll_enabled", "reachable", "producing",
|
||||
"limit_relative", "limit_absolute", "events",
|
||||
"AC", "DC", "INV", "radio_stats",
|
||||
}
|
||||
|
||||
for _, field := range expectedRootFields {
|
||||
if _, exists := inv[field]; !exists {
|
||||
t.Errorf("Missing expected root field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Check radio_stats field names
|
||||
radioStats := inv["radio_stats"].(map[string]interface{})
|
||||
expectedRadioFields := []string{
|
||||
"tx_request", "tx_re_request", "rx_success",
|
||||
"rx_fail_nothing", "rx_fail_partial", "rx_fail_corrupt", "rssi",
|
||||
}
|
||||
|
||||
for _, field := range expectedRadioFields {
|
||||
if _, exists := radioStats[field]; !exists {
|
||||
t.Errorf("Missing expected radio_stats field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Check hints field names
|
||||
hints := jsonData["hints"].(map[string]interface{})
|
||||
expectedHintFields := []string{
|
||||
"time_sync", "radio_problem", "default_password", "pin_mapping_issue",
|
||||
}
|
||||
|
||||
for _, field := range expectedHintFields {
|
||||
if _, exists := hints[field]; !exists {
|
||||
t.Errorf("Missing expected hints field: %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenDTUCompatibility_WarningFixture validates branches only hit when OpenDTU
|
||||
// cannot determine inverter max power, event log, or when hints flag issues.
|
||||
func TestOpenDTUCompatibility_WarningFixture(t *testing.T) {
|
||||
data, err := loadTestData("livedata_warnings.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load warning test data: %v", err)
|
||||
}
|
||||
|
||||
var liveData LiveData
|
||||
if err := json.Unmarshal(data, &liveData); err != nil {
|
||||
t.Fatalf("Failed to unmarshal warning fixture: %v", err)
|
||||
}
|
||||
|
||||
if len(liveData.Inverters) != 1 {
|
||||
t.Fatalf("Expected 1 inverter in warning fixture, got %d", len(liveData.Inverters))
|
||||
}
|
||||
|
||||
inv := liveData.Inverters[0]
|
||||
if inv.LimitAbsolute != -1 {
|
||||
t.Errorf("Expected LimitAbsolute fallback -1, got %v", inv.LimitAbsolute)
|
||||
}
|
||||
if inv.Events != -1 {
|
||||
t.Errorf("Expected Events=-1 when event log channel missing, got %v", inv.Events)
|
||||
}
|
||||
if !inv.PollEnabled {
|
||||
t.Errorf("Expected PollEnabled=true for warning fixture")
|
||||
}
|
||||
if inv.Reachable {
|
||||
t.Errorf("Expected Reachable=false for warning fixture")
|
||||
}
|
||||
|
||||
if liveData.Hints.TimeSync {
|
||||
t.Errorf("Expected TimeSync=false, got %v", liveData.Hints.TimeSync)
|
||||
}
|
||||
if !liveData.Hints.RadioProblem {
|
||||
t.Errorf("Expected RadioProblem=true, got %v", liveData.Hints.RadioProblem)
|
||||
}
|
||||
if !liveData.Hints.DefaultPassword {
|
||||
t.Errorf("Expected DefaultPassword=true, got %v", liveData.Hints.DefaultPassword)
|
||||
}
|
||||
if !liveData.Hints.PinMappingIssue {
|
||||
t.Errorf("Expected PinMappingIssue=true, got %v", liveData.Hints.PinMappingIssue)
|
||||
}
|
||||
|
||||
var generic map[string]interface{}
|
||||
if err := json.Unmarshal(data, &generic); err != nil {
|
||||
t.Fatalf("Failed to unmarshal warning fixture generically: %v", err)
|
||||
}
|
||||
invertersRaw, ok := generic["inverters"].([]interface{})
|
||||
if !ok || len(invertersRaw) == 0 {
|
||||
t.Fatalf("Generic inverter array missing")
|
||||
}
|
||||
invMap, ok := invertersRaw[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Generic inverter map missing")
|
||||
}
|
||||
if _, exists := invMap["BAT"]; !exists {
|
||||
t.Errorf("Expected additional channel map 'BAT' to be present")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to validate VUD structure
|
||||
func validateVUD(t *testing.T, fieldName string, vud VUD) {
|
||||
t.Helper()
|
||||
// Value can be any number (including 0 or negative)
|
||||
_ = vud.V
|
||||
// Unit can be empty string for dimensionless values
|
||||
_ = vud.U
|
||||
// Decimals should be non-negative
|
||||
if vud.D < 0 {
|
||||
t.Errorf("%s: decimals (d) should be non-negative, got %d", fieldName, vud.D)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenDTUCompatibility_EventsValue tests the events field edge cases
|
||||
// From WebApi_ws_live.cpp lines 206-210:
|
||||
// - Returns event count if available
|
||||
// - Returns -1 if not available
|
||||
func TestOpenDTUCompatibility_EventsValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
eventsValue int
|
||||
expectedValid bool
|
||||
}{
|
||||
{"No events", 0, true},
|
||||
{"Some events", 5, true},
|
||||
{"Many events", 100, true},
|
||||
{"Not available", -1, true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Parse test data and modify events value
|
||||
data, err := loadTestData("livedata_producing.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test data: %v", err)
|
||||
}
|
||||
|
||||
// Parse, modify, and re-encode
|
||||
var liveData LiveData
|
||||
err = json.Unmarshal(data, &liveData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
liveData.Inverters[0].Events = tc.eventsValue
|
||||
|
||||
// Re-marshal and unmarshal to test the value
|
||||
modifiedData, _ := json.Marshal(liveData)
|
||||
var testData LiveData
|
||||
err = json.Unmarshal(modifiedData, &testData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal modified data: %v", err)
|
||||
}
|
||||
|
||||
if testData.Inverters[0].Events != tc.eventsValue {
|
||||
t.Errorf("Expected events=%d, got %d", tc.eventsValue, testData.Inverters[0].Events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue