diff --git a/modules/weatherservices/arpansagovau/client.go b/modules/weatherservices/arpansagovau/client.go
index 2c522c61c..22d360da9 100644
--- a/modules/weatherservices/arpansagovau/client.go
+++ b/modules/weatherservices/arpansagovau/client.go
@@ -7,6 +7,9 @@ import (
"net/http"
)
+// apiURL is the endpoint for UV data. Overridden in tests.
+var apiURL = "https://uvdata.arpansa.gov.au/xml/uvvalues.xml"
+
type Stations struct {
XMLName xml.Name `xml:"stations"`
Text string `xml:",chardata"`
@@ -55,7 +58,7 @@ func getLocationData(cityname string) (*location, error) {
/* -------------------- Unexported Functions -------------------- */
func apiRequest() (*http.Response, error) {
- req, err := http.NewRequest("GET", "https://uvdata.arpansa.gov.au/xml/uvvalues.xml", http.NoBody)
+ req, err := http.NewRequest("GET", apiURL, http.NoBody)
if err != nil {
return nil, err
}
@@ -65,9 +68,9 @@ func apiRequest() (*http.Response, error) {
if err != nil {
return nil, err
}
- defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
+ _ = resp.Body.Close()
return nil, fmt.Errorf("%s", resp.Status)
}
diff --git a/modules/weatherservices/arpansagovau/client_test.go b/modules/weatherservices/arpansagovau/client_test.go
new file mode 100644
index 000000000..eaff8851c
--- /dev/null
+++ b/modules/weatherservices/arpansagovau/client_test.go
@@ -0,0 +1,250 @@
+package arpansagovau
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+const sampleXML = `
+
+
+ adl
+ 3.2
+
+ 25/07/2026
+ Saturday, 25 July 2026
+ 2026/07/25 03:00
+ ok
+
+
+ syd
+ 8.7
+
+ 25/07/2026
+ Saturday, 25 July 2026
+ 2026/07/25 03:30
+ ok
+
+
+ mel
+ 0.0
+
+ 25/07/2026
+ Saturday, 25 July 2026
+ 2026/07/25 03:30
+ unavailable
+
+`
+
+func TestParseXML_ValidData(t *testing.T) {
+ stations, err := parseXML(strings.NewReader(sampleXML))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(stations.Location) != 3 {
+ t.Fatalf("expected 3 locations, got %d", len(stations.Location))
+ }
+
+ tests := []struct {
+ idx int
+ id string
+ index float32
+ time string
+ date string
+ status string
+ }{
+ {0, "adl", 3.2, "12:30 PM", "25/07/2026", "ok"},
+ {1, "syd", 8.7, "1:00 PM", "25/07/2026", "ok"},
+ {2, "mel", 0.0, "1:00 PM", "25/07/2026", "unavailable"},
+ }
+
+ for _, tc := range tests {
+ loc := stations.Location[tc.idx]
+ if loc.ID != tc.id {
+ t.Errorf("location[%d]: expected ID %q, got %q", tc.idx, tc.id, loc.ID)
+ }
+ if loc.Index != tc.index {
+ t.Errorf("location[%d]: expected index %v, got %v", tc.idx, tc.index, loc.Index)
+ }
+ if loc.Time != tc.time {
+ t.Errorf("location[%d]: expected time %q, got %q", tc.idx, tc.time, loc.Time)
+ }
+ if loc.Date != tc.date {
+ t.Errorf("location[%d]: expected date %q, got %q", tc.idx, tc.date, loc.Date)
+ }
+ if loc.Status != tc.status {
+ t.Errorf("location[%d]: expected status %q, got %q", tc.idx, tc.status, loc.Status)
+ }
+ }
+}
+
+func TestParseXML_EmptyStations(t *testing.T) {
+ xml := ``
+ stations, err := parseXML(strings.NewReader(xml))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(stations.Location) != 0 {
+ t.Errorf("expected 0 locations, got %d", len(stations.Location))
+ }
+}
+
+func TestParseXML_InvalidXML(t *testing.T) {
+ _, err := parseXML(strings.NewReader("not xml at all"))
+ if err == nil {
+ t.Fatal("expected error for invalid XML")
+ }
+}
+
+func TestParseXML_MalformedXML(t *testing.T) {
+ xml := `adl`
+ _, err := parseXML(strings.NewReader(xml))
+ if err == nil {
+ t.Fatal("expected error for malformed/incomplete XML")
+ }
+}
+
+func TestParseXML_EmptyReader(t *testing.T) {
+ _, err := parseXML(strings.NewReader(""))
+ if err == nil {
+ t.Fatal("expected error for empty input")
+ }
+}
+
+func TestGetLocationData_Success(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ _, _ = w.Write([]byte(sampleXML))
+ }))
+ defer srv.Close()
+
+ oldURL := apiURL
+ apiURL = srv.URL
+ defer func() { apiURL = oldURL }()
+
+ loc, err := getLocationData("syd")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if loc.name != "syd" {
+ t.Errorf("expected name 'syd', got %q", loc.name)
+ }
+ if loc.index != 8.7 {
+ t.Errorf("expected index 8.7, got %v", loc.index)
+ }
+ if loc.time != "1:00 PM" {
+ t.Errorf("expected time '1:00 PM', got %q", loc.time)
+ }
+ if loc.status != "ok" {
+ t.Errorf("expected status 'ok', got %q", loc.status)
+ }
+}
+
+func TestGetLocationData_NotFound(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ _, _ = w.Write([]byte(sampleXML))
+ }))
+ defer srv.Close()
+
+ oldURL := apiURL
+ apiURL = srv.URL
+ defer func() { apiURL = oldURL }()
+
+ loc, err := getLocationData("nonexistent")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if loc.name != "" {
+ t.Errorf("expected empty name for non-existent city, got %q", loc.name)
+ }
+}
+
+func TestGetLocationData_ServerError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ oldURL := apiURL
+ apiURL = srv.URL
+ defer func() { apiURL = oldURL }()
+
+ _, err := getLocationData("syd")
+ if err == nil {
+ t.Fatal("expected error for server error response")
+ }
+}
+
+func TestGetLocationData_InvalidXMLResponse(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("not xml"))
+ }))
+ defer srv.Close()
+
+ oldURL := apiURL
+ apiURL = srv.URL
+ defer func() { apiURL = oldURL }()
+
+ _, err := getLocationData("syd")
+ if err == nil {
+ t.Fatal("expected error for invalid XML response")
+ }
+}
+
+func TestGetLocationData_ConnectionRefused(t *testing.T) {
+ oldURL := apiURL
+ apiURL = "http://127.0.0.1:1"
+ defer func() { apiURL = oldURL }()
+
+ _, err := getLocationData("syd")
+ if err == nil {
+ t.Fatal("expected error for connection refused")
+ }
+}
+
+func TestApiRequest_Success(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ t.Errorf("expected GET, got %s", r.Method)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(""))
+ }))
+ defer srv.Close()
+
+ oldURL := apiURL
+ apiURL = srv.URL
+ defer func() { apiURL = oldURL }()
+
+ resp, err := apiRequest()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 200 {
+ t.Errorf("expected 200, got %d", resp.StatusCode)
+ }
+}
+
+func TestApiRequest_Non200(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }))
+ defer srv.Close()
+
+ oldURL := apiURL
+ apiURL = srv.URL
+ defer func() { apiURL = oldURL }()
+
+ _, err := apiRequest()
+ if err == nil {
+ t.Fatal("expected error for non-200")
+ }
+ if !strings.Contains(err.Error(), "403") {
+ t.Errorf("expected 403 in error, got %q", err.Error())
+ }
+}
diff --git a/modules/weatherservices/arpansagovau/settings_test.go b/modules/weatherservices/arpansagovau/settings_test.go
new file mode 100644
index 000000000..8ca079a42
--- /dev/null
+++ b/modules/weatherservices/arpansagovau/settings_test.go
@@ -0,0 +1,93 @@
+package arpansagovau
+
+import (
+ "testing"
+
+ "github.com/olebedev/config"
+)
+
+const globalYAML = `
+wtf:
+ colors:
+ border:
+ focusable: "darkslateblue"
+ focused: "orange"
+ normal: "gray"
+`
+
+func TestNewSettingsFromYAML(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ wantCity string
+ }{
+ {
+ name: "with locationid set",
+ yaml: `
+locationid: syd
+position:
+ top: 0
+ left: 0
+ height: 1
+ width: 1
+`,
+ wantCity: "syd",
+ },
+ {
+ name: "with different city",
+ yaml: `
+locationid: adl
+position:
+ top: 0
+ left: 0
+ height: 1
+ width: 1
+`,
+ wantCity: "adl",
+ },
+ {
+ name: "without locationid",
+ yaml: `
+enabled: true
+position:
+ top: 0
+ left: 0
+ height: 1
+ width: 1
+`,
+ wantCity: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ymlConfig, err := config.ParseYaml(tc.yaml)
+ if err != nil {
+ t.Fatalf("failed to parse yaml: %v", err)
+ }
+
+ globalConfig, err := config.ParseYaml(globalYAML)
+ if err != nil {
+ t.Fatalf("failed to parse global yaml: %v", err)
+ }
+
+ settings := NewSettingsFromYAML("arpansagovau", ymlConfig, globalConfig)
+
+ if settings.city != tc.wantCity {
+ t.Errorf("expected city %q, got %q", tc.wantCity, settings.city)
+ }
+ if settings.Common == nil {
+ t.Fatal("expected Common to be non-nil")
+ }
+ })
+ }
+}
+
+func TestSettingsDefaults(t *testing.T) {
+ if defaultFocusable != false {
+ t.Errorf("expected defaultFocusable to be false")
+ }
+ if defaultTitle != "ARPANSA UV Data" {
+ t.Errorf("expected defaultTitle to be 'ARPANSA UV Data', got %q", defaultTitle)
+ }
+}
diff --git a/modules/weatherservices/arpansagovau/widget_test.go b/modules/weatherservices/arpansagovau/widget_test.go
new file mode 100644
index 000000000..169d8ccb9
--- /dev/null
+++ b/modules/weatherservices/arpansagovau/widget_test.go
@@ -0,0 +1,153 @@
+package arpansagovau
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestFormatLocationData_UVLevels(t *testing.T) {
+ tests := []struct {
+ name string
+ loc *location
+ wantLevel string
+ wantColor string
+ wantContains []string
+ wantAbsent []string
+ }{
+ {
+ name: "low UV",
+ loc: &location{name: "adl", index: 1.5, time: "10:00 AM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(LOW)",
+ wantColor: "[green]",
+ wantContains: []string{"Location: adl", "UV index:", "1.50", "Local time: 10:00 AM 25/07/2026", "Detector status: ok"},
+ },
+ {
+ name: "moderate UV lower bound",
+ loc: &location{name: "bri", index: 2.5, time: "11:00 AM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(MODERATE)",
+ wantColor: "[yellow]",
+ wantContains: []string{"2.50"},
+ },
+ {
+ name: "moderate UV upper",
+ loc: &location{name: "can", index: 5.4, time: "12:00 PM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(MODERATE)",
+ wantColor: "[yellow]",
+ wantContains: []string{"5.40"},
+ },
+ {
+ name: "high UV",
+ loc: &location{name: "syd", index: 6.0, time: "1:00 PM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(HIGH)",
+ wantColor: "[orange]",
+ wantContains: []string{"6.00"},
+ },
+ {
+ name: "very high UV",
+ loc: &location{name: "dar", index: 9.0, time: "2:00 PM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(VERY HIGH)",
+ wantColor: "[red]",
+ wantContains: []string{"9.00"},
+ },
+ {
+ name: "extreme UV",
+ loc: &location{name: "tow", index: 11.0, time: "12:00 PM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(EXTREME)",
+ wantColor: "[fuchsia]",
+ wantContains: []string{"11.00"},
+ },
+ {
+ name: "extreme UV at boundary",
+ loc: &location{name: "tow", index: 10.5, time: "12:00 PM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(EXTREME)",
+ wantColor: "[fuchsia]",
+ wantContains: []string{"10.50"},
+ },
+ {
+ name: "very high UV at boundary",
+ loc: &location{name: "per", index: 7.5, time: "11:30 AM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(VERY HIGH)",
+ wantColor: "[red]",
+ wantContains: []string{"7.50"},
+ },
+ {
+ name: "high UV at boundary",
+ loc: &location{name: "mel", index: 5.5, time: "11:30 AM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(HIGH)",
+ wantColor: "[orange]",
+ wantContains: []string{"5.50"},
+ },
+ {
+ name: "zero UV",
+ loc: &location{name: "hob", index: 0.0, time: "7:00 PM", date: "25/07/2026", status: "ok"},
+ wantLevel: "(LOW)",
+ wantColor: "[green]",
+ wantContains: []string{"0.00"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := formatLocationData(tc.loc)
+
+ if !strings.Contains(result, tc.wantLevel) {
+ t.Errorf("expected level %q in output:\n%s", tc.wantLevel, result)
+ }
+ if !strings.Contains(result, tc.wantColor) {
+ t.Errorf("expected color %q in output:\n%s", tc.wantColor, result)
+ }
+ for _, s := range tc.wantContains {
+ if !strings.Contains(result, s) {
+ t.Errorf("expected %q in output:\n%s", s, result)
+ }
+ }
+ })
+ }
+}
+
+func TestFormatLocationData_EmptyName(t *testing.T) {
+ loc := &location{name: "", index: 5.0, status: "ok"}
+ result := formatLocationData(loc)
+ expected := "[red]No data?"
+ if result != expected {
+ t.Errorf("expected %q, got %q", expected, result)
+ }
+}
+
+func TestFormatLocationData_StatusNotOk(t *testing.T) {
+ tests := []struct {
+ name string
+ loc *location
+ expect string
+ }{
+ {
+ name: "unavailable status",
+ loc: &location{name: "mel", index: 0.0, status: "unavailable"},
+ expect: "[red]Data unavailable for mel",
+ },
+ {
+ name: "error status",
+ loc: &location{name: "adl", index: 0.0, status: "error"},
+ expect: "[red]Data unavailable for adl",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := formatLocationData(tc.loc)
+ if result != tc.expect {
+ t.Errorf("expected %q, got %q", tc.expect, result)
+ }
+ })
+ }
+}
+
+func TestFormatLocationData_IndexFormatting(t *testing.T) {
+ loc := &location{name: "syd", index: 3.14159, time: "12:00 PM", date: "01/01/2026", status: "ok"}
+ result := formatLocationData(loc)
+ expected := fmt.Sprintf("%.2f", float32(3.14159))
+ if !strings.Contains(result, expected) {
+ t.Errorf("expected formatted index %q in output:\n%s", expected, result)
+ }
+}