package card import ( "strings" "testing" "time" "github.com/tiennm99/ghglance/internal/github" "github.com/tiennm99/ghglance/internal/theme" ) // TestPadToWeekGridRotatesByWeekStart checks that the leading blank pad // matches the configured start day — with weekStart=Monday, a Thursday-first // series needs 3 leading zero-date slots so row 0 stays Monday. func TestPadToWeekGridRotatesByWeekStart(t *testing.T) { // Thursday 2025-01-02 — int(Weekday)=4. thu := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) days := []github.DailyContribution{{Date: thu, Count: 1}} cases := []struct { name string weekStart time.Weekday wantOffset int }{ {"Sunday start", time.Sunday, 4}, // Thu is row 4 of Sun..Sat {"Monday start", time.Monday, 3}, // Thu is row 3 of Mon..Sun {"Thursday start", time.Thursday, 0}, {"Friday start", time.Friday, 6}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { grid := padToWeekGrid(days, c.weekStart) // The first real day should land at wantOffset; slots before it // carry a zero Date. if grid[c.wantOffset].Date != thu { t.Fatalf("weekStart=%v: expected Thu at offset %d, got %v", c.weekStart, c.wantOffset, grid[c.wantOffset].Date) } for i := 0; i < c.wantOffset; i++ { if !grid[i].Date.IsZero() { t.Errorf("weekStart=%v: slot %d should be blank, got %v", c.weekStart, i, grid[i].Date) } } if len(grid)%7 != 0 { t.Errorf("weekStart=%v: grid len %d not a multiple of 7", c.weekStart, len(grid)) } }) } } // TestRenderWeekdayRespectsWeekStart asserts bar order follows weekStart. // Saturday (6) has the peak — with Monday start, Saturday lands at position 5, // which should carry the accent fill. func TestRenderWeekdayRespectsWeekStart(t *testing.T) { th, _ := theme.Lookup("dracula") var data [7]int data[time.Saturday] = 99 // peak sunSVG := string(renderWeekday("t", data, time.Sunday, th)) monSVG := string(renderWeekday("t", data, time.Monday, th)) // Label positions: with Sunday start, "Sun" is the first label; with // Monday start, "Mon" is the first label. Check the order of labels // by scanning the LABEL occurrences in the SVG. sunOrder := extractWeekdayLabels(sunSVG) wantSun := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} if !equalStrings(sunOrder, wantSun) { t.Errorf("Sunday-start label order = %v, want %v", sunOrder, wantSun) } monOrder := extractWeekdayLabels(monSVG) wantMon := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} if !equalStrings(monOrder, wantMon) { t.Errorf("Monday-start label order = %v, want %v", monOrder, wantMon) } } // TestRenderHeatmapLabelsRespectWeekStart asserts the left-gutter weekday // labels rotate with weekStart (rows 1,3,5 get labels). func TestRenderHeatmapLabelsRespectWeekStart(t *testing.T) { th, _ := theme.Lookup("dracula") base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) var days []github.DailyContribution for i := 0; i < 30; i++ { days = append(days, github.DailyContribution{Date: base.AddDate(0, 0, i), Count: i % 3}) } cases := []struct { name string weekStart time.Weekday want []string // labels expected at rows 1, 3, 5 }{ {"Sunday start", time.Sunday, []string{"Mon", "Wed", "Fri"}}, {"Monday start", time.Monday, []string{"Tue", "Thu", "Sat"}}, {"Saturday start", time.Saturday, []string{"Sun", "Tue", "Thu"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { svg := string(renderHeatmap("t", days, c.weekStart, th)) for _, label := range c.want { // Row labels use text-anchor="end"; pick that form so we // don't collide with month labels along the top. needle := `text-anchor="end">` + label if !strings.Contains(svg, needle) { t.Errorf("weekStart=%v: missing row label %q", c.weekStart, label) } } }) } } // TestRenderHeatmapMonthLabelsSkipPaddedCells guards against the leading // zero-pad cell at week 0 leaking a bogus "Jan" label (zero time = 0001-01-01, // Day=1 ≤ 7, Month=Jan). When weekStart=Monday and data starts mid-week, the // pad cell would otherwise render an out-of-range month above the first column. func TestRenderHeatmapMonthLabelsSkipPaddedCells(t *testing.T) { th, _ := theme.Lookup("dracula") // Sunday 2025-04-27 — with weekStart=Monday this needs 6 leading zero pads, // putting the bogus "Jan" label at week 0 in the buggy version. start := time.Date(2025, 4, 27, 0, 0, 0, 0, time.UTC) var days []github.DailyContribution for i := 0; i < 200; i++ { days = append(days, github.DailyContribution{Date: start.AddDate(0, 0, i), Count: i % 5}) } svg := string(renderHeatmap("t", days, time.Monday, th)) // The data range is 2025-04-27 .. 2025-11-12 — none of which is January. // Any "Jan" label is the leaked zero-time month from the leading pad. if strings.Contains(svg, ">Jan") { t.Error("month-label loop leaked a Jan label from the leading zero-pad cell") } // Sanity: months actually in range should still appear. for _, m := range []string{"May", "Jun", "Jul"} { if !strings.Contains(svg, ">"+m+"") { t.Errorf("expected month label %q in heatmap, missing", m) } } } // extractWeekdayLabels pulls weekday 3-letter names out of … // blocks in the order they appear. Good enough for bar-chart cards that emit // exactly 7 weekday labels and no colliding strings. func extractWeekdayLabels(svg string) []string { names := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} var out []string rest := svg for { idx := -1 var pick string for _, n := range names { // Match the `>Label` trailer so we catch rendered labels // and not the `` tooltip text (which uses "—"). needle := ">" + n + "</text>" i := strings.Index(rest, needle) if i >= 0 && (idx == -1 || i < idx) { idx = i pick = n } } if idx == -1 { return out } out = append(out, pick) rest = rest[idx+len(pick)+len("</text>")+1:] } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }