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) + } +}