add testing, reverse engineered / generated from OpenDTU source to improve compatibility testing and detecting regressions early.

This commit is contained in:
Pieter Hollander 2025-10-04 01:57:56 +02:00
parent 6b496a39ee
commit 99959726c8
Signed by: pieter
SSH key fingerprint: SHA256:HbX+9cBXsop9SuvL+mELd29sK+7DehFfdVweFVDtMSg
12 changed files with 2888 additions and 0 deletions

View 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)
}
})
}
}