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:
Tobias Gesellchen
2026-01-09 11:41:14 +01:00
parent cc39efc882
commit 9f980765f5
9 changed files with 2971 additions and 63 deletions
+118
View File
@@ -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
}
+670
View File
@@ -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)
}
}
}
+369
View File
@@ -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
}
+823
View File
@@ -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()
}
}