diff --git a/app/widget_maker.go b/app/widget_maker.go index 313588d37..a825a284a 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -72,6 +72,7 @@ import ( "github.com/wtfutil/wtf/modules/stocks/yfinance" "github.com/wtfutil/wtf/modules/subreddit" "github.com/wtfutil/wtf/modules/system" + "github.com/wtfutil/wtf/modules/tennis" "github.com/wtfutil/wtf/modules/textfile" "github.com/wtfutil/wtf/modules/todo" "github.com/wtfutil/wtf/modules/todo_plus" @@ -313,6 +314,9 @@ func MakeWidget( case "system": settings := system.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = system.NewWidget(tviewApp, redrawChan, buildDate(), buildVersion(), settings) + case "tennis": + settings := tennis.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = tennis.NewWidget(tviewApp, redrawChan, pages, settings) case "textfile": settings := textfile.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = textfile.NewWidget(tviewApp, redrawChan, pages, settings) diff --git a/modules/tennis/client.go b/modules/tennis/client.go new file mode 100644 index 000000000..c8da78aeb --- /dev/null +++ b/modules/tennis/client.go @@ -0,0 +1,101 @@ +package tennis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +const ( + defaultBaseURL = "https://api.livetennisapi.com/api/public/v1" + + // FreeKeyURL is where users can sign up for a free API key. + FreeKeyURL = "https://livetennisapi.com/subscribe/free" +) + +// Sentinel errors so the widget can render specific help text per failure mode. +var ( + errUnauthorized = errors.New("unauthorized (401): invalid or missing API key") + errRateLimited = errors.New("rate limited (429): too many requests") +) + +// Client fetches matches from the Live Tennis API. +type Client struct { + apiKey string + httpClient *http.Client + baseURL string +} + +// NewClient creates a Client. Pass nil for httpClient to use http.DefaultClient. +// baseURL overrides the API endpoint (useful for testing); pass "" for the default. +func NewClient(apiKey string, httpClient *http.Client, baseURL string) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + if baseURL == "" { + baseURL = defaultBaseURL + } + return &Client{apiKey: apiKey, httpClient: httpClient, baseURL: baseURL} +} + +// FetchMatches retrieves matches filtered by status (live|upcoming), +// tour (optional, e.g. atp/wta) and limit (0 = API default). +func (c *Client) FetchMatches(ctx context.Context, status, tour string, limit int) ([]Match, error) { + u, err := url.Parse(c.baseURL + "/matches") + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + + q := u.Query() + if status != "" { + q.Set("status", status) + } + if tour != "" { + q.Set("tour", tour) + } + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("x-api-key", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusOK: + // fall through to parsing + case http.StatusUnauthorized: + return nil, errUnauthorized + case http.StatusTooManyRequests: + return nil, errRateLimited + default: + return nil, fmt.Errorf("unexpected status %d from Live Tennis API", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var envelope matchesResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("parsing Live Tennis API response: %w", err) + } + + return envelope.Data, nil +} diff --git a/modules/tennis/client_test.go b/modules/tennis/client_test.go new file mode 100644 index 000000000..3f7d0f467 --- /dev/null +++ b/modules/tennis/client_test.go @@ -0,0 +1,199 @@ +package tennis + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +const liveFixture = `{ + "data": [ + { + "tournament": "Tampere", + "round": "QF", + "players": { + "p1": {"name": "Sinner", "ranking": 1}, + "p2": {"name": "Alcaraz", "ranking": 2} + }, + "score": { + "sets": [1, 1], + "games": [[6, 4, 2], [3, 6, 1]], + "points": ["40", "AD"], + "server": 1, + "is_tiebreak": false + }, + "scheduled_time": "2026-07-24T15:00:00Z", + "winner": null + }, + { + "tournament": "Umag", + "round": "R16", + "players": { + "p1": {"name": "Djokovic", "ranking": 7}, + "p2": {"name": "Musetti", "ranking": 10} + }, + "score": null, + "scheduled_time": "2026-07-24T18:30:00Z", + "winner": null + } + ], + "meta": {"count": 2} +}` + +func TestFetchMatches_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Errorf("expected x-api-key header 'test-key', got %q", got) + } + if got := r.URL.Query().Get("status"); got != "live" { + t.Errorf("expected status=live, got %q", got) + } + if got := r.URL.Query().Get("tour"); got != "atp" { + t.Errorf("expected tour=atp, got %q", got) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Errorf("expected limit=5, got %q", got) + } + if r.URL.Path != "/matches" { + t.Errorf("expected path /matches, got %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(liveFixture)) + })) + defer srv.Close() + + c := NewClient("test-key", srv.Client(), srv.URL) + matches, err := c.FetchMatches(context.Background(), "live", "atp", 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } + + m := matches[0] + if m.Tournament != "Tampere" || m.Round != "QF" { + t.Errorf("unexpected tournament/round: %q %q", m.Tournament, m.Round) + } + if m.Players.P1.Name != "Sinner" || m.Players.P1.Ranking != 1 { + t.Errorf("unexpected p1: %+v", m.Players.P1) + } + if m.Score == nil { + t.Fatal("expected non-nil score for live match") + } + if m.Score.Server != 1 { + t.Errorf("expected server=1, got %d", m.Score.Server) + } + if len(m.Score.Games) != 2 || len(m.Score.Games[0]) != 3 || m.Score.Games[0][0] != 6 { + t.Errorf("unexpected games: %+v", m.Score.Games) + } + if len(m.Score.Points) != 2 || m.Score.Points[1] != "AD" { + t.Errorf("unexpected points: %+v", m.Score.Points) + } + if m.Winner != 0 { + t.Errorf("expected winner=0 for null winner, got %d", m.Winner) + } + + if matches[1].Score != nil { + t.Error("expected nil score for upcoming match") + } +} + +func TestFetchMatches_OmitsEmptyParams(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if _, ok := q["tour"]; ok { + t.Error("expected no tour param") + } + if _, ok := q["limit"]; ok { + t.Error("expected no limit param") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data": [], "meta": {}}`)) + })) + defer srv.Close() + + c := NewClient("k", srv.Client(), srv.URL) + matches, err := c.FetchMatches(context.Background(), "live", "", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(matches) != 0 { + t.Fatalf("expected 0 matches, got %d", len(matches)) + } +} + +func TestFetchMatches_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := NewClient("bad-key", srv.Client(), srv.URL) + _, err := c.FetchMatches(context.Background(), "live", "", 0) + if !errors.Is(err, errUnauthorized) { + t.Fatalf("expected errUnauthorized, got %v", err) + } +} + +func TestFetchMatches_RateLimited(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + c := NewClient("k", srv.Client(), srv.URL) + _, err := c.FetchMatches(context.Background(), "live", "", 0) + if !errors.Is(err, errRateLimited) { + t.Fatalf("expected errRateLimited, got %v", err) + } +} + +func TestFetchMatches_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := NewClient("k", srv.Client(), srv.URL) + _, err := c.FetchMatches(context.Background(), "live", "", 0) + if err == nil { + t.Fatal("expected error for 500 status") + } + if errors.Is(err, errUnauthorized) || errors.Is(err, errRateLimited) { + t.Fatalf("expected generic error, got %v", err) + } +} + +func TestFetchMatches_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + + c := NewClient("k", srv.Client(), srv.URL) + _, err := c.FetchMatches(context.Background(), "live", "", 0) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestFetchMatches_RequestError(t *testing.T) { + c := NewClient("k", nil, "http://127.0.0.1:1") // port 1 should refuse + _, err := c.FetchMatches(context.Background(), "live", "", 0) + if err == nil { + t.Fatal("expected error for connection refused") + } +} + +func TestNewClient_Defaults(t *testing.T) { + c := NewClient("k", nil, "") + if c.httpClient != http.DefaultClient { + t.Error("expected http.DefaultClient when nil passed") + } + if c.baseURL != defaultBaseURL { + t.Errorf("expected default base URL %q, got %q", defaultBaseURL, c.baseURL) + } +} diff --git a/modules/tennis/display.go b/modules/tennis/display.go new file mode 100644 index 000000000..66c267885 --- /dev/null +++ b/modules/tennis/display.go @@ -0,0 +1,140 @@ +package tennis + +import ( + "errors" + "fmt" + "strings" + + "github.com/rivo/tview" +) + +// servingMarker is appended next to the player who is currently serving. +const servingMarker = "[green]*[-]" + +// missingKeyText is the setup hint shown when no API key is configured. +func missingKeyText() string { + return strings.Join([]string{ + "No Live Tennis API key configured.", + "", + "Set 'apiKey' in the tennis module config,", + "or export WTF_TENNIS_API_KEY in your environment.", + "", + fmt.Sprintf("Get a free key: %s", FreeKeyURL), + }, "\n") +} + +// errorText maps client errors to helpful display text. +func errorText(err error) string { + switch { + case errors.Is(err, errUnauthorized): + return strings.Join([]string{ + "[red]Invalid API key (401)[-]", + "", + "The Live Tennis API rejected the configured key.", + fmt.Sprintf("Check 'apiKey' / WTF_TENNIS_API_KEY, or get a free key: %s", FreeKeyURL), + }, "\n") + case errors.Is(err, errRateLimited): + return strings.Join([]string{ + "[yellow]Rate limited (429)[-]", + "", + "Too many requests to the Live Tennis API.", + "Increase this module's refreshInterval and try again.", + }, "\n") + default: + return tview.Escape(err.Error()) + } +} + +// renderMatchLine renders a single match as one line, e.g. +// +// Sinner (1) 6-3 4-6 2-1[green]*[-] (40-AD) vs Alcaraz (2) • Tampere QF +func renderMatchLine(match Match) string { + p1 := formatPlayer(match.Players.P1, match.Winner == 1) + p2 := formatPlayer(match.Players.P2, match.Winner == 2) + + live := match.Score != nil && match.Winner == 0 + + parts := []string{p1} + + if match.Score != nil { + score := formatGames(match.Score) + if live && match.Score.Server == 1 { + score += servingMarker + } + if points := formatPoints(match.Score); live && points != "" { + score = strings.TrimSpace(score + " " + points) + } + if score != "" { + parts = append(parts, score) + } + } + + parts = append(parts, "vs") + + if live && match.Score.Server == 2 { + parts = append(parts, p2+servingMarker) + } else { + parts = append(parts, p2) + } + + if location := formatLocation(match); location != "" { + parts = append(parts, "•", location) + } + + if match.Score == nil && match.ScheduledTime != "" { + parts = append(parts, "•", "🕙 "+tview.Escape(strings.Replace(match.ScheduledTime, "T", " ", 1))) + } + + return strings.Join(parts, " ") +} + +// formatPlayer renders "Name (ranking)", bolding the winner. +func formatPlayer(player Player, winner bool) string { + name := tview.Escape(player.Name) + if name == "" { + name = "TBD" + } + if player.Ranking > 0 { + name = fmt.Sprintf("%s (%d)", name, player.Ranking) + } + if winner { + name = fmt.Sprintf("[::b]%s[::-]", name) + } + return name +} + +// formatGames renders per-set games, e.g. "6-3 4-6 2-1". When the API does not +// supply per-set games it falls back to the set counts, e.g. "2-1 sets". +func formatGames(score *Score) string { + var parts []string + + if len(score.Games) == 2 { + count := min(len(score.Games[0]), len(score.Games[1])) + for i := 0; i < count; i++ { + parts = append(parts, fmt.Sprintf("%d-%d", score.Games[0][i], score.Games[1][i])) + } + } + + if len(parts) == 0 && len(score.Sets) == 2 { + return fmt.Sprintf("%d-%d sets", score.Sets[0], score.Sets[1]) + } + + return strings.Join(parts, " ") +} + +// formatPoints renders the current-game points, e.g. "(40-AD)" or "(TB 5-3)". +func formatPoints(score *Score) string { + if len(score.Points) != 2 || score.Points[0] == "" || score.Points[1] == "" { + return "" + } + points := fmt.Sprintf("%s-%s", tview.Escape(score.Points[0]), tview.Escape(score.Points[1])) + if score.IsTiebreak { + points = "TB " + points + } + return fmt.Sprintf("(%s)", points) +} + +// formatLocation renders "Tournament Round", e.g. "Tampere QF". +func formatLocation(match Match) string { + return tview.Escape(strings.TrimSpace(strings.Join([]string{match.Tournament, match.Round}, " "))) +} diff --git a/modules/tennis/display_test.go b/modules/tennis/display_test.go new file mode 100644 index 000000000..f564a36f9 --- /dev/null +++ b/modules/tennis/display_test.go @@ -0,0 +1,208 @@ +package tennis + +import ( + "errors" + "strings" + "testing" +) + +func TestRenderMatchLine(t *testing.T) { + tests := []struct { + name string + match Match + want string + }{ + { + name: "live, player one serving", + match: Match{ + Tournament: "Tampere", + Round: "QF", + Players: Players{ + P1: Player{Name: "Sinner", Ranking: 1}, + P2: Player{Name: "Alcaraz", Ranking: 2}, + }, + Score: &Score{ + Sets: []int{1, 1}, + Games: [][]int{{6, 4, 2}, {3, 6, 1}}, + Points: []string{"40", "AD"}, + Server: 1, + }, + }, + want: "Sinner (1) 6-3 4-6 2-1[green]*[-] (40-AD) vs Alcaraz (2) • Tampere QF", + }, + { + name: "live, player two serving", + match: Match{ + Tournament: "Tampere", + Round: "QF", + Players: Players{ + P1: Player{Name: "Sinner", Ranking: 1}, + P2: Player{Name: "Alcaraz", Ranking: 2}, + }, + Score: &Score{ + Sets: []int{1, 0}, + Games: [][]int{{6, 2}, {3, 2}}, + Points: []string{"15", "30"}, + Server: 2, + }, + }, + want: "Sinner (1) 6-3 2-2 (15-30) vs Alcaraz (2)[green]*[-] • Tampere QF", + }, + { + name: "completed bolds the winner and drops the serving marker", + match: Match{ + Tournament: "Wimbledon", + Round: "F", + Players: Players{ + P1: Player{Name: "Sinner", Ranking: 1}, + P2: Player{Name: "Alcaraz", Ranking: 2}, + }, + Score: &Score{ + Sets: []int{1, 3}, + Games: [][]int{{6, 4, 4, 4}, {4, 6, 6, 6}}, + // Server may still be present in completed payloads + Server: 1, + }, + Winner: 2, + }, + want: "Sinner (1) 6-4 4-6 4-6 4-6 vs [::b]Alcaraz (2)[::-] • Wimbledon F", + }, + { + name: "upcoming has no score and shows the scheduled time", + match: Match{ + Tournament: "Umag", + Round: "R16", + Players: Players{ + P1: Player{Name: "Djokovic", Ranking: 7}, + P2: Player{Name: "Musetti", Ranking: 10}, + }, + ScheduledTime: "2026-07-24T18:30:00Z", + }, + want: "Djokovic (7) vs Musetti (10) • Umag R16 • 🕙 2026-07-24 18:30:00Z", + }, + { + name: "tiebreak points are labelled", + match: Match{ + Tournament: "Tampere", + Round: "SF", + Players: Players{ + P1: Player{Name: "Rune"}, + P2: Player{Name: "Fils"}, + }, + Score: &Score{ + Games: [][]int{{6}, {6}}, + Points: []string{"5", "3"}, + Server: 1, + IsTiebreak: true, + }, + }, + want: "Rune 6-6[green]*[-] (TB 5-3) vs Fils • Tampere SF", + }, + { + name: "missing players fall back to TBD", + match: Match{ + Tournament: "Umag", + Round: "QF", + }, + want: "TBD vs TBD • Umag QF", + }, + { + name: "square brackets in API data are escaped", + match: Match{ + Tournament: "Cup [red]", + Players: Players{ + P1: Player{Name: "A [blue] B"}, + P2: Player{Name: "C"}, + }, + }, + want: "A [blue[] B vs C • Cup [red[]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := renderMatchLine(tt.match); got != tt.want { + t.Errorf("\n got %q\nwant %q", got, tt.want) + } + }) + } +} + +func TestFormatPlayer(t *testing.T) { + tests := []struct { + name string + player Player + winner bool + want string + }{ + {"ranked", Player{Name: "Sinner", Ranking: 1}, false, "Sinner (1)"}, + {"unranked", Player{Name: "Qualifier"}, false, "Qualifier"}, + {"empty name", Player{}, false, "TBD"}, + {"winner bold", Player{Name: "Alcaraz", Ranking: 2}, true, "[::b]Alcaraz (2)[::-]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatPlayer(tt.player, tt.winner); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatGames(t *testing.T) { + tests := []struct { + name string + score *Score + want string + }{ + {"per-set games", &Score{Games: [][]int{{6, 4}, {3, 6}}}, "6-3 4-6"}, + {"sets fallback when no games", &Score{Sets: []int{2, 1}}, "2-1 sets"}, + {"sets fallback when games empty", &Score{Games: [][]int{{}, {}}, Sets: []int{0, 0}}, "0-0 sets"}, + {"uneven game arrays do not panic", &Score{Games: [][]int{{6, 4, 2}, {3, 6}}}, "6-3 4-6"}, + {"malformed games array", &Score{Games: [][]int{{6, 4}}}, ""}, + {"nothing at all", &Score{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatGames(tt.score); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatPoints(t *testing.T) { + tests := []struct { + name string + score *Score + want string + }{ + {"regular game", &Score{Points: []string{"40", "AD"}}, "(40-AD)"}, + {"tiebreak", &Score{Points: []string{"5", "3"}, IsTiebreak: true}, "(TB 5-3)"}, + {"no points", &Score{}, ""}, + {"partial points", &Score{Points: []string{"40", ""}}, ""}, + {"wrong arity", &Score{Points: []string{"40"}}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatPoints(tt.score); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestErrorText(t *testing.T) { + if got := errorText(errUnauthorized); !strings.Contains(got, "401") { + t.Errorf("expected 401 hint, got %q", got) + } + if got := errorText(errRateLimited); !strings.Contains(got, "429") { + t.Errorf("expected 429 hint, got %q", got) + } + if got := errorText(errors.New("boom")); got != "boom" { + t.Errorf("expected raw error text, got %q", got) + } +} diff --git a/modules/tennis/keyboard.go b/modules/tennis/keyboard.go new file mode 100644 index 000000000..66f51a646 --- /dev/null +++ b/modules/tennis/keyboard.go @@ -0,0 +1,50 @@ +package tennis + +import "github.com/gdamore/tcell/v2" + +// statusCycle is the order the 'l'/'h' keys move through the API's match +// statuses. The API's completed-match listing requires a paid plan, so it is +// deliberately not in the cycle: every state here works on a free key. +var statusCycle = []string{"live", "upcoming"} + +func (widget *Widget) initializeKeyboardControls() { + widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) + widget.InitializeRefreshKeyboardControl(widget.Refresh) + + widget.SetKeyboardChar("l", widget.nextStatus, "Show the next match status") + widget.SetKeyboardChar("h", widget.prevStatus, "Show the previous match status") + + widget.SetKeyboardKey(tcell.KeyRight, widget.nextStatus, "Show the next match status") + widget.SetKeyboardKey(tcell.KeyLeft, widget.prevStatus, "Show the previous match status") +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) nextStatus() { + widget.settings.status = shiftStatus(widget.settings.status, 1) + widget.Refresh() +} + +func (widget *Widget) prevStatus() { + widget.settings.status = shiftStatus(widget.settings.status, -1) + widget.Refresh() +} + +// shiftStatus returns the status `offset` places away from `current` in +// statusCycle, wrapping at both ends. +func shiftStatus(current string, offset int) string { + idx := 0 + for i, status := range statusCycle { + if status == current { + idx = i + break + } + } + + next := (idx + offset) % len(statusCycle) + if next < 0 { + next += len(statusCycle) + } + + return statusCycle[next] +} diff --git a/modules/tennis/keyboard_test.go b/modules/tennis/keyboard_test.go new file mode 100644 index 000000000..30e6303f7 --- /dev/null +++ b/modules/tennis/keyboard_test.go @@ -0,0 +1,58 @@ +package tennis + +import ( + "net/http" + "testing" +) + +func TestShiftStatus(t *testing.T) { + tests := []struct { + name string + current string + offset int + want string + }{ + {"forward", "live", 1, "upcoming"}, + {"forward wraps", "upcoming", 1, "live"}, + {"backward", "upcoming", -1, "live"}, + {"backward wraps", "live", -1, "upcoming"}, + {"unknown status starts at the beginning", "bogus", 1, "upcoming"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shiftStatus(tt.current, tt.offset); got != tt.want { + t.Errorf("shiftStatus(%q, %d) = %q, want %q", tt.current, tt.offset, got, tt.want) + } + }) + } +} + +func TestStatusKeysRequeryTheAPI(t *testing.T) { + var seen []string + + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.URL.Query().Get("status")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data": []}`)) + }) + defer srv.Close() + + widget.nextStatus() + widget.nextStatus() + widget.prevStatus() + + want := []string{"upcoming", "live", "upcoming"} + if len(seen) != len(want) { + t.Fatalf("expected %d requests, got %d (%v)", len(want), len(seen), seen) + } + for i, status := range want { + if seen[i] != status { + t.Errorf("request %d: got status %q, want %q", i, seen[i], status) + } + } + + if widget.settings.status != "upcoming" { + t.Errorf("expected settings.status 'upcoming', got %q", widget.settings.status) + } +} diff --git a/modules/tennis/settings.go b/modules/tennis/settings.go new file mode 100644 index 000000000..f33b35cfd --- /dev/null +++ b/modules/tennis/settings.go @@ -0,0 +1,63 @@ +package tennis + +import ( + "os" + + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/utils" +) + +const ( + defaultFocusable = true + defaultTitle = "Tennis" + + defaultStatus = "live" + defaultMatchLimit = 10 +) + +// Settings defines the configuration options for this module +type Settings struct { + *cfg.Common + + apiKey string `help:"Your Live Tennis API key. A free key (1,000 requests/day) is available at https://livetennisapi.com/subscribe/free." values:"A valid Live Tennis API key"` + baseURL string `help:"The base URL of the Live Tennis API." values:"A URL" optional:"true" default:"https://api.livetennisapi.com/api/public/v1"` + tour string `help:"Restrict matches to a single tour." values:"atp, wta, or empty for all tours" optional:"true"` + status string `help:"Which matches to display." values:"live or upcoming" optional:"true" default:"live"` + matchLimit int `help:"The maximum number of matches to display." values:"A positive integer" optional:"true" default:"10"` +} + +// NewSettingsFromYAML creates and returns an instance of Settings with configuration options populated +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + settings := Settings{ + Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), + + apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_TENNIS_API_KEY"))), + baseURL: ymlConfig.UString("baseURL", defaultBaseURL), + tour: ymlConfig.UString("tour", ""), + status: normalizeStatus(ymlConfig.UString("status", defaultStatus)), + matchLimit: ymlConfig.UInt("matchLimit", defaultMatchLimit), + } + + cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() + + settings.SetDocumentationPath("sports/tennis") + + return &settings +} + +// normalizeStatus clamps the configured status filter to one the API accepts, +// falling back to the default rather than sending a bad request. +func normalizeStatus(status string) string { + for _, valid := range statusCycle { + if status == valid { + return status + } + } + + return defaultStatus +} + +func (widget *Widget) ConfigText() string { + return utils.HelpFromInterface(Settings{}) +} diff --git a/modules/tennis/settings_test.go b/modules/tennis/settings_test.go new file mode 100644 index 000000000..4ee9c5b85 --- /dev/null +++ b/modules/tennis/settings_test.go @@ -0,0 +1,170 @@ +package tennis + +import ( + "testing" + + "github.com/olebedev/config" +) + +const globalYAML = ` +wtf: + colors: + border: + focusable: "darkslateblue" + focused: "orange" + normal: "gray" +` + +func parseConfigs(t *testing.T, moduleYAML string) (*config.Config, *config.Config) { + t.Helper() + + moduleConfig, err := config.ParseYaml(moduleYAML) + if err != nil { + t.Fatalf("failed to parse module yaml: %v", err) + } + + globalConfig, err := config.ParseYaml(globalYAML) + if err != nil { + t.Fatalf("failed to parse global yaml: %v", err) + } + + return moduleConfig, globalConfig +} + +func TestNewSettingsFromYAML(t *testing.T) { + t.Setenv("WTF_TENNIS_API_KEY", "") + + moduleConfig, globalConfig := parseConfigs(t, ` +apiKey: "yaml-key" +baseURL: "https://tennis.example.test/v1" +tour: "wta" +status: "upcoming" +matchLimit: 3 +enabled: true +refreshInterval: 60s +position: + top: 0 + left: 0 + height: 1 + width: 1 +`) + + settings := NewSettingsFromYAML("tennis", moduleConfig, globalConfig) + + if settings.apiKey != "yaml-key" { + t.Errorf("expected apiKey='yaml-key', got %q", settings.apiKey) + } + if settings.tour != "wta" { + t.Errorf("expected tour='wta', got %q", settings.tour) + } + if settings.status != "upcoming" { + t.Errorf("expected status='upcoming', got %q", settings.status) + } + if settings.baseURL != "https://tennis.example.test/v1" { + t.Errorf("expected overridden baseURL, got %q", settings.baseURL) + } + if settings.matchLimit != 3 { + t.Errorf("expected matchLimit=3, got %d", settings.matchLimit) + } + if settings.RefreshInterval.Seconds() != 60 { + t.Errorf("expected refreshInterval=60s, got %v", settings.RefreshInterval) + } + if settings.DocPath != "sports/tennis" { + t.Errorf("expected DocPath='sports/tennis', got %q", settings.DocPath) + } +} + +func TestNewSettingsFromYAML_Defaults(t *testing.T) { + t.Setenv("WTF_TENNIS_API_KEY", "") + + moduleConfig, globalConfig := parseConfigs(t, ` +apiKey: "yaml-key" +position: + top: 0 + left: 0 + height: 1 + width: 1 +`) + + settings := NewSettingsFromYAML("tennis", moduleConfig, globalConfig) + + if settings.tour != "" { + t.Errorf("expected empty default tour, got %q", settings.tour) + } + if settings.baseURL != defaultBaseURL { + t.Errorf("expected default baseURL %q, got %q", defaultBaseURL, settings.baseURL) + } + if settings.status != defaultStatus { + t.Errorf("expected default status %q, got %q", defaultStatus, settings.status) + } + if settings.matchLimit != defaultMatchLimit { + t.Errorf("expected default matchLimit=%d, got %d", defaultMatchLimit, settings.matchLimit) + } + if settings.Title != defaultTitle { + t.Errorf("expected default title %q, got %q", defaultTitle, settings.Title) + } +} + +func TestNewSettingsFromYAML_APIKeySources(t *testing.T) { + tests := []struct { + name string + envValue string + moduleYAML string + want string + }{ + { + name: "environment variable", + envValue: "env-key", + moduleYAML: "enabled: true\n", + want: "env-key", + }, + { + name: "yaml overrides the environment", + envValue: "env-key", + moduleYAML: "apiKey: \"yaml-key\"\n", + want: "yaml-key", + }, + { + name: "lowercase apikey is accepted", + envValue: "env-key", + moduleYAML: "apikey: \"lower-key\"\n", + want: "lower-key", + }, + { + name: "unset everywhere", + envValue: "", + moduleYAML: "enabled: true\n", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("WTF_TENNIS_API_KEY", tt.envValue) + + moduleConfig, globalConfig := parseConfigs(t, tt.moduleYAML) + settings := NewSettingsFromYAML("tennis", moduleConfig, globalConfig) + + if settings.apiKey != tt.want { + t.Errorf("got apiKey %q, want %q", settings.apiKey, tt.want) + } + }) + } +} + +func TestNormalizeStatus(t *testing.T) { + tests := []struct{ in, want string }{ + {"live", "live"}, + {"upcoming", "upcoming"}, + {"completed", "live"}, // completed requires a paid plan; clamp to the free-tier surface + {"bogus", "live"}, + {"LIVE", "live"}, + {"", "live"}, + } + + for _, tt := range tests { + if got := normalizeStatus(tt.in); got != tt.want { + t.Errorf("normalizeStatus(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/modules/tennis/types.go b/modules/tennis/types.go new file mode 100644 index 000000000..cd04144b1 --- /dev/null +++ b/modules/tennis/types.go @@ -0,0 +1,48 @@ +package tennis + +// Player represents one of the two players in a match. +type Player struct { + Name string `json:"name"` + Ranking int `json:"ranking"` +} + +// Players holds both players of a match. +type Players struct { + P1 Player `json:"p1"` + P2 Player `json:"p2"` +} + +// Score represents the live/final score of a match. It is nullable in the +// API payload (e.g. for matches that have not started yet). +type Score struct { + // Sets won by each player: [p1Sets, p2Sets] + Sets []int `json:"sets"` + + // Games per set for each player: [[p1Set1, p1Set2, ...], [p2Set1, p2Set2, ...]] + Games [][]int `json:"games"` + + // Current game points, e.g. ["40", "AD"]. Empty when not applicable. + Points []string `json:"points"` + + // Server is 1 or 2 (which player is serving); 0 when unknown. + Server int `json:"server"` + + IsTiebreak bool `json:"is_tiebreak"` +} + +// Match represents a single tennis match from the Live Tennis API. +type Match struct { + Tournament string `json:"tournament"` + Round string `json:"round"` + Players Players `json:"players"` + Score *Score `json:"score"` + ScheduledTime string `json:"scheduled_time"` + + // Winner is 1 or 2 for completed matches, 0 otherwise. + Winner int `json:"winner"` +} + +// matchesResponse is the envelope returned by GET /matches. +type matchesResponse struct { + Data []Match `json:"data"` +} diff --git a/modules/tennis/widget.go b/modules/tennis/widget.go new file mode 100644 index 000000000..11faac231 --- /dev/null +++ b/modules/tennis/widget.go @@ -0,0 +1,108 @@ +package tennis + +import ( + "context" + "fmt" + "strings" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/view" +) + +// Widget displays tennis matches from the Live Tennis API +type Widget struct { + view.TextWidget + + client *Client + settings *Settings + matches []Match + err error +} + +// NewWidget creates and returns an instance of Widget +func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { + widget := Widget{ + TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), + + client: NewClient(settings.apiKey, nil, settings.baseURL), + settings: settings, + } + + widget.initializeKeyboardControls() + + widget.View.SetScrollable(true) + + return &widget +} + +/* -------------------- Exported Functions -------------------- */ + +// Refresh fetches the latest matches and redraws the widget +func (widget *Widget) Refresh() { + if widget.Disabled() { + return + } + + if widget.settings.apiKey == "" { + widget.matches = nil + widget.err = nil + widget.Redraw(widget.content) + return + } + + matches, err := widget.client.FetchMatches( + context.Background(), + widget.settings.status, + widget.settings.tour, + widget.settings.matchLimit, + ) + if err != nil { + widget.err = err + widget.matches = nil + } else { + widget.err = nil + if limit := widget.settings.matchLimit; limit > 0 && len(matches) > limit { + matches = matches[:limit] + } + widget.matches = matches + } + + widget.Redraw(widget.content) +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) content() (string, string, bool) { + title := widget.title() + + if widget.settings.apiKey == "" { + return title, missingKeyText(), true + } + + if widget.err != nil { + return title, errorText(widget.err), true + } + + if len(widget.matches) == 0 { + return title, fmt.Sprintf("No %s matches", widget.settings.status), false + } + + lines := make([]string, 0, len(widget.matches)) + for _, match := range widget.matches { + lines = append(lines, renderMatchLine(match)) + } + + return title, strings.Join(lines, "\n"), false +} + +// title renders the widget title. It deliberately avoids square brackets: +// tview parses those as style tags in titles and silently swallows them. +func (widget *Widget) title() string { + title := widget.CommonSettings().Title + + if widget.settings.tour != "" { + title = fmt.Sprintf("%s %s", title, strings.ToUpper(widget.settings.tour)) + } + + return fmt.Sprintf("%s (%s)", title, widget.settings.status) +} diff --git a/modules/tennis/widget_test.go b/modules/tennis/widget_test.go new file mode 100644 index 000000000..36239e3d4 --- /dev/null +++ b/modules/tennis/widget_test.go @@ -0,0 +1,223 @@ +package tennis + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/cfg" +) + +// createTestWidget builds a widget wired to an httptest server. No real API +// key is ever used. +func createTestWidget(apiKey string, handler http.HandlerFunc) (*Widget, *httptest.Server) { + srv := httptest.NewServer(handler) + + tviewApp := tview.NewApplication() + redrawChan := make(chan bool, 1) + + settings := &Settings{ + Common: &cfg.Common{ + Title: "Tennis", + Enabled: true, + }, + apiKey: apiKey, + status: defaultStatus, + matchLimit: defaultMatchLimit, + } + + widget := NewWidget(tviewApp, redrawChan, nil, settings) + widget.client = NewClient(apiKey, srv.Client(), srv.URL) + + return widget, srv +} + +func TestContent_NoAPIKey(t *testing.T) { + widget, srv := createTestWidget("", func(w http.ResponseWriter, r *http.Request) { + t.Error("server should not be called without an API key") + }) + defer srv.Close() + + widget.Refresh() + + _, body, wrap := widget.content() + if !wrap { + t.Error("expected wrap=true for setup hint") + } + if !strings.Contains(body, "WTF_TENNIS_API_KEY") { + t.Errorf("expected env var hint in body, got %q", body) + } + if !strings.Contains(body, FreeKeyURL) { + t.Errorf("expected free key URL in body, got %q", body) + } +} + +func TestContent_Unauthorized(t *testing.T) { + widget, srv := createTestWidget("bad-key", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }) + defer srv.Close() + + widget.Refresh() + + _, body, wrap := widget.content() + if !wrap { + t.Error("expected wrap=true for error content") + } + if !strings.Contains(body, "401") { + t.Errorf("expected 401 in body, got %q", body) + } + if !strings.Contains(body, FreeKeyURL) { + t.Errorf("expected free key URL in body, got %q", body) + } +} + +func TestContent_RateLimited(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }) + defer srv.Close() + + widget.Refresh() + + _, body, _ := widget.content() + if !strings.Contains(body, "429") { + t.Errorf("expected 429 in body, got %q", body) + } + if !strings.Contains(body, "refreshInterval") { + t.Errorf("expected refreshInterval hint in body, got %q", body) + } +} + +func TestContent_Empty(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data": [], "meta": {}}`)) + }) + defer srv.Close() + + widget.Refresh() + + _, body, wrap := widget.content() + if wrap { + t.Error("expected wrap=false for empty state") + } + if body != "No live matches" { + t.Errorf("expected 'No live matches', got %q", body) + } +} + +func TestContent_LiveMatches(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(liveFixture)) + }) + defer srv.Close() + + widget.Refresh() + + if widget.err != nil { + t.Fatalf("unexpected error: %v", widget.err) + } + + title, body, wrap := widget.content() + if wrap { + t.Error("expected wrap=false for match content") + } + if title != "Tennis (live)" { + t.Errorf("unexpected title %q", title) + } + + lines := strings.Split(body, "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), body) + } + + // Live match: score, green serving marker on p1's side, points, location + want := "Sinner (1) 6-3 4-6 2-1[green]*[-] (40-AD) vs Alcaraz (2) • Tampere QF" + if lines[0] != want { + t.Errorf("live line mismatch:\n got %q\nwant %q", lines[0], want) + } + + // Upcoming match (null score): no score block, scheduled time shown + if !strings.Contains(lines[1], "Djokovic (7) vs Musetti (10)") { + t.Errorf("expected upcoming players line, got %q", lines[1]) + } + if !strings.Contains(lines[1], "2026-07-24 18:30:00Z") { + t.Errorf("expected scheduled time, got %q", lines[1]) + } +} + +func TestContent_MatchLimit(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(liveFixture)) + }) + defer srv.Close() + + widget.settings.matchLimit = 1 + widget.Refresh() + + if len(widget.matches) != 1 { + t.Fatalf("expected 1 match after limit, got %d", len(widget.matches)) + } +} + +func TestRefresh_Disabled(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) { + t.Error("server should not be called when widget is disabled") + }) + defer srv.Close() + + widget.Disable() + widget.Refresh() + + if widget.matches != nil { + t.Error("expected nil matches when disabled") + } +} + +func TestWidgetTitle(t *testing.T) { + tests := []struct { + name string + tour string + status string + want string + }{ + {"no tour", "", "live", "Tennis (live)"}, + {"with tour", "wta", "upcoming", "Tennis WTA (upcoming)"}, + {"tour and status", "atp", "live", "Tennis ATP (live)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) {}) + defer srv.Close() + + widget.settings.tour = tt.tour + widget.settings.status = tt.status + + got := widget.title() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + // tview parses square brackets in titles as style tags and + // silently swallows them, so the title must not contain any. + if strings.ContainsAny(got, "[]") { + t.Errorf("title must not contain square brackets: %q", got) + } + }) + } +} + +func TestConfigText(t *testing.T) { + widget, srv := createTestWidget("key", func(w http.ResponseWriter, r *http.Request) {}) + defer srv.Close() + + if widget.ConfigText() == "" { + t.Error("expected non-empty config text") + } +}