mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat: Add comprehensive zone management for multiroom control
- Implement GET /getZone and POST /setZone endpoints
- Add complete zone models with validation and error handling
- Create zone builder pattern for fluent API construction
- Add all zone operations: create, add, remove, dissolve
- Implement zone status queries and member management
- Add comprehensive CLI support for all zone commands
- Create 100+ tests covering all zone functionality
- Add WebSocket zone event handling and monitoring
- Include extensive documentation and examples
- Update project completion to 90% (18/20 endpoints)
BREAKING: None - all additions are backward compatible
NEW ENDPOINTS: /getZone (GET), /setZone (POST)
NEW CLI COMMANDS: -zone, -zone-status, -zone-members, -create-zone,
-add-to-zone, -remove-from-zone, -dissolve-zone
This commit is contained in:
@@ -724,3 +724,121 @@ func (c *Client) post(endpoint string, payload interface{}, result interface{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetZone gets the current multiroom zone configuration
|
||||
func (c *Client) GetZone() (*models.ZoneInfo, error) {
|
||||
var zone models.ZoneInfo
|
||||
err := c.get("/getZone", &zone)
|
||||
return &zone, err
|
||||
}
|
||||
|
||||
// SetZone configures multiroom zone settings
|
||||
func (c *Client) SetZone(zoneRequest *models.ZoneRequest) error {
|
||||
if err := zoneRequest.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid zone request: %w", err)
|
||||
}
|
||||
|
||||
return c.post("/setZone", zoneRequest, nil)
|
||||
}
|
||||
|
||||
// CreateZone creates a new multiroom zone with the specified master and members
|
||||
func (c *Client) CreateZone(masterDeviceID string, memberDeviceIDs []string) error {
|
||||
zoneRequest := models.NewZoneRequest(masterDeviceID)
|
||||
|
||||
for _, deviceID := range memberDeviceIDs {
|
||||
zoneRequest.AddMemberByDeviceID(deviceID)
|
||||
}
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// CreateZoneWithIPs creates a new multiroom zone with device IDs and IP addresses
|
||||
func (c *Client) CreateZoneWithIPs(masterDeviceID string, members map[string]string) error {
|
||||
zoneRequest := models.NewZoneRequest(masterDeviceID)
|
||||
|
||||
for deviceID, ipAddress := range members {
|
||||
zoneRequest.AddMember(deviceID, ipAddress)
|
||||
}
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// AddToZone adds a device to an existing zone
|
||||
func (c *Client) AddToZone(deviceID, ipAddress string) error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Convert to zone request and add member
|
||||
zoneRequest := currentZone.ToZoneRequest()
|
||||
zoneRequest.AddMember(deviceID, ipAddress)
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// RemoveFromZone removes a device from the current zone
|
||||
func (c *Client) RemoveFromZone(deviceID string) error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Convert to zone request and remove member
|
||||
zoneRequest := currentZone.ToZoneRequest()
|
||||
zoneRequest.RemoveMember(deviceID)
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// DissolveZone dissolves the current zone, making all devices standalone
|
||||
func (c *Client) DissolveZone() error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Create standalone configuration (master only, no members)
|
||||
zoneRequest := models.NewZoneRequest(currentZone.Master)
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// IsInZone checks if this device is part of a multiroom zone
|
||||
func (c *Client) IsInZone() (bool, error) {
|
||||
zone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return !zone.IsStandalone(), nil
|
||||
}
|
||||
|
||||
// GetZoneStatus returns the zone status for this device
|
||||
func (c *Client) GetZoneStatus() (models.ZoneStatus, error) {
|
||||
zone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return models.ZoneStatusStandalone, err
|
||||
}
|
||||
|
||||
// Get device info to determine our device ID
|
||||
deviceInfo, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
return models.ZoneStatusStandalone, fmt.Errorf("failed to get device info: %w", err)
|
||||
}
|
||||
|
||||
return zone.GetZoneStatus(deviceInfo.DeviceID), nil
|
||||
}
|
||||
|
||||
// GetZoneMembers returns all devices in the current zone
|
||||
func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
zone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return zone.GetAllDeviceIDs(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/user_account/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
responseStatus int
|
||||
expectError bool
|
||||
expectedMaster string
|
||||
expectedMembers int
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
expectedMaster: "ABCD1234EFGH",
|
||||
expectedMembers: 0,
|
||||
},
|
||||
{
|
||||
name: "Zone with members",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`,
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
expectedMaster: "ABCD1234EFGH",
|
||||
expectedMembers: 2,
|
||||
},
|
||||
{
|
||||
name: "Server error",
|
||||
responseXML: `<error>Server Error</error>`,
|
||||
responseStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
expectedMaster: "",
|
||||
expectedMembers: 0,
|
||||
},
|
||||
{
|
||||
name: "Device not found",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="ABCD1234EFGH">
|
||||
<error value="7" name="DEVICE_NOT_FOUND_ERROR">Device not found</error>
|
||||
</errors>`,
|
||||
responseStatus: http.StatusNotFound,
|
||||
expectError: true,
|
||||
expectedMaster: "",
|
||||
expectedMembers: 0,
|
||||
},
|
||||
}
|
||||
|
||||
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.URL.Path != "/getZone" {
|
||||
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("Expected GET method, got %s", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(tt.responseStatus)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
zone, err := client.GetZone()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if zone.Master != tt.expectedMaster {
|
||||
t.Errorf("Expected master %s, got %s", tt.expectedMaster, zone.Master)
|
||||
}
|
||||
|
||||
if len(zone.Members) != tt.expectedMembers {
|
||||
t.Errorf("Expected %d members, got %d", tt.expectedMembers, len(zone.Members))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SetZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
zoneRequest *models.ZoneRequest
|
||||
responseStatus int
|
||||
expectError bool
|
||||
errorMessage string
|
||||
}{
|
||||
{
|
||||
name: "Valid zone request",
|
||||
zoneRequest: func() *models.ZoneRequest {
|
||||
zr := models.NewZoneRequest("ABCD1234EFGH")
|
||||
zr.AddMember("EFGH5678IJKL", "192.168.1.11")
|
||||
return zr
|
||||
}(),
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Standalone zone request",
|
||||
zoneRequest: models.NewZoneRequest("ABCD1234EFGH"),
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid zone request - empty master",
|
||||
zoneRequest: &models.ZoneRequest{},
|
||||
expectError: true,
|
||||
errorMessage: "invalid zone request: master device ID is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid zone request - duplicate device",
|
||||
zoneRequest: func() *models.ZoneRequest {
|
||||
zr := models.NewZoneRequest("ABCD1234EFGH")
|
||||
zr.AddMember("ABCD1234EFGH", "192.168.1.10") // Same as master
|
||||
return zr
|
||||
}(),
|
||||
expectError: true,
|
||||
errorMessage: "invalid zone request: duplicate device ID found: ABCD1234EFGH",
|
||||
},
|
||||
{
|
||||
name: "Server error response",
|
||||
zoneRequest: models.NewZoneRequest("ABCD1234EFGH"),
|
||||
responseStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
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.URL.Path != "/setZone" {
|
||||
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Check Content-Type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/xml" {
|
||||
t.Errorf("Expected Content-Type application/xml, got %s", contentType)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(tt.responseStatus)
|
||||
if tt.responseStatus != http.StatusOK {
|
||||
w.Write([]byte(`<error>Server Error</error>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
err := client.SetZone(tt.zoneRequest)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
} else if tt.errorMessage != "" && err.Error() != tt.errorMessage {
|
||||
t.Errorf("Expected error message '%s', got '%s'", tt.errorMessage, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateZone(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setZone" {
|
||||
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
masterDeviceID := "ABCD1234EFGH"
|
||||
memberDeviceIDs := []string{"EFGH5678IJKL", "IJKL9012MNOP"}
|
||||
|
||||
err := client.CreateZone(masterDeviceID, memberDeviceIDs)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateZoneWithIPs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setZone" {
|
||||
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
masterDeviceID := "ABCD1234EFGH"
|
||||
members := map[string]string{
|
||||
"EFGH5678IJKL": "192.168.1.11",
|
||||
"IJKL9012MNOP": "192.168.1.12",
|
||||
}
|
||||
|
||||
err := client.CreateZoneWithIPs(masterDeviceID, members)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddToZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
getZoneCalled = true
|
||||
// Return existing zone
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
setZoneCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
err := client.AddToZone("IJKL9012MNOP", "192.168.1.12")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
|
||||
if !getZoneCalled {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveFromZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
getZoneCalled = true
|
||||
// Return existing zone with members
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
setZoneCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
err := client.RemoveFromZone("EFGH5678IJKL")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
|
||||
if !getZoneCalled {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DissolveZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
getZoneCalled = true
|
||||
// Return existing zone with members
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
setZoneCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
err := client.DissolveZone()
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
|
||||
if !getZoneCalled {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_IsInZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
expectedResult bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
expectedResult: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Device in zone",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`,
|
||||
expectedResult: true,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
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.URL.Path != "/getZone" {
|
||||
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
result, err := client.IsInZone()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expectedResult {
|
||||
t.Errorf("Expected result %t, got %t", tt.expectedResult, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetZoneStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceInfoXML string
|
||||
zoneXML string
|
||||
expectedStatus models.ZoneStatus
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>Living Room</name>
|
||||
<type>SoundTouch 20</type>
|
||||
</info>`,
|
||||
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
expectedStatus: models.ZoneStatusStandalone,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zone master",
|
||||
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>Living Room</name>
|
||||
<type>SoundTouch 20</type>
|
||||
</info>`,
|
||||
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`,
|
||||
expectedStatus: models.ZoneStatusMaster,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zone member",
|
||||
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="EFGH5678IJKL">
|
||||
<name>Kitchen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
</info>`,
|
||||
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`,
|
||||
expectedStatus: models.ZoneStatusSlave,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if r.URL.Path == "/getZone" {
|
||||
w.Write([]byte(tt.zoneXML))
|
||||
} else if r.URL.Path == "/info" {
|
||||
w.Write([]byte(tt.deviceInfoXML))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
status, err := client.GetZoneStatus()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if status != tt.expectedStatus {
|
||||
t.Errorf("Expected status %v, got %v", tt.expectedStatus, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetZoneMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
expectedMembers []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
expectedMembers: []string{"ABCD1234EFGH"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zone with members",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`,
|
||||
expectedMembers: []string{"ABCD1234EFGH", "EFGH5678IJKL", "IJKL9012MNOP"},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
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.URL.Path != "/getZone" {
|
||||
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
members, err := client.GetZoneMembers()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(members) != len(tt.expectedMembers) {
|
||||
t.Errorf("Expected %d members, got %d", len(tt.expectedMembers), len(members))
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedMember := range tt.expectedMembers {
|
||||
if members[i] != expectedMember {
|
||||
t.Errorf("Expected member %s at index %d, got %s", expectedMember, i, members[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Zone_ErrorHandling(t *testing.T) {
|
||||
t.Run("GetZone network error", func(t *testing.T) {
|
||||
client := NewClientFromHost("invalid.host:9999")
|
||||
client.timeout = 100 * time.Millisecond
|
||||
|
||||
_, err := client.GetZone()
|
||||
if err == nil {
|
||||
t.Error("Expected network error, but got none")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetZone network error", func(t *testing.T) {
|
||||
client := NewClientFromHost("invalid.host:9999")
|
||||
client.timeout = 100 * time.Millisecond
|
||||
|
||||
zr := models.NewZoneRequest("DEVICE123")
|
||||
err := client.SetZone(zr)
|
||||
if err == nil {
|
||||
t.Error("Expected network error, but got none")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AddToZone - GetZone fails", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getZone" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`<error>Server Error</error>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
err := client.AddToZone("DEVICE456", "192.168.1.10")
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error when GetZone fails, but got none")
|
||||
}
|
||||
|
||||
expectedErrorPrefix := "failed to get current zone:"
|
||||
if !strings.Contains(err.Error(), expectedErrorPrefix) {
|
||||
t.Errorf("Expected error to contain '%s', got: %v", expectedErrorPrefix, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkClient_GetZone(b *testing.B) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := client.GetZone()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkClient_SetZone(b *testing.B) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
zoneRequest := models.NewZoneRequest("ABCD1234EFGH")
|
||||
zoneRequest.AddMember("EFGH5678IJKL", "192.168.1.11")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := client.SetZone(zoneRequest)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ZoneInfo represents the response from GET /getZone endpoint
|
||||
type ZoneInfo struct {
|
||||
XMLName xml.Name `xml:"zone"`
|
||||
Master string `xml:"master,attr"`
|
||||
Members []Member `xml:"member"`
|
||||
}
|
||||
|
||||
// Member represents a device member in a multiroom zone
|
||||
type Member struct {
|
||||
XMLName xml.Name `xml:"member"`
|
||||
DeviceID string `xml:",chardata"`
|
||||
IP string `xml:"ipaddress,attr"`
|
||||
}
|
||||
|
||||
// ZoneRequest represents the request for POST /setZone endpoint
|
||||
type ZoneRequest struct {
|
||||
XMLName xml.Name `xml:"zone"`
|
||||
Master string `xml:"master,attr"`
|
||||
Members []MemberEntry `xml:"member"`
|
||||
}
|
||||
|
||||
// MemberEntry represents a member entry in zone configuration requests
|
||||
type MemberEntry struct {
|
||||
XMLName xml.Name `xml:"member"`
|
||||
DeviceID string `xml:",chardata"`
|
||||
IP string `xml:"ipaddress,attr,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneStatus represents possible zone states
|
||||
type ZoneStatus string
|
||||
|
||||
const (
|
||||
ZoneStatusStandalone ZoneStatus = "STANDALONE"
|
||||
ZoneStatusMaster ZoneStatus = "MASTER"
|
||||
ZoneStatusSlave ZoneStatus = "SLAVE"
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation
|
||||
func (zs ZoneStatus) String() string {
|
||||
switch zs {
|
||||
case ZoneStatusStandalone:
|
||||
return "Standalone"
|
||||
case ZoneStatusMaster:
|
||||
return "Zone Master"
|
||||
case ZoneStatusSlave:
|
||||
return "Zone Member"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// NewZoneRequest creates a new zone configuration request
|
||||
func NewZoneRequest(masterDeviceID string) *ZoneRequest {
|
||||
return &ZoneRequest{
|
||||
Master: masterDeviceID,
|
||||
Members: []MemberEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// AddMember adds a device to the zone configuration
|
||||
func (zr *ZoneRequest) AddMember(deviceID, ipAddress string) {
|
||||
member := MemberEntry{
|
||||
DeviceID: deviceID,
|
||||
IP: ipAddress,
|
||||
}
|
||||
zr.Members = append(zr.Members, member)
|
||||
}
|
||||
|
||||
// AddMemberByDeviceID adds a device to the zone by device ID only
|
||||
func (zr *ZoneRequest) AddMemberByDeviceID(deviceID string) {
|
||||
member := MemberEntry{
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
zr.Members = append(zr.Members, member)
|
||||
}
|
||||
|
||||
// RemoveMember removes a device from the zone configuration
|
||||
func (zr *ZoneRequest) RemoveMember(deviceID string) {
|
||||
for i, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
zr.Members = append(zr.Members[:i], zr.Members[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearMembers removes all members from the zone (creates standalone configuration)
|
||||
func (zr *ZoneRequest) ClearMembers() {
|
||||
zr.Members = []MemberEntry{}
|
||||
}
|
||||
|
||||
// HasMember checks if a device is in the zone configuration
|
||||
func (zr *ZoneRequest) HasMember(deviceID string) bool {
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetMemberCount returns the number of members in the zone
|
||||
func (zr *ZoneRequest) GetMemberCount() int {
|
||||
return len(zr.Members)
|
||||
}
|
||||
|
||||
// Validate validates the zone request
|
||||
func (zr *ZoneRequest) Validate() error {
|
||||
if zr.Master == "" {
|
||||
return fmt.Errorf("master device ID is required")
|
||||
}
|
||||
|
||||
// Check for duplicate device IDs
|
||||
seen := make(map[string]bool)
|
||||
seen[zr.Master] = true
|
||||
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == "" {
|
||||
return fmt.Errorf("member device ID cannot be empty")
|
||||
}
|
||||
|
||||
if seen[member.DeviceID] {
|
||||
return fmt.Errorf("duplicate device ID found: %s", member.DeviceID)
|
||||
}
|
||||
seen[member.DeviceID] = true
|
||||
|
||||
// Validate IP address if provided
|
||||
if member.IP != "" {
|
||||
if net.ParseIP(member.IP) == nil {
|
||||
return fmt.Errorf("invalid IP address for device %s: %s", member.DeviceID, member.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsStandalone returns true if this is a standalone (single device) configuration
|
||||
func (zi *ZoneInfo) IsStandalone() bool {
|
||||
return len(zi.Members) == 0
|
||||
}
|
||||
|
||||
// IsMaster returns true if the given device ID is the zone master
|
||||
func (zi *ZoneInfo) IsMaster(deviceID string) bool {
|
||||
return zi.Master == deviceID
|
||||
}
|
||||
|
||||
// IsMember returns true if the given device ID is a zone member (not master)
|
||||
func (zi *ZoneInfo) IsMember(deviceID string) bool {
|
||||
for _, member := range zi.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsInZone returns true if the given device ID is in the zone (master or member)
|
||||
func (zi *ZoneInfo) IsInZone(deviceID string) bool {
|
||||
return zi.IsMaster(deviceID) || zi.IsMember(deviceID)
|
||||
}
|
||||
|
||||
// GetMemberByDeviceID returns the member with the given device ID
|
||||
func (zi *ZoneInfo) GetMemberByDeviceID(deviceID string) (*Member, bool) {
|
||||
for _, member := range zi.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return &member, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetMemberByIP returns the member with the given IP address
|
||||
func (zi *ZoneInfo) GetMemberByIP(ipAddress string) (*Member, bool) {
|
||||
for _, member := range zi.Members {
|
||||
if member.IP == ipAddress {
|
||||
return &member, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetAllDeviceIDs returns all device IDs in the zone (master + members)
|
||||
func (zi *ZoneInfo) GetAllDeviceIDs() []string {
|
||||
devices := []string{zi.Master}
|
||||
for _, member := range zi.Members {
|
||||
devices = append(devices, member.DeviceID)
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// GetTotalDeviceCount returns the total number of devices in the zone
|
||||
func (zi *ZoneInfo) GetTotalDeviceCount() int {
|
||||
return 1 + len(zi.Members) // Master + members
|
||||
}
|
||||
|
||||
// GetZoneStatus returns the zone status for a given device ID
|
||||
func (zi *ZoneInfo) GetZoneStatus(deviceID string) ZoneStatus {
|
||||
if zi.IsMaster(deviceID) {
|
||||
if zi.IsStandalone() {
|
||||
return ZoneStatusStandalone
|
||||
}
|
||||
return ZoneStatusMaster
|
||||
}
|
||||
if zi.IsMember(deviceID) {
|
||||
return ZoneStatusSlave
|
||||
}
|
||||
return ZoneStatusStandalone // Device not in zone
|
||||
}
|
||||
|
||||
// String returns a human-readable string representation of the zone
|
||||
func (zi *ZoneInfo) String() string {
|
||||
if zi.IsStandalone() {
|
||||
return fmt.Sprintf("Standalone device: %s", zi.Master)
|
||||
}
|
||||
|
||||
var memberIDs []string
|
||||
for _, member := range zi.Members {
|
||||
memberIDs = append(memberIDs, member.DeviceID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Zone Master: %s, Members: [%s] (%d total devices)",
|
||||
zi.Master, strings.Join(memberIDs, ", "), zi.GetTotalDeviceCount())
|
||||
}
|
||||
|
||||
// ToZoneRequest converts ZoneInfo to a ZoneRequest for modification
|
||||
func (zi *ZoneInfo) ToZoneRequest() *ZoneRequest {
|
||||
request := NewZoneRequest(zi.Master)
|
||||
for _, member := range zi.Members {
|
||||
request.AddMember(member.DeviceID, member.IP)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// ZoneOperation represents different types of zone operations
|
||||
type ZoneOperation string
|
||||
|
||||
const (
|
||||
ZoneOpCreate ZoneOperation = "CREATE"
|
||||
ZoneOpModify ZoneOperation = "MODIFY"
|
||||
ZoneOpAddMember ZoneOperation = "ADD_MEMBER"
|
||||
ZoneOpRemove ZoneOperation = "REMOVE_MEMBER"
|
||||
ZoneOpDissolve ZoneOperation = "DISSOLVE"
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation
|
||||
func (zo ZoneOperation) String() string {
|
||||
switch zo {
|
||||
case ZoneOpCreate:
|
||||
return "Create Zone"
|
||||
case ZoneOpModify:
|
||||
return "Modify Zone"
|
||||
case ZoneOpAddMember:
|
||||
return "Add Member"
|
||||
case ZoneOpRemove:
|
||||
return "Remove Member"
|
||||
case ZoneOpDissolve:
|
||||
return "Dissolve Zone"
|
||||
default:
|
||||
return "Unknown Operation"
|
||||
}
|
||||
}
|
||||
|
||||
// ZoneBuilder provides a fluent interface for building zone configurations
|
||||
type ZoneBuilder struct {
|
||||
request *ZoneRequest
|
||||
}
|
||||
|
||||
// NewZoneBuilder creates a new zone builder with the specified master device
|
||||
func NewZoneBuilder(masterDeviceID string) *ZoneBuilder {
|
||||
return &ZoneBuilder{
|
||||
request: NewZoneRequest(masterDeviceID),
|
||||
}
|
||||
}
|
||||
|
||||
// WithMember adds a member to the zone configuration
|
||||
func (zb *ZoneBuilder) WithMember(deviceID, ipAddress string) *ZoneBuilder {
|
||||
zb.request.AddMember(deviceID, ipAddress)
|
||||
return zb
|
||||
}
|
||||
|
||||
// WithMemberByDeviceID adds a member by device ID only
|
||||
func (zb *ZoneBuilder) WithMemberByDeviceID(deviceID string) *ZoneBuilder {
|
||||
zb.request.AddMemberByDeviceID(deviceID)
|
||||
return zb
|
||||
}
|
||||
|
||||
// Build returns the constructed zone request
|
||||
func (zb *ZoneBuilder) Build() (*ZoneRequest, error) {
|
||||
if err := zb.request.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return zb.request, nil
|
||||
}
|
||||
|
||||
// ZoneError represents zone-specific errors
|
||||
type ZoneError struct {
|
||||
Operation ZoneOperation
|
||||
DeviceID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (ze *ZoneError) Error() string {
|
||||
if ze.DeviceID != "" {
|
||||
return fmt.Sprintf("zone %s failed for device %s: %s",
|
||||
ze.Operation.String(), ze.DeviceID, ze.Reason)
|
||||
}
|
||||
return fmt.Sprintf("zone %s failed: %s", ze.Operation.String(), ze.Reason)
|
||||
}
|
||||
|
||||
// NewZoneError creates a new zone error
|
||||
func NewZoneError(op ZoneOperation, deviceID, reason string) *ZoneError {
|
||||
return &ZoneError{
|
||||
Operation: op,
|
||||
DeviceID: deviceID,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// Common zone error reasons
|
||||
const (
|
||||
ZoneErrorDeviceNotFound = "device not found"
|
||||
ZoneErrorDeviceOffline = "device offline"
|
||||
ZoneErrorIncompatible = "incompatible device type"
|
||||
ZoneErrorAlreadyInZone = "device already in zone"
|
||||
ZoneErrorNotInZone = "device not in zone"
|
||||
ZoneErrorMasterRequired = "master device required"
|
||||
ZoneErrorNetworkError = "network communication error"
|
||||
ZoneErrorUnsupported = "operation not supported by device"
|
||||
ZoneErrorMaxMembersReached = "maximum zone members reached"
|
||||
)
|
||||
|
||||
// ZoneCapabilities represents zone-related capabilities of a device
|
||||
type ZoneCapabilities struct {
|
||||
CanBeMaster bool `json:"canBeMaster"`
|
||||
CanBeMember bool `json:"canBeMember"`
|
||||
MaxZoneMembers int `json:"maxZoneMembers"`
|
||||
SupportsMultiroom bool `json:"supportsMultiroom"`
|
||||
}
|
||||
|
||||
// DefaultZoneCapabilities returns default zone capabilities
|
||||
func DefaultZoneCapabilities() ZoneCapabilities {
|
||||
return ZoneCapabilities{
|
||||
CanBeMaster: true,
|
||||
CanBeMember: true,
|
||||
MaxZoneMembers: 6, // Common SoundTouch limit
|
||||
SupportsMultiroom: true,
|
||||
}
|
||||
}
|
||||
|
||||
// CanCreateZone returns true if the device can create zones
|
||||
func (zc *ZoneCapabilities) CanCreateZone() bool {
|
||||
return zc.SupportsMultiroom && zc.CanBeMaster
|
||||
}
|
||||
|
||||
// CanJoinZone returns true if the device can join zones
|
||||
func (zc *ZoneCapabilities) CanJoinZone() bool {
|
||||
return zc.SupportsMultiroom && zc.CanBeMember
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewZoneRequest(t *testing.T) {
|
||||
masterID := "ABCD1234"
|
||||
zr := NewZoneRequest(masterID)
|
||||
|
||||
if zr.Master != masterID {
|
||||
t.Errorf("Expected master %s, got %s", masterID, zr.Master)
|
||||
}
|
||||
|
||||
if len(zr.Members) != 0 {
|
||||
t.Errorf("Expected 0 members, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_AddMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
if zr.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected first member DEVICE456, got %s", zr.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[0].IP != "192.168.1.10" {
|
||||
t.Errorf("Expected first member IP 192.168.1.10, got %s", zr.Members[0].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_AddMemberByDeviceID(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
zr.AddMemberByDeviceID("DEVICE456")
|
||||
|
||||
if len(zr.Members) != 1 {
|
||||
t.Errorf("Expected 1 member, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
if zr.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected member DEVICE456, got %s", zr.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[0].IP != "" {
|
||||
t.Errorf("Expected empty IP, got %s", zr.Members[0].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_RemoveMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
zr.AddMember("DEVICEABC", "192.168.1.12")
|
||||
|
||||
// Remove middle member
|
||||
zr.RemoveMember("DEVICE789")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removal, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
// Check that the correct member was removed
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == "DEVICE789" {
|
||||
t.Error("DEVICE789 should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove non-existent member (should not change anything)
|
||||
zr.RemoveMember("NONEXISTENT")
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removing non-existent, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_ClearMembers(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
|
||||
zr.ClearMembers()
|
||||
|
||||
if len(zr.Members) != 0 {
|
||||
t.Errorf("Expected 0 members after clear, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_HasMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
|
||||
if !zr.HasMember("DEVICE456") {
|
||||
t.Error("Expected HasMember to return true for DEVICE456")
|
||||
}
|
||||
|
||||
if zr.HasMember("NONEXISTENT") {
|
||||
t.Error("Expected HasMember to return false for non-existent device")
|
||||
}
|
||||
|
||||
if zr.HasMember("MASTER123") {
|
||||
t.Error("Expected HasMember to return false for master device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_GetMemberCount(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
if zr.GetMemberCount() != 0 {
|
||||
t.Errorf("Expected 0 members initially, got %d", zr.GetMemberCount())
|
||||
}
|
||||
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
if zr.GetMemberCount() != 1 {
|
||||
t.Errorf("Expected 1 member, got %d", zr.GetMemberCount())
|
||||
}
|
||||
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
if zr.GetMemberCount() != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", zr.GetMemberCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func() *ZoneRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid zone request",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
return zr
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid zone request without IP",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMemberByDeviceID("DEVICE456")
|
||||
return zr
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty master device ID",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := &ZoneRequest{}
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "master device ID is required",
|
||||
},
|
||||
{
|
||||
name: "Empty member device ID",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.Members = append(zr.Members, MemberEntry{DeviceID: ""})
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "member device ID cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Duplicate member device ID",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE456", "192.168.1.11")
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "duplicate device ID found: DEVICE456",
|
||||
},
|
||||
{
|
||||
name: "Master device ID as member",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("MASTER123", "192.168.1.10")
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "duplicate device ID found: MASTER123",
|
||||
},
|
||||
{
|
||||
name: "Invalid IP address",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "invalid.ip.address")
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid IP address for device DEVICE456: invalid.ip.address",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
zr := tt.setupFunc()
|
||||
err := zr.Validate()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but got none")
|
||||
} else if err.Error() != tt.errorMsg {
|
||||
t.Errorf("Expected error '%s', got '%s'", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsStandalone(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{},
|
||||
}
|
||||
|
||||
if !zi.IsStandalone() {
|
||||
t.Error("Expected standalone zone to return true")
|
||||
}
|
||||
|
||||
zi.Members = append(zi.Members, Member{DeviceID: "DEVICE456"})
|
||||
if zi.IsStandalone() {
|
||||
t.Error("Expected zone with members to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsMaster(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
if !zi.IsMaster("MASTER123") {
|
||||
t.Error("Expected IsMaster to return true for master device")
|
||||
}
|
||||
|
||||
if zi.IsMaster("DEVICE456") {
|
||||
t.Error("Expected IsMaster to return false for member device")
|
||||
}
|
||||
|
||||
if zi.IsMaster("NONEXISTENT") {
|
||||
t.Error("Expected IsMaster to return false for non-existent device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsMember(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
},
|
||||
}
|
||||
|
||||
if !zi.IsMember("DEVICE456") {
|
||||
t.Error("Expected IsMember to return true for member device")
|
||||
}
|
||||
|
||||
if zi.IsMember("MASTER123") {
|
||||
t.Error("Expected IsMember to return false for master device")
|
||||
}
|
||||
|
||||
if zi.IsMember("NONEXISTENT") {
|
||||
t.Error("Expected IsMember to return false for non-existent device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsInZone(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
if !zi.IsInZone("MASTER123") {
|
||||
t.Error("Expected IsInZone to return true for master device")
|
||||
}
|
||||
|
||||
if !zi.IsInZone("DEVICE456") {
|
||||
t.Error("Expected IsInZone to return true for member device")
|
||||
}
|
||||
|
||||
if zi.IsInZone("NONEXISTENT") {
|
||||
t.Error("Expected IsInZone to return false for non-existent device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetMemberByDeviceID(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456", IP: "192.168.1.10"},
|
||||
{DeviceID: "DEVICE789", IP: "192.168.1.11"},
|
||||
},
|
||||
}
|
||||
|
||||
member, found := zi.GetMemberByDeviceID("DEVICE456")
|
||||
if !found {
|
||||
t.Error("Expected to find member DEVICE456")
|
||||
}
|
||||
if member.IP != "192.168.1.10" {
|
||||
t.Errorf("Expected IP 192.168.1.10, got %s", member.IP)
|
||||
}
|
||||
|
||||
_, found = zi.GetMemberByDeviceID("NONEXISTENT")
|
||||
if found {
|
||||
t.Error("Expected not to find non-existent member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetMemberByIP(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456", IP: "192.168.1.10"},
|
||||
{DeviceID: "DEVICE789", IP: "192.168.1.11"},
|
||||
},
|
||||
}
|
||||
|
||||
member, found := zi.GetMemberByIP("192.168.1.10")
|
||||
if !found {
|
||||
t.Error("Expected to find member by IP 192.168.1.10")
|
||||
}
|
||||
if member.DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected device ID DEVICE456, got %s", member.DeviceID)
|
||||
}
|
||||
|
||||
_, found = zi.GetMemberByIP("192.168.1.99")
|
||||
if found {
|
||||
t.Error("Expected not to find member with non-existent IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetAllDeviceIDs(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
},
|
||||
}
|
||||
|
||||
deviceIDs := zi.GetAllDeviceIDs()
|
||||
expected := []string{"MASTER123", "DEVICE456", "DEVICE789"}
|
||||
|
||||
if len(deviceIDs) != len(expected) {
|
||||
t.Errorf("Expected %d device IDs, got %d", len(expected), len(deviceIDs))
|
||||
}
|
||||
|
||||
for i, expectedID := range expected {
|
||||
if deviceIDs[i] != expectedID {
|
||||
t.Errorf("Expected device ID %s at index %d, got %s", expectedID, i, deviceIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetTotalDeviceCount(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
},
|
||||
}
|
||||
|
||||
count := zi.GetTotalDeviceCount()
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 devices, got %d", count)
|
||||
}
|
||||
|
||||
// Test standalone
|
||||
zi.Members = []Member{}
|
||||
count = zi.GetTotalDeviceCount()
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 device for standalone, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetZoneStatus(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test master device in zone
|
||||
status := zi.GetZoneStatus("MASTER123")
|
||||
if status != ZoneStatusMaster {
|
||||
t.Errorf("Expected ZoneStatusMaster, got %v", status)
|
||||
}
|
||||
|
||||
// Test member device
|
||||
status = zi.GetZoneStatus("DEVICE456")
|
||||
if status != ZoneStatusSlave {
|
||||
t.Errorf("Expected ZoneStatusSlave, got %v", status)
|
||||
}
|
||||
|
||||
// Test device not in zone
|
||||
status = zi.GetZoneStatus("NONEXISTENT")
|
||||
if status != ZoneStatusStandalone {
|
||||
t.Errorf("Expected ZoneStatusStandalone, got %v", status)
|
||||
}
|
||||
|
||||
// Test standalone master
|
||||
zi.Members = []Member{}
|
||||
status = zi.GetZoneStatus("MASTER123")
|
||||
if status != ZoneStatusStandalone {
|
||||
t.Errorf("Expected ZoneStatusStandalone for standalone master, got %v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneStatus_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
status ZoneStatus
|
||||
expected string
|
||||
}{
|
||||
{ZoneStatusStandalone, "Standalone"},
|
||||
{ZoneStatusMaster, "Zone Master"},
|
||||
{ZoneStatusSlave, "Zone Member"},
|
||||
{ZoneStatus("UNKNOWN"), "Unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tt.status.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_String(t *testing.T) {
|
||||
// Test standalone
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{},
|
||||
}
|
||||
|
||||
result := zi.String()
|
||||
expected := "Standalone device: MASTER123"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
|
||||
// Test zone with members
|
||||
zi.Members = []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
}
|
||||
|
||||
result = zi.String()
|
||||
expected = "Zone Master: MASTER123, Members: [DEVICE456, DEVICE789] (3 total devices)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_ToZoneRequest(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456", IP: "192.168.1.10"},
|
||||
{DeviceID: "DEVICE789", IP: "192.168.1.11"},
|
||||
},
|
||||
}
|
||||
|
||||
zr := zi.ToZoneRequest()
|
||||
|
||||
if zr.Master != zi.Master {
|
||||
t.Errorf("Expected master %s, got %s", zi.Master, zr.Master)
|
||||
}
|
||||
|
||||
if len(zr.Members) != len(zi.Members) {
|
||||
t.Errorf("Expected %d members, got %d", len(zi.Members), len(zr.Members))
|
||||
}
|
||||
|
||||
for i, member := range zi.Members {
|
||||
if zr.Members[i].DeviceID != member.DeviceID {
|
||||
t.Errorf("Expected member %s, got %s", member.DeviceID, zr.Members[i].DeviceID)
|
||||
}
|
||||
if zr.Members[i].IP != member.IP {
|
||||
t.Errorf("Expected IP %s, got %s", member.IP, zr.Members[i].IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneBuilder(t *testing.T) {
|
||||
zb := NewZoneBuilder("MASTER123")
|
||||
|
||||
zr, err := zb.
|
||||
WithMember("DEVICE456", "192.168.1.10").
|
||||
WithMemberByDeviceID("DEVICE789").
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if zr.Master != "MASTER123" {
|
||||
t.Errorf("Expected master MASTER123, got %s", zr.Master)
|
||||
}
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
if zr.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected first member DEVICE456, got %s", zr.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[0].IP != "192.168.1.10" {
|
||||
t.Errorf("Expected first member IP 192.168.1.10, got %s", zr.Members[0].IP)
|
||||
}
|
||||
|
||||
if zr.Members[1].DeviceID != "DEVICE789" {
|
||||
t.Errorf("Expected second member DEVICE789, got %s", zr.Members[1].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[1].IP != "" {
|
||||
t.Errorf("Expected second member empty IP, got %s", zr.Members[1].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneBuilder_ValidationError(t *testing.T) {
|
||||
zb := NewZoneBuilder("")
|
||||
|
||||
_, err := zb.Build()
|
||||
if err == nil {
|
||||
t.Error("Expected validation error for empty master ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneOperation_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
op ZoneOperation
|
||||
expected string
|
||||
}{
|
||||
{ZoneOpCreate, "Create Zone"},
|
||||
{ZoneOpModify, "Modify Zone"},
|
||||
{ZoneOpAddMember, "Add Member"},
|
||||
{ZoneOpRemove, "Remove Member"},
|
||||
{ZoneOpDissolve, "Dissolve Zone"},
|
||||
{ZoneOperation("UNKNOWN"), "Unknown Operation"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tt.op.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op ZoneOperation
|
||||
deviceID string
|
||||
reason string
|
||||
expectedMsg string
|
||||
}{
|
||||
{
|
||||
name: "Error with device ID",
|
||||
op: ZoneOpAddMember,
|
||||
deviceID: "DEVICE123",
|
||||
reason: "device offline",
|
||||
expectedMsg: "zone Add Member failed for device DEVICE123: device offline",
|
||||
},
|
||||
{
|
||||
name: "Error without device ID",
|
||||
op: ZoneOpCreate,
|
||||
deviceID: "",
|
||||
reason: "network error",
|
||||
expectedMsg: "zone Create Zone failed: network error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewZoneError(tt.op, tt.deviceID, tt.reason)
|
||||
|
||||
if err.Error() != tt.expectedMsg {
|
||||
t.Errorf("Expected error message '%s', got '%s'", tt.expectedMsg, err.Error())
|
||||
}
|
||||
|
||||
if err.Operation != tt.op {
|
||||
t.Errorf("Expected operation %v, got %v", tt.op, err.Operation)
|
||||
}
|
||||
|
||||
if err.DeviceID != tt.deviceID {
|
||||
t.Errorf("Expected device ID '%s', got '%s'", tt.deviceID, err.DeviceID)
|
||||
}
|
||||
|
||||
if err.Reason != tt.reason {
|
||||
t.Errorf("Expected reason '%s', got '%s'", tt.reason, err.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneCapabilities(t *testing.T) {
|
||||
caps := DefaultZoneCapabilities()
|
||||
|
||||
if !caps.CanCreateZone() {
|
||||
t.Error("Expected default capabilities to allow zone creation")
|
||||
}
|
||||
|
||||
if !caps.CanJoinZone() {
|
||||
t.Error("Expected default capabilities to allow joining zones")
|
||||
}
|
||||
|
||||
// Test incapable device
|
||||
caps.SupportsMultiroom = false
|
||||
if caps.CanCreateZone() {
|
||||
t.Error("Expected device without multiroom support to not create zones")
|
||||
}
|
||||
|
||||
if caps.CanJoinZone() {
|
||||
t.Error("Expected device without multiroom support to not join zones")
|
||||
}
|
||||
|
||||
// Test master-only device
|
||||
caps.SupportsMultiroom = true
|
||||
caps.CanBeMember = false
|
||||
if caps.CanJoinZone() {
|
||||
t.Error("Expected device that can't be member to not join zones")
|
||||
}
|
||||
|
||||
if !caps.CanCreateZone() {
|
||||
t.Error("Expected device that can be master to create zones")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneXMLMarshaling(t *testing.T) {
|
||||
t.Run("ZoneInfo XML Unmarshaling", func(t *testing.T) {
|
||||
xmlData := `<zone master="MASTER123">
|
||||
<member ipaddress="192.168.1.10">DEVICE456</member>
|
||||
<member ipaddress="192.168.1.11">DEVICE789</member>
|
||||
</zone>`
|
||||
|
||||
var zi ZoneInfo
|
||||
err := xml.Unmarshal([]byte(xmlData), &zi)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if zi.Master != "MASTER123" {
|
||||
t.Errorf("Expected master MASTER123, got %s", zi.Master)
|
||||
}
|
||||
|
||||
if len(zi.Members) != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", len(zi.Members))
|
||||
}
|
||||
|
||||
if zi.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected first member DEVICE456, got %s", zi.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zi.Members[0].IP != "192.168.1.10" {
|
||||
t.Errorf("Expected first member IP 192.168.1.10, got %s", zi.Members[0].IP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZoneRequest XML Marshaling", func(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
|
||||
data, err := xml.MarshalIndent(zr, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<zone master="MASTER123">
|
||||
<member ipaddress="192.168.1.10">DEVICE456</member>
|
||||
<member ipaddress="192.168.1.11">DEVICE789</member>
|
||||
</zone>`
|
||||
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML:\n%s\n\nGot:\n%s", expected, string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZoneRequest XML Marshaling Without IP", func(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMemberByDeviceID("DEVICE456")
|
||||
|
||||
data, err := xml.MarshalIndent(zr, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<zone master="MASTER123">
|
||||
<member>DEVICE456</member>
|
||||
</zone>`
|
||||
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML:\n%s\n\nGot:\n%s", expected, string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty Zone XML", func(t *testing.T) {
|
||||
xmlData := `<zone master="MASTER123"></zone>`
|
||||
|
||||
var zi ZoneInfo
|
||||
err := xml.Unmarshal([]byte(xmlData), &zi)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if zi.Master != "MASTER123" {
|
||||
t.Errorf("Expected master MASTER123, got %s", zi.Master)
|
||||
}
|
||||
|
||||
if len(zi.Members) != 0 {
|
||||
t.Errorf("Expected 0 members, got %d", len(zi.Members))
|
||||
}
|
||||
|
||||
if !zi.IsStandalone() {
|
||||
t.Error("Expected zone to be standalone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestZoneEdgeCases(t *testing.T) {
|
||||
t.Run("Zone with many members", func(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER")
|
||||
for i := 1; i <= 10; i++ {
|
||||
deviceID := fmt.Sprintf("DEVICE%03d", i)
|
||||
ip := fmt.Sprintf("192.168.1.%d", i+10)
|
||||
zr.AddMember(deviceID, ip)
|
||||
}
|
||||
|
||||
if err := zr.Validate(); err != nil {
|
||||
t.Errorf("Expected valid zone request, got error: %v", err)
|
||||
}
|
||||
|
||||
if zr.GetMemberCount() != 10 {
|
||||
t.Errorf("Expected 10 members, got %d", zr.GetMemberCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Zone status for various scenarios", func(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test all status types
|
||||
if zi.GetZoneStatus("MASTER123") != ZoneStatusMaster {
|
||||
t.Error("Master should have ZoneStatusMaster")
|
||||
}
|
||||
|
||||
if zi.GetZoneStatus("DEVICE456") != ZoneStatusSlave {
|
||||
t.Error("Member should have ZoneStatusSlave")
|
||||
}
|
||||
|
||||
if zi.GetZoneStatus("OTHER") != ZoneStatusStandalone {
|
||||
t.Error("Unknown device should have ZoneStatusStandalone")
|
||||
}
|
||||
|
||||
// Test standalone scenario
|
||||
zi.Members = []Member{}
|
||||
if zi.GetZoneStatus("MASTER123") != ZoneStatusStandalone {
|
||||
t.Error("Standalone master should have ZoneStatusStandalone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkZoneInfo_GetAllDeviceIDs(b *testing.B) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
{DeviceID: "DEVICEABC"},
|
||||
{DeviceID: "DEVICEDEF"},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = zi.GetAllDeviceIDs()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkZoneRequest_Validate(b *testing.B) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
for i := 0; i < 5; i++ {
|
||||
deviceID := fmt.Sprintf("DEVICE%d", i)
|
||||
ip := fmt.Sprintf("192.168.1.%d", i+10)
|
||||
zr.AddMember(deviceID, ip)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = zr.Validate()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user