feat: implement balance control (GET/POST /balance)

- Add complete balance control functionality via GET/POST /balance endpoints
- Implement GetBalance() for current stereo balance retrieval
- Add SetBalance() with range validation (-50 to +50)
- Include IncreaseBalance() and DecreaseBalance() with safety limits
- Add SetBalanceSafe() with automatic value clamping
- Create comprehensive balance models with validation and helpers
- Add CLI flags: -balance, -set-balance, -inc-balance, -dec-balance
- Implement left/right percentage calculation and human-readable descriptions
- Create comprehensive test suite (30+ test cases) with mock servers
- Add error handling for devices that don't support balance control
- Update documentation with complete balance control reference
- Update API endpoints status (GET/POST /balance:  Implemented)
- Update project status (70% overall completion, 100% control endpoints)
- Complete audio management trilogy: Volume + Bass + Balance
- Real device testing shows device-dependent feature availability
- XML request/response format validation and compliance
- Human-readable balance descriptions (Far Left, Center, Right, etc.)
- Left/Right channel percentage display for better UX
This commit is contained in:
Tobias Gesellchen
2026-01-09 09:37:57 +01:00
parent 16b3be1950
commit b7b856b98f
7 changed files with 1621 additions and 12 deletions
+597
View File
@@ -0,0 +1,597 @@
package client
import (
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/user_account/bose-soundtouch/pkg/models"
)
func TestClient_GetBalance(t *testing.T) {
tests := []struct {
name string
serverResponse string
wantError bool
wantTargetBalance int
wantActualBalance int
wantDeviceID string
}{
{
name: "Valid balance response",
serverResponse: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>15</targetbalance>
<actualbalance>15</actualbalance>
</balance>`,
wantError: false,
wantTargetBalance: 15,
wantActualBalance: 15,
wantDeviceID: "1234567890AB",
},
{
name: "Negative balance response",
serverResponse: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>-25</targetbalance>
<actualbalance>-25</actualbalance>
</balance>`,
wantError: false,
wantTargetBalance: -25,
wantActualBalance: -25,
wantDeviceID: "1234567890AB",
},
{
name: "Zero balance response",
serverResponse: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>0</targetbalance>
<actualbalance>0</actualbalance>
</balance>`,
wantError: false,
wantTargetBalance: 0,
wantActualBalance: 0,
wantDeviceID: "1234567890AB",
},
{
name: "Balance adjustment in progress",
serverResponse: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>30</targetbalance>
<actualbalance>20</actualbalance>
</balance>`,
wantError: false,
wantTargetBalance: 30,
wantActualBalance: 20,
wantDeviceID: "1234567890AB",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("Expected GET request, got %s", r.Method)
return
}
if r.URL.Path != "/balance" {
t.Errorf("Expected path /balance, got %s", r.URL.Path)
return
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.serverResponse))
}))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:], // Remove "http://"
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
balance, err := client.GetBalance()
if tt.wantError {
if err == nil {
t.Errorf("Expected error, got nil")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if balance.TargetBalance != tt.wantTargetBalance {
t.Errorf("Expected target balance %d, got %d", tt.wantTargetBalance, balance.TargetBalance)
}
if balance.ActualBalance != tt.wantActualBalance {
t.Errorf("Expected actual balance %d, got %d", tt.wantActualBalance, balance.ActualBalance)
}
if balance.DeviceID != tt.wantDeviceID {
t.Errorf("Expected device ID %s, got %s", tt.wantDeviceID, balance.DeviceID)
}
}
})
}
}
func TestClient_SetBalance(t *testing.T) {
tests := []struct {
name string
level int
wantError bool
}{
{
name: "Valid balance level 0",
level: 0,
wantError: false,
},
{
name: "Valid balance level +50",
level: 50,
wantError: false,
},
{
name: "Valid balance level -50",
level: -50,
wantError: false,
},
{
name: "Valid balance level +25",
level: 25,
wantError: false,
},
{
name: "Valid balance level -25",
level: -25,
wantError: false,
},
{
name: "Invalid balance level +51",
level: 51,
wantError: true,
},
{
name: "Invalid balance level -51",
level: -51,
wantError: true,
},
{
name: "Invalid balance level +100",
level: 100,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !tt.wantError {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
return
}
if r.URL.Path != "/balance" {
t.Errorf("Expected path /balance, got %s", r.URL.Path)
return
}
// Verify Content-Type
if contentType := r.Header.Get("Content-Type"); contentType != "application/xml" {
t.Errorf("Expected Content-Type application/xml, got %s", contentType)
return
}
// Parse and validate request body
var balanceReq models.BalanceRequest
err := xml.NewDecoder(r.Body).Decode(&balanceReq)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
if balanceReq.Level != tt.level {
t.Errorf("Expected balance level %d, got %d", tt.level, balanceReq.Level)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
err := client.SetBalance(tt.level)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
} else {
// Test validation without server
config := ClientConfig{
Host: "localhost",
Port: 8090,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
err := client.SetBalance(tt.level)
if err == nil {
t.Errorf("Expected error for invalid balance level %d, got nil", tt.level)
}
}
})
}
}
func TestClient_SetBalanceSafe(t *testing.T) {
tests := []struct {
name string
level int
expectedLevel int
}{
{
name: "Valid level unchanged",
level: 25,
expectedLevel: 25,
},
{
name: "Too high clamped",
level: 75,
expectedLevel: 50,
},
{
name: "Too low clamped",
level: -75,
expectedLevel: -50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse request body to verify clamped level
var balanceReq models.BalanceRequest
err := xml.NewDecoder(r.Body).Decode(&balanceReq)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
if balanceReq.Level != tt.expectedLevel {
t.Errorf("Expected clamped balance level %d, got %d", tt.expectedLevel, balanceReq.Level)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
err := client.SetBalanceSafe(tt.level)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
func TestClient_IncreaseBalance(t *testing.T) {
tests := []struct {
name string
currentBalance int
amount int
expectedNewBalance int
}{
{
name: "Normal increase",
currentBalance: 0,
amount: 15,
expectedNewBalance: 15,
},
{
name: "Increase with clamping",
currentBalance: 40,
amount: 15,
expectedNewBalance: 50,
},
{
name: "Increase from negative",
currentBalance: -15,
amount: 10,
expectedNewBalance: -5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getCallCount := 0
postCallCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.Method == "GET" && r.URL.Path == "/balance" {
getCallCount++
var response string
if getCallCount == 1 {
// First call - return current balance
response = `<balance deviceID="1234567890AB"><targetbalance>` +
fmt.Sprintf("%d", tt.currentBalance) + `</targetbalance><actualbalance>` +
fmt.Sprintf("%d", tt.currentBalance) + `</actualbalance></balance>`
} else {
// Second call - return new balance level
response = `<balance deviceID="1234567890AB"><targetbalance>` +
fmt.Sprintf("%d", tt.expectedNewBalance) + `</targetbalance><actualbalance>` +
fmt.Sprintf("%d", tt.expectedNewBalance) + `</actualbalance></balance>`
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
} else if r.Method == "POST" && r.URL.Path == "/balance" {
postCallCount++
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
balance, err := client.IncreaseBalance(tt.amount)
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if balance.GetLevel() != tt.expectedNewBalance {
t.Errorf("Expected new balance level %d, got %d", tt.expectedNewBalance, balance.GetLevel())
}
if getCallCount != 2 {
t.Errorf("Expected 2 GET calls, got %d", getCallCount)
}
if postCallCount != 1 {
t.Errorf("Expected 1 POST call, got %d", postCallCount)
}
})
}
}
func TestClient_DecreaseBalance(t *testing.T) {
tests := []struct {
name string
currentBalance int
amount int
expectedNewBalance int
}{
{
name: "Normal decrease",
currentBalance: 15,
amount: 10,
expectedNewBalance: 5,
},
{
name: "Decrease with clamping",
currentBalance: -35,
amount: 20,
expectedNewBalance: -50,
},
{
name: "Decrease to negative",
currentBalance: 10,
amount: 20,
expectedNewBalance: -10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getCallCount := 0
postCallCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.Method == "GET" && r.URL.Path == "/balance" {
getCallCount++
var response string
if getCallCount == 1 {
// First call - return current balance
response = `<balance deviceID="1234567890AB"><targetbalance>` +
fmt.Sprintf("%d", tt.currentBalance) + `</targetbalance><actualbalance>` +
fmt.Sprintf("%d", tt.currentBalance) + `</actualbalance></balance>`
} else {
// Second call - return new balance level
response = `<balance deviceID="1234567890AB"><targetbalance>` +
fmt.Sprintf("%d", tt.expectedNewBalance) + `</targetbalance><actualbalance>` +
fmt.Sprintf("%d", tt.expectedNewBalance) + `</actualbalance></balance>`
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
} else if r.Method == "POST" && r.URL.Path == "/balance" {
postCallCount++
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
balance, err := client.DecreaseBalance(tt.amount)
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if balance.GetLevel() != tt.expectedNewBalance {
t.Errorf("Expected new balance level %d, got %d", tt.expectedNewBalance, balance.GetLevel())
}
if getCallCount != 2 {
t.Errorf("Expected 2 GET calls, got %d", getCallCount)
}
if postCallCount != 1 {
t.Errorf("Expected 1 POST call, got %d", postCallCount)
}
})
}
}
func TestClient_Balance_ErrorHandling(t *testing.T) {
tests := []struct {
name string
serverResponse func(w http.ResponseWriter, r *http.Request)
method func(*Client) error
wantError bool
errorContains string
}{
{
name: "GetBalance server returns 404",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not Found"))
},
method: func(c *Client) error {
_, err := c.GetBalance()
return err
},
wantError: true,
errorContains: "failed to get balance",
},
{
name: "SetBalance server returns 500",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
},
method: func(c *Client) error {
return c.SetBalance(15)
},
wantError: true,
errorContains: "API request failed with status 500",
},
{
name: "GetBalance invalid XML response",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid xml"))
},
method: func(c *Client) error {
_, err := c.GetBalance()
return err
},
wantError: true,
errorContains: "failed to get balance",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
err := tt.method(client)
if tt.wantError {
if err == nil {
t.Errorf("Expected error, got nil")
} else if !containsSubstring(err.Error(), tt.errorContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestClient_Balance_RequestFormat(t *testing.T) {
// Test that the request XML format is correct
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read and parse the raw request body
var balanceReq models.BalanceRequest
err := xml.NewDecoder(r.Body).Decode(&balanceReq)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
// Validate XML structure
expectedLevel := 25
if balanceReq.Level != expectedLevel {
t.Errorf("Expected balance level %d, got %d", expectedLevel, balanceReq.Level)
}
// Re-encode to verify XML format
actualXML, err := xml.Marshal(balanceReq)
if err != nil {
t.Errorf("Failed to marshal BalanceRequest: %v", err)
return
}
expectedXML := "<balance>25</balance>"
if string(actualXML) != expectedXML {
t.Errorf("Expected XML '%s', got '%s'", expectedXML, string(actualXML))
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
err := client.SetBalance(25)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
+64
View File
@@ -360,6 +360,70 @@ func (c *Client) DecreaseBass(amount int) (*models.Bass, error) {
return c.GetBass()
}
// GetBalance retrieves the current balance level from the /balance endpoint
func (c *Client) GetBalance() (*models.Balance, error) {
var balance models.Balance
err := c.get("/balance", &balance)
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
return &balance, nil
}
// SetBalance sets the balance level using the /balance endpoint
func (c *Client) SetBalance(level int) error {
if !models.ValidateBalanceLevel(level) {
return fmt.Errorf("invalid balance level: %d (must be between %d and %d)", level, models.BalanceLevelMin, models.BalanceLevelMax)
}
balanceReq, err := models.NewBalanceRequest(level)
if err != nil {
return fmt.Errorf("failed to create balance request: %w", err)
}
return c.post("/balance", balanceReq, nil)
}
// SetBalanceSafe sets balance with validation and clamping
func (c *Client) SetBalanceSafe(level int) error {
clampedLevel := models.ClampBalanceLevel(level)
return c.SetBalance(clampedLevel)
}
// IncreaseBalance increases balance by the specified amount (with safety limits)
func (c *Client) IncreaseBalance(amount int) (*models.Balance, error) {
currentBalance, err := c.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get current balance: %w", err)
}
newLevel := models.ClampBalanceLevel(currentBalance.GetLevel() + amount)
err = c.SetBalance(newLevel)
if err != nil {
return nil, fmt.Errorf("failed to set balance: %w", err)
}
// Return updated balance
return c.GetBalance()
}
// DecreaseBalance decreases balance by the specified amount (with safety limits)
func (c *Client) DecreaseBalance(amount int) (*models.Balance, error) {
currentBalance, err := c.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get current balance: %w", err)
}
newLevel := models.ClampBalanceLevel(currentBalance.GetLevel() - amount)
err = c.SetBalance(newLevel)
if err != nil {
return nil, fmt.Errorf("failed to set balance: %w", err)
}
// Return updated balance
return c.GetBalance()
}
// SelectSource selects an audio source using the /select endpoint
func (c *Client) SelectSource(source string, sourceAccount string) error {
// Validate source parameter
+170
View File
@@ -0,0 +1,170 @@
package models
import (
"encoding/xml"
"fmt"
)
// Balance represents the response from /balance endpoint
type Balance struct {
XMLName xml.Name `xml:"balance"`
DeviceID string `xml:"deviceID,attr"`
TargetBalance int `xml:"targetbalance"`
ActualBalance int `xml:"actualbalance"`
}
// BalanceRequest represents the request for POST /balance endpoint
type BalanceRequest struct {
XMLName xml.Name `xml:"balance"`
Level int `xml:",chardata"`
}
// Balance level constants
const (
BalanceLevelMin = -50
BalanceLevelMax = 50
BalanceLevelDefault = 0
)
// NewBalanceRequest creates a new balance request with validation
func NewBalanceRequest(level int) (*BalanceRequest, error) {
if !ValidateBalanceLevel(level) {
return nil, fmt.Errorf("invalid balance level: %d (must be between %d and %d)", level, BalanceLevelMin, BalanceLevelMax)
}
return &BalanceRequest{
Level: level,
}, nil
}
// ValidateBalanceLevel validates that a balance level is within the allowed range
func ValidateBalanceLevel(level int) bool {
return level >= BalanceLevelMin && level <= BalanceLevelMax
}
// ClampBalanceLevel clamps a balance level to the valid range
func ClampBalanceLevel(level int) int {
if level < BalanceLevelMin {
return BalanceLevelMin
}
if level > BalanceLevelMax {
return BalanceLevelMax
}
return level
}
// GetLevel returns the target balance level
func (b *Balance) GetLevel() int {
return b.TargetBalance
}
// GetActualLevel returns the actual balance level
func (b *Balance) GetActualLevel() int {
return b.ActualBalance
}
// IsAtTarget returns true if actual balance matches target balance
func (b *Balance) IsAtTarget() bool {
return b.TargetBalance == b.ActualBalance
}
// GetBalanceLevelName returns a descriptive name for the balance level
func GetBalanceLevelName(level int) string {
switch {
case level < -30:
return "Far Left"
case level < -10:
return "Left"
case level < 0:
return "Slightly Left"
case level == 0:
return "Center"
case level <= 10:
return "Slightly Right"
case level <= 30:
return "Right"
default:
return "Far Right"
}
}
// GetBalanceLevelCategory returns the balance category
func GetBalanceLevelCategory(level int) string {
switch {
case level < 0:
return "Left Channel"
case level == 0:
return "Balanced"
default:
return "Right Channel"
}
}
// String returns a human-readable string representation
func (b *Balance) String() string {
return fmt.Sprintf("Balance: %d (%s)", b.GetLevel(), GetBalanceLevelName(b.GetLevel()))
}
// UnmarshalXML implements custom XML unmarshaling with validation
func (b *Balance) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
// Use a temporary struct to avoid infinite recursion
type TempBalance Balance
temp := (*TempBalance)(b)
if err := d.DecodeElement(temp, &start); err != nil {
return err
}
// Validate balance levels are within acceptable range
if !ValidateBalanceLevel(b.TargetBalance) {
return fmt.Errorf("invalid target balance level: %d", b.TargetBalance)
}
if !ValidateBalanceLevel(b.ActualBalance) {
return fmt.Errorf("invalid actual balance level: %d", b.ActualBalance)
}
return nil
}
// MarshalXML implements custom XML marshaling
func (b *Balance) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type TempBalance Balance
temp := (*TempBalance)(b)
return e.EncodeElement(temp, start)
}
// IsLeftBalance returns true if balance favors left channel (negative level)
func (b *Balance) IsLeftBalance() bool {
return b.GetLevel() < 0
}
// IsRightBalance returns true if balance favors right channel (positive level)
func (b *Balance) IsRightBalance() bool {
return b.GetLevel() > 0
}
// IsBalanced returns true if balance is centered (zero level)
func (b *Balance) IsBalanced() bool {
return b.GetLevel() == 0
}
// GetBalanceChangeNeeded returns the amount of change needed to reach target from actual
func (b *Balance) GetBalanceChangeNeeded() int {
return b.TargetBalance - b.ActualBalance
}
// GetLeftRightPercentage returns the balance as left/right percentages
func (b *Balance) GetLeftRightPercentage() (left, right int) {
level := b.GetLevel()
if level <= 0 {
// Left emphasis or center
left = 50 + (-level / 2)
right = 50 - (-level / 2)
} else {
// Right emphasis
left = 50 - (level / 2)
right = 50 + (level / 2)
}
return left, right
}
+649
View File
@@ -0,0 +1,649 @@
package models
import (
"encoding/xml"
"testing"
)
func TestNewBalanceRequest(t *testing.T) {
tests := []struct {
name string
level int
wantError bool
wantLevel int
}{
{
name: "Valid balance level 0",
level: 0,
wantError: false,
wantLevel: 0,
},
{
name: "Valid balance level +50",
level: 50,
wantError: false,
wantLevel: 50,
},
{
name: "Valid balance level -50",
level: -50,
wantError: false,
wantLevel: -50,
},
{
name: "Valid balance level +25",
level: 25,
wantError: false,
wantLevel: 25,
},
{
name: "Valid balance level -25",
level: -25,
wantError: false,
wantLevel: -25,
},
{
name: "Invalid balance level +51",
level: 51,
wantError: true,
},
{
name: "Invalid balance level -51",
level: -51,
wantError: true,
},
{
name: "Invalid balance level +100",
level: 100,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := NewBalanceRequest(tt.level)
if tt.wantError {
if err == nil {
t.Errorf("NewBalanceRequest() expected error, got nil")
}
} else {
if err != nil {
t.Errorf("NewBalanceRequest() unexpected error: %v", err)
}
if req.Level != tt.wantLevel {
t.Errorf("NewBalanceRequest() level = %d, want %d", req.Level, tt.wantLevel)
}
}
})
}
}
func TestValidateBalanceLevel(t *testing.T) {
tests := []struct {
name string
level int
want bool
}{
{
name: "Valid minimum level",
level: -50,
want: true,
},
{
name: "Valid maximum level",
level: 50,
want: true,
},
{
name: "Valid zero level",
level: 0,
want: true,
},
{
name: "Valid positive level",
level: 25,
want: true,
},
{
name: "Valid negative level",
level: -25,
want: true,
},
{
name: "Invalid too high",
level: 51,
want: false,
},
{
name: "Invalid too low",
level: -51,
want: false,
},
{
name: "Invalid way too high",
level: 100,
want: false,
},
{
name: "Invalid way too low",
level: -100,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateBalanceLevel(tt.level); got != tt.want {
t.Errorf("ValidateBalanceLevel() = %v, want %v", got, tt.want)
}
})
}
}
func TestClampBalanceLevel(t *testing.T) {
tests := []struct {
name string
level int
want int
}{
{
name: "Valid level unchanged",
level: 0,
want: 0,
},
{
name: "Valid positive level unchanged",
level: 25,
want: 25,
},
{
name: "Valid negative level unchanged",
level: -25,
want: -25,
},
{
name: "Maximum level unchanged",
level: 50,
want: 50,
},
{
name: "Minimum level unchanged",
level: -50,
want: -50,
},
{
name: "Too high clamped to max",
level: 51,
want: 50,
},
{
name: "Too low clamped to min",
level: -51,
want: -50,
},
{
name: "Way too high clamped to max",
level: 100,
want: 50,
},
{
name: "Way too low clamped to min",
level: -100,
want: -50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClampBalanceLevel(tt.level); got != tt.want {
t.Errorf("ClampBalanceLevel() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetBalanceLevelName(t *testing.T) {
tests := []struct {
name string
level int
want string
}{
{
name: "Far left balance",
level: -50,
want: "Far Left",
},
{
name: "Left balance",
level: -20,
want: "Left",
},
{
name: "Slightly left balance",
level: -5,
want: "Slightly Left",
},
{
name: "Center balance",
level: 0,
want: "Center",
},
{
name: "Slightly right balance",
level: 5,
want: "Slightly Right",
},
{
name: "Right balance",
level: 20,
want: "Right",
},
{
name: "Far right balance",
level: 50,
want: "Far Right",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetBalanceLevelName(tt.level); got != tt.want {
t.Errorf("GetBalanceLevelName() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetBalanceLevelCategory(t *testing.T) {
tests := []struct {
name string
level int
want string
}{
{
name: "Left channel negative",
level: -25,
want: "Left Channel",
},
{
name: "Left channel minimum",
level: -50,
want: "Left Channel",
},
{
name: "Balanced center",
level: 0,
want: "Balanced",
},
{
name: "Right channel positive",
level: 25,
want: "Right Channel",
},
{
name: "Right channel maximum",
level: 50,
want: "Right Channel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetBalanceLevelCategory(tt.level); got != tt.want {
t.Errorf("GetBalanceLevelCategory() = %v, want %v", got, tt.want)
}
})
}
}
func TestBalance_GetMethods(t *testing.T) {
balance := &Balance{
TargetBalance: 25,
ActualBalance: 20,
DeviceID: "1234567890AB",
}
if got := balance.GetLevel(); got != 25 {
t.Errorf("GetLevel() = %v, want %v", got, 25)
}
if got := balance.GetActualLevel(); got != 20 {
t.Errorf("GetActualLevel() = %v, want %v", got, 20)
}
if got := balance.IsAtTarget(); got != false {
t.Errorf("IsAtTarget() = %v, want %v", got, false)
}
if got := balance.GetBalanceChangeNeeded(); got != 5 {
t.Errorf("GetBalanceChangeNeeded() = %v, want %v", got, 5)
}
}
func TestBalance_BooleanMethods(t *testing.T) {
tests := []struct {
name string
balance *Balance
wantLeft bool
wantRight bool
wantBalanced bool
wantAtTarget bool
}{
{
name: "Right balance",
balance: &Balance{TargetBalance: 25, ActualBalance: 25},
wantLeft: false,
wantRight: true,
wantBalanced: false,
wantAtTarget: true,
},
{
name: "Left balance",
balance: &Balance{TargetBalance: -15, ActualBalance: -15},
wantLeft: true,
wantRight: false,
wantBalanced: false,
wantAtTarget: true,
},
{
name: "Center balance",
balance: &Balance{TargetBalance: 0, ActualBalance: 0},
wantLeft: false,
wantRight: false,
wantBalanced: true,
wantAtTarget: true,
},
{
name: "Not at target",
balance: &Balance{TargetBalance: 25, ActualBalance: 10},
wantLeft: false,
wantRight: true,
wantBalanced: false,
wantAtTarget: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.balance.IsLeftBalance(); got != tt.wantLeft {
t.Errorf("IsLeftBalance() = %v, want %v", got, tt.wantLeft)
}
if got := tt.balance.IsRightBalance(); got != tt.wantRight {
t.Errorf("IsRightBalance() = %v, want %v", got, tt.wantRight)
}
if got := tt.balance.IsBalanced(); got != tt.wantBalanced {
t.Errorf("IsBalanced() = %v, want %v", got, tt.wantBalanced)
}
if got := tt.balance.IsAtTarget(); got != tt.wantAtTarget {
t.Errorf("IsAtTarget() = %v, want %v", got, tt.wantAtTarget)
}
})
}
}
func TestBalance_GetLeftRightPercentage(t *testing.T) {
tests := []struct {
name string
balance *Balance
wantLeft int
wantRight int
}{
{
name: "Center balance",
balance: &Balance{TargetBalance: 0},
wantLeft: 50,
wantRight: 50,
},
{
name: "Right balance +20",
balance: &Balance{TargetBalance: 20},
wantLeft: 40,
wantRight: 60,
},
{
name: "Left balance -20",
balance: &Balance{TargetBalance: -20},
wantLeft: 60,
wantRight: 40,
},
{
name: "Far right +50",
balance: &Balance{TargetBalance: 50},
wantLeft: 25,
wantRight: 75,
},
{
name: "Far left -50",
balance: &Balance{TargetBalance: -50},
wantLeft: 75,
wantRight: 25,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
left, right := tt.balance.GetLeftRightPercentage()
if left != tt.wantLeft {
t.Errorf("GetLeftRightPercentage() left = %v, want %v", left, tt.wantLeft)
}
if right != tt.wantRight {
t.Errorf("GetLeftRightPercentage() right = %v, want %v", right, tt.wantRight)
}
})
}
}
func TestBalance_String(t *testing.T) {
balance := &Balance{
TargetBalance: 15,
ActualBalance: 15,
}
expected := "Balance: 15 (Right)"
if got := balance.String(); got != expected {
t.Errorf("String() = %v, want %v", got, expected)
}
}
func TestBalance_UnmarshalXML(t *testing.T) {
tests := []struct {
name string
xmlData string
wantError bool
want Balance
}{
{
name: "Valid balance XML",
xmlData: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>15</targetbalance>
<actualbalance>15</actualbalance>
</balance>`,
wantError: false,
want: Balance{
DeviceID: "1234567890AB",
TargetBalance: 15,
ActualBalance: 15,
},
},
{
name: "Valid negative balance XML",
xmlData: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>-25</targetbalance>
<actualbalance>-25</actualbalance>
</balance>`,
wantError: false,
want: Balance{
DeviceID: "1234567890AB",
TargetBalance: -25,
ActualBalance: -25,
},
},
{
name: "Valid zero balance XML",
xmlData: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>0</targetbalance>
<actualbalance>0</actualbalance>
</balance>`,
wantError: false,
want: Balance{
DeviceID: "1234567890AB",
TargetBalance: 0,
ActualBalance: 0,
},
},
{
name: "Invalid target balance too high",
xmlData: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>75</targetbalance>
<actualbalance>25</actualbalance>
</balance>`,
wantError: true,
},
{
name: "Invalid actual balance too low",
xmlData: `<?xml version="1.0" encoding="UTF-8" ?>
<balance deviceID="1234567890AB">
<targetbalance>25</targetbalance>
<actualbalance>-75</actualbalance>
</balance>`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var balance Balance
err := xml.Unmarshal([]byte(tt.xmlData), &balance)
if tt.wantError {
if err == nil {
t.Errorf("UnmarshalXML() expected error, got nil")
}
} else {
if err != nil {
t.Errorf("UnmarshalXML() unexpected error: %v", err)
}
if balance.DeviceID != tt.want.DeviceID {
t.Errorf("DeviceID = %v, want %v", balance.DeviceID, tt.want.DeviceID)
}
if balance.TargetBalance != tt.want.TargetBalance {
t.Errorf("TargetBalance = %v, want %v", balance.TargetBalance, tt.want.TargetBalance)
}
if balance.ActualBalance != tt.want.ActualBalance {
t.Errorf("ActualBalance = %v, want %v", balance.ActualBalance, tt.want.ActualBalance)
}
}
})
}
}
func TestBalance_MarshalXML(t *testing.T) {
tests := []struct {
name string
balance Balance
wantError bool
}{
{
name: "Valid balance marshal",
balance: Balance{
DeviceID: "1234567890AB",
TargetBalance: 15,
ActualBalance: 15,
},
wantError: false,
},
{
name: "Valid negative balance marshal",
balance: Balance{
DeviceID: "1234567890AB",
TargetBalance: -25,
ActualBalance: -25,
},
wantError: false,
},
{
name: "Valid zero balance marshal",
balance: Balance{
DeviceID: "1234567890AB",
TargetBalance: 0,
ActualBalance: 0,
},
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := xml.Marshal(tt.balance)
if tt.wantError {
if err == nil {
t.Errorf("MarshalXML() expected error, got nil")
}
} else {
if err != nil {
t.Errorf("MarshalXML() unexpected error: %v", err)
}
}
})
}
}
func TestBalanceRequest_MarshalXML(t *testing.T) {
req := &BalanceRequest{
Level: 25,
}
data, err := xml.Marshal(req)
if err != nil {
t.Errorf("MarshalXML() unexpected error: %v", err)
}
expected := "<balance>25</balance>"
if string(data) != expected {
t.Errorf("MarshalXML() = %v, want %v", string(data), expected)
}
}
func TestBalanceConstants(t *testing.T) {
if BalanceLevelMin != -50 {
t.Errorf("BalanceLevelMin = %v, want %v", BalanceLevelMin, -50)
}
if BalanceLevelMax != 50 {
t.Errorf("BalanceLevelMax = %v, want %v", BalanceLevelMax, 50)
}
if BalanceLevelDefault != 0 {
t.Errorf("BalanceLevelDefault = %v, want %v", BalanceLevelDefault, 0)
}
}
func TestBalanceLevelEdgeCases(t *testing.T) {
// Test boundary values
t.Run("Minimum boundary", func(t *testing.T) {
if !ValidateBalanceLevel(-50) {
t.Error("ValidateBalanceLevel(-50) should be true")
}
if ValidateBalanceLevel(-51) {
t.Error("ValidateBalanceLevel(-51) should be false")
}
})
t.Run("Maximum boundary", func(t *testing.T) {
if !ValidateBalanceLevel(50) {
t.Error("ValidateBalanceLevel(50) should be true")
}
if ValidateBalanceLevel(51) {
t.Error("ValidateBalanceLevel(51) should be false")
}
})
t.Run("Zero boundary", func(t *testing.T) {
if !ValidateBalanceLevel(0) {
t.Error("ValidateBalanceLevel(0) should be true")
}
})
}