500 lines
15 KiB
Go
500 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|