package web
import (
"regexp"
"strconv"
"strings"
"testing"
)
// A dot's size is how many files its check-in touched, on a log scale.
//
// Linear would be useless: a licence re-stamp touching 400 files is not
// four hundred times the work of a one-line fix, and drawn linearly it
// would be the only thing on the chart.
func TestADotIsSizedByWhatItsCheckinTouched(t *testing.T) {
prev := 0.0
for _, tc := range []struct{ files int }{{1}, {3}, {9}, {40}} {
r := sampleRadius(tc.files)
if r <= prev {
t.Errorf("FAIL: %d files gave r=%.1f, not larger than the step below (%.1f)", tc.files, r, prev)
}
prev = r
}
// Four steps and no more: past the top step a sweep stops growing,
// so one mass rename cannot swamp the chart.
if sampleRadius(40) != sampleRadius(4000) {
t.Error("FAIL: the scale is unbounded; a 4000-file sweep draws larger than a 40-file one")
}
if got := len(map[float64]bool{
sampleRadius(1): true, sampleRadius(3): true, sampleRadius(9): true, sampleRadius(40): true,
}); got != 4 {
t.Errorf("FAIL: %d distinct sizes, want 4", got)
}
}
// A merge authored nothing — RFC 0013 keeps its filechanges out of the
// row's count — so it draws smallest. It still draws, because it
// happened.
func TestAMergeDrawsSmallestAndStillDraws(t *testing.T) {
if sampleRadius(0) != sampleRadius(1) {
t.Error("FAIL: a merge (0 authored files) is not drawn at the smallest step")
}
if sampleRadius(0) <= 0 {
t.Error("FAIL: a merge is drawn at zero radius, so it vanishes")
}
}
// Every radius is three characters wide. sampleBucket picks the day
// bucket from a measured per-mark byte cost, so a radius that sometimes
// rendered wider would quietly shift when the chart coarsens and change
// how much history fits on the page.
func TestEveryRadiusIsTheSameWidthOnTheWire(t *testing.T) {
for _, files := range []int{0, 1, 2, 4, 5, 15, 16, 400} {
if got := strconv.FormatFloat(sampleRadius(files), 'f', 1, 64); len(got) != 3 {
t.Errorf("FAIL: %d files renders r=%q, %d chars — the byte budget assumes 3", files, got, len(got))
}
}
}
// The drawn chart carries the sizes, not just the helper: a check-in
// touching many files is visibly bigger than a one-file one.
func TestTheDrawnChartVariesItsDots(t *testing.T) {
act := activityOut{
Since: "2026-09-01", Until: "2026-09-03", Window: "recent",
Authors: []activityAuthor{{Name: "dan", Checkins: 2, Hue: 120}},
Rows: []activityRow{
{Day: 0, Hour: 9, Author: 0, Files: 1, Hash: strings.Repeat("a", 40)},
{Day: 1, Hour: 9, Author: 0, Files: 30, Hash: strings.Repeat("b", 40)},
},
}
svg := coreSampleSVG("alpha", "clock", act, -1, 1)
radii := regexp.MustCompile(`r="([0-9.]+)"`).FindAllStringSubmatch(svg, -1)
seen := map[string]bool{}
for _, m := range radii {
seen[m[1]] = true
}
if len(seen) < 2 {
t.Errorf("FAIL: every dot is the same size: %v", seen)
}
// And the tooltip still says the exact number, which the size cannot.
if !strings.Contains(svg, "30 files") {
t.Error("FAIL: the tooltip no longer names the file count")
}
}
// The page says what size means. A dot whose area encodes something,
// on a page that never explains it, reads as decoration.
func TestThePageExplainsTheSizeCode(t *testing.T) {
draw := func(axis string) string {
var buf strings.Builder
err := activityPage.ExecuteTemplate(&buf, "act-view", pageData{
Repo: "alpha",
Activity: &activityView{
Axis: axis, Win: "recent",
Since: "2026-09-01", Until: "2026-09-03", Window: "recent", Bucket: 1,
},
})
if err != nil {
t.Fatal(err)
}
return buf.String()
}
if !strings.Contains(draw("clock"), "how many files that check-in touched") {
t.Error("FAIL: the clock chart does not say what a dot's size means")
}
// The tree axis says nothing, because there a mark is one file and
// size would mean nothing.
if strings.Contains(draw("tree"), "how many files that check-in touched") {
t.Error("FAIL: the size key appears on the axis it does not describe")
}
}
// Coarsened, a cell is several check-ins at once, so it is sized by what
// they touched TOGETHER — the same reading as a fine dot, at the scale
// the byte budget forced. Sizing it by the count of check-ins instead
// would encode a different quantity under the same visual code.
func TestACoarsenedCellIsSizedByTheFilesInIt(t *testing.T) {
rows := []activityRow{}
// Two check-ins of one file each land in the same bucket and hour;
// together they are a 2-file cell, not two 1-file dots.
for d := 0; d < 2; d++ {
rows = append(rows, activityRow{Day: d, Hour: 9, Author: 0, Files: 1,
Hash: strings.Repeat("a", 40)})
}
// A lone 20-file check-in elsewhere, for contrast.
rows = append(rows, activityRow{Day: 10, Hour: 9, Author: 0, Files: 20,
Hash: strings.Repeat("b", 40)})
act := activityOut{
Since: "2026-09-01", Until: "2026-09-20", Window: "recent",
Authors: []activityAuthor{{Name: "dan", Checkins: 3, Hue: 120}},
Rows: rows,
}
svg := coreSampleSVG("alpha", "clock", act, -1, 4)
if !strings.Contains(svg, "2 check-ins") {
t.Error("FAIL: the coarse cell does not say how many check-ins it holds")
}
if !strings.Contains(svg, "2 files") {
t.Error("FAIL: the coarse cell does not say how many files they touched together")
}
// The 2-file cell and the 20-file one must not be the same size.
seen := map[string]bool{}
for _, m := range regexp.MustCompile(`<circle class="dot"[^>]*r="([0-9.]+)"`).FindAllStringSubmatch(svg, -1) {
seen[m[1]] = true
}
if len(seen) < 2 {
t.Errorf("FAIL: coarse cells are all one size: %v", seen)
}
// A coarse cell is a count, never a link: it is not one check-in.
if strings.Contains(svg, `<a href="/r/alpha/checkin/`) {
t.Error("FAIL: a coarsened cell links to a check-in it is not")
}
}