package web
import (
"fmt"
"html/template"
"regexp"
"strconv"
"strings"
"testing"
)
// A branch tip is drawn as the quarry mark is: an open ring with the
// check-in dot at its centre. The ring alone only said "nothing follows
// this"; the dot says "and this is where the branch is now", which is
// the fact a reader is actually scanning the graph for.
//
// Every other check-in stays a solid node — if the two ever render the
// same, the graph stops answering "where is each branch now?" and the
// only way to find a tip is to read the branch list instead.
func TestBranchTipsCarryTheCentreDot(t *testing.T) {
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "checkin", Hash: "aaa", Date: "2026-08-18 10:00:00", Branch: "trunk", IsLeaf: true, Parents: []string{"bbb"}},
{Kind: "checkin", Hash: "bbb", Date: "2026-08-18 09:00:00", Branch: "trunk"},
})
var tip, mid string
for _, d := range days {
for _, row := range d.Rows {
switch row.Hash {
case "aaa":
tip = string(row.Head)
case "bbb":
mid = string(row.Head)
}
}
}
if tip == "" || mid == "" {
t.Fatalf("timeline dropped a row: tip=%q mid=%q", tip, mid)
}
if !strings.Contains(tip, `r="4.85"`) || !strings.Contains(tip, `fill:var(--bg)`) {
t.Errorf("the tip is not an open ring:\n%s", tip)
}
if !strings.Contains(tip, `r="1.25"`) {
t.Errorf("the tip has no centre dot, so it reads as a dead end:\n%s", tip)
}
// The ring must stay hollow: a filled ring plus a dot is just a
// slightly larger solid node and tells the reader nothing.
if strings.Count(tip, "<circle") != 2 {
t.Errorf("a tip should be exactly ring + dot, got:\n%s", tip)
}
if strings.Contains(mid, `r="1.25"`) || strings.Contains(mid, `fill:var(--bg)`) {
t.Errorf("an ordinary check-in is being drawn as a tip:\n%s", mid)
}
if strings.Count(mid, "<circle") != 1 {
t.Errorf("an ordinary check-in should be one solid node, got:\n%s", mid)
}
}
// railOf reports which rail column a row's node was drawn on, by
// reading the node dot's x back out of the head SVG.
func railOf(t *testing.T, row tlRow) int {
t.Helper()
head := string(row.Head)
i := strings.Index(head, `<circle cx="`)
if i < 0 {
t.Fatalf("row %q drew no node", row.Title)
}
rest := head[i+len(`<circle cx="`):]
x, err := strconv.Atoi(rest[:strings.Index(rest, `"`)])
if err != nil {
t.Fatal(err)
}
return (x - railW/2) / railW
}
// The mainline owns the leftmost rail whenever it is on the page, even
// when a branch holds the newest commit — otherwise a branch that
// forked yesterday takes the column a reader scans for trunk.
func TestMainlineKeepsTheLeftmostRail(t *testing.T) {
// Newest first: the branch commits, then trunk's.
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "ci", Hash: "b2", Date: "2026-08-22T16:50:00", Branch: "ticket-list", Parents: []string{"b1"}, Comment: "branch tip"},
{Kind: "ci", Hash: "b1", Date: "2026-08-22T16:46:00", Branch: "ticket-list", Parents: []string{"t1"}, Comment: "branch base"},
{Kind: "ci", Hash: "t1", Date: "2026-08-22T16:35:00", Branch: "trunk", Parents: []string{"t0"}, Comment: "trunk work"},
{Kind: "ci", Hash: "t0", Date: "2026-08-22T16:30:00", Branch: "trunk", Comment: "trunk root"},
})
var rows []tlRow
for _, d := range days {
rows = append(rows, d.Rows...)
}
if len(rows) != 4 {
t.Fatalf("rows = %d", len(rows))
}
for _, row := range rows {
got := railOf(t, row)
if row.Branch == "trunk" && got != 0 {
t.Errorf("trunk commit %q drew on rail %d, want 0", row.Title, got)
}
if row.Branch != "trunk" && got == 0 {
t.Errorf("branch commit %q took the mainline's rail", row.Title)
}
}
}
// With the mainline absent from the page entirely, nothing is reserved
// and the visible lines close up to the left edge.
func TestWithoutMainlineTheLinesCloseUp(t *testing.T) {
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "ci", Hash: "b2", Date: "2026-08-22T16:50:00", Branch: "ticket-list", Parents: []string{"b1"}, Comment: "tip"},
{Kind: "ci", Hash: "b1", Date: "2026-08-22T16:46:00", Branch: "ticket-list", Comment: "base"},
})
for _, d := range days {
for _, row := range d.Rows {
if got := railOf(t, row); got != 0 {
t.Errorf("with no mainline in view, %q drew on rail %d, want 0", row.Title, got)
}
}
}
}
// Two branch lines on one page must never be the same colour. A hash
// alone cannot promise that — `ticket-list` and `fossil-caps` hashed to
// near-identical magentas — so the palette resolves collisions among
// the branches actually in view.
func TestBranchColoursAreDistinctOnAPage(t *testing.T) {
names := []string{"ticket-list", "fossil-caps", "mirror-posture", "ticket-editor", "ticket-paging"}
var entries []timelineEntry
for i, n := range names {
entries = append(entries, timelineEntry{
Kind: "ci", Hash: fmt.Sprintf("h%d", i), Date: "2026-08-22T16:00:00",
Branch: n, Comment: n,
})
}
pal := newPalette("trunk", entries)
seen := map[string]string{}
for _, n := range names {
c := pal.stroke(n)
if other, dup := seen[c]; dup {
t.Fatalf("%q and %q share the colour %s", other, n, c)
}
seen[c] = n
}
// None of them may take the mainline's blue or read as the accent.
for _, n := range names {
if h := pal.hueOf(n); h > 193 && h < 233 {
t.Errorf("%q (hue %d) sits in the mainline's blue band", n, h)
} else if h < 38 || h > 340 {
t.Errorf("%q (hue %d) sits in the accent's orange band", n, h)
}
}
}
// A branch keeps its colour across pages when nothing contests its
// slot: the hue is hashed first and only moved on an actual collision.
func TestUncontestedBranchKeepsItsColour(t *testing.T) {
one := newPalette("trunk", []timelineEntry{
{Kind: "ci", Hash: "a", Branch: "solo", Date: "2026-08-22T16:00:00"},
})
two := newPalette("trunk", []timelineEntry{
{Kind: "ci", Hash: "a", Branch: "solo", Date: "2026-08-22T16:00:00"},
{Kind: "ci", Hash: "b", Branch: "zzz-unrelated", Date: "2026-08-22T15:00:00"},
})
if one.stroke("solo") != two.stroke("solo") {
t.Fatalf("an uncontested branch changed colour: %s vs %s",
one.stroke("solo"), two.stroke("solo"))
}
}
// Assignment cannot depend on the order commits happen to arrive in,
// or the same page would repaint itself differently after one push.
func TestColourAssignmentIsOrderIndependent(t *testing.T) {
mk := func(bs ...string) []timelineEntry {
var out []timelineEntry
for i, b := range bs {
out = append(out, timelineEntry{Kind: "ci", Hash: fmt.Sprint(i), Branch: b,
Date: "2026-08-22T16:00:00"})
}
return out
}
a := newPalette("trunk", mk("alpha", "beta", "gamma"))
b := newPalette("trunk", mk("gamma", "alpha", "beta"))
for _, n := range []string{"alpha", "beta", "gamma"} {
if a.stroke(n) != b.stroke(n) {
t.Fatalf("%q changed colour with commit order: %s vs %s", n, a.stroke(n), b.stroke(n))
}
}
}
// Nothing follows a leaf, so nothing may be drawn above it: the head
// band's upper segment is the claim "a line arrives here from above",
// and on a tip that claim is false.
func TestNothingIsDrawnAboveALeaf(t *testing.T) {
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "ci", Hash: "tip", Date: "2026-08-23T00:33:00", Branch: "wiki-read",
Parents: []string{"base"}, Comment: "tip", IsLeaf: true},
{Kind: "ci", Hash: "base", Date: "2026-08-23T00:20:00", Branch: "trunk",
Comment: "base"},
})
head := string(days[0].Rows[0].Head)
if strings.Contains(head, `y1="0"`) && strings.Contains(head, `y2="24"`) {
t.Fatalf("a line was drawn above a leaf:\n%s", head)
}
if !strings.Contains(head, `y1="24"`) {
t.Fatalf("the tip lost its line downward:\n%s", head)
}
}
// railsFromZero reports which rail columns a head band draws from its
// top edge — as a straight segment or as the start of a curve.
func railsFromZero(head string) map[int]bool {
out := map[int]bool{}
for _, m := range regexp.MustCompile(`<line x1="(\d+)" y1="0"`).FindAllStringSubmatch(head, -1) {
x, _ := strconv.Atoi(m[1])
out[x] = true
}
for _, m := range regexp.MustCompile(`<path d="M (\d+) 0`).FindAllStringSubmatch(head, -1) {
x, _ := strconv.Atoi(m[1])
out[x] = true
}
return out
}
// railsInTail reports which columns a tail band draws to its bottom
// edge. Only the stretching tl-tail layer counts: an off-page stub is
// its own layer, and its dots stop by design.
func railsInTail(tail string) map[int]bool {
tail, _, _ = strings.Cut(tail, "</svg>")
out := map[int]bool{}
for _, m := range regexp.MustCompile(`<line x1="(\d+)"`).FindAllStringSubmatch(tail, -1) {
x, _ := strconv.Atoi(m[1])
out[x] = true
}
return out
}
// Every row is drawn as two bands — a fixed head and a stretchy tail —
// so a line is continuous only if each head resumes every column its
// predecessor's tail was still drawing. Miss one and the graph shows a
// gap: the line simply stops for the height of a row and starts again.
//
// The case that broke it: a merge whose second parent is an older
// MAINLINE commit. That parent's row puts the node on rail 0 (the
// mainline always draws in its own column) while the rail that was
// heading there is rail 1. The upper segment was decided by "did a rail
// point at this node", which was rail 1 — so rail 0, still carrying the
// mainline down the page, was left undrawn above the node.
func TestRailsAreContinuousAcrossRowBoundaries(t *testing.T) {
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "ci", Hash: "m", Date: "2026-08-23T10:37:00", Branch: "trunk",
Parents: []string{"a", "z"}, Comment: "merge"},
{Kind: "ci", Hash: "a", Date: "2026-08-23T10:36:30", Branch: "trunk",
Parents: []string{"x"}, Comment: "work"},
{Kind: "ci", Hash: "z", Date: "2026-08-23T10:36:00", Branch: "trunk",
Parents: []string{"y"}, Comment: "the merged parent"},
{Kind: "ci", Hash: "x", Date: "2026-08-23T10:35:00", Branch: "trunk",
Comment: "older"},
})
var rows []tlRow
for _, d := range days {
rows = append(rows, d.Rows...)
}
if len(rows) != 4 {
t.Fatalf("rows = %d", len(rows))
}
for i := 1; i < len(rows); i++ {
prev := railsInTail(string(rows[i-1].Tail))
here := railsFromZero(string(rows[i].Head))
for x := range prev {
if !here[x] {
t.Errorf("gap at row %d (%q): the row above draws column x=%d to its bottom edge, this row does not resume it\nabove tail: %s\nthis head: %s",
i, rows[i].Title, x, rows[i-1].Tail, rows[i].Head)
}
}
}
}
// A CHUNK OF A PAGED TIMELINE DRAWS A MERGED BRANCH AS MERGED.
//
// The timeline loads in chunks (?before=), each laid out on its own.
// A branch whose landing merged it in an earlier chunk reached the next
// chunk with nothing above its tip, so every landed branch read as
// abandoned on a phone. A check-in that is not a leaf, and that no row
// of its chunk names as a parent, has its child above the chunk: its
// line comes in from the chunk's top edge, as on one long page.
func TestAChunkDrawsABranchMergedAboveIt(t *testing.T) {
all := []timelineEntry{
{Kind: "ci", Hash: "m1", Date: "2026-10-03T00:10:00", Branch: "trunk", Parents: []string{"t2", "b1"}, IsLeaf: true, Comment: "land feature"},
{Kind: "ci", Hash: "t2", HasChild: true, Date: "2026-10-03T00:05:00", Branch: "trunk", Parents: []string{"t1"}, Comment: "trunk work"},
{Kind: "ci", Hash: "b1", HasChild: true, Date: "2026-10-02T21:19:00", Branch: "feature", Parents: []string{"t1"}, Comment: "feature tip"},
{Kind: "ci", Hash: "t1", HasChild: true, Date: "2026-10-02T21:00:00", Branch: "trunk", Parents: []string{"t0"}, Comment: "fork point"},
}
above := func(days []tlDay, hash string) bool {
for _, d := range days {
for _, row := range d.Rows {
if row.Hash != hash {
continue
}
nx := railOf(t, row)*railW + railW/2
return strings.Contains(string(row.Head), fmt.Sprintf(`<line x1="%d" y1="0" x2="%d"`, nx, nx))
}
}
t.Fatalf("no row for %s", hash)
return false
}
if !above(buildTimeline("r", "trunk", all), "b1") {
t.Fatal("FAIL: on one page the merged tip has no line above it")
}
// The second chunk alone: the landing (m1) and t2 were in the first.
if !above(buildTimeline("r", "trunk", all[2:]), "b1") {
t.Fatal("FAIL: in a later chunk the merged tip draws as a dead end")
}
// A tip with no child, open or closed (a closed one is not a leaf
// either), still has nothing above it.
tip := []timelineEntry{{Kind: "ci", Hash: "x1", Date: "2026-10-02T20:00:00", Branch: "spike", Parents: []string{"t0"}, Comment: "abandoned, closed"}}
if above(buildTimeline("r", "trunk", tip), "x1") {
t.Fatal("FAIL: a leaf drew a line above it")
}
t.Log("ok: a branch merged above a chunk is drawn coming in from the top")
}
// Twelve branches on one page take twelve different colours, and
// neighbouring slots differ in lightness as well as hue, so two lines
// 22 degrees apart still read as two lines.
func TestTwelveBranchesTakeTwelveColours(t *testing.T) {
var entries []timelineEntry
for i := 0; i < 12; i++ {
n := fmt.Sprintf("branch-%02d", i)
entries = append(entries, timelineEntry{Kind: "ci", Hash: n, Date: "2026-10-04T10:00:00", Branch: n, Comment: n})
}
pal := newPalette("trunk", entries)
seen := map[string]string{}
for _, e := range entries {
c := pal.stroke(e.Branch)
if other, dup := seen[c]; dup {
t.Fatalf("FAIL: %q and %q share %s", other, e.Branch, c)
}
seen[c] = e.Branch
}
if len(branchSlots) != 12 {
t.Fatalf("FAIL: %d slots, want 12", len(branchSlots))
}
for i := 1; i < len(branchSlots); i++ {
if branchLightness(i) == branchLightness(i-1) {
t.Fatalf("FAIL: slots %d and %d share a lightness", i-1, i)
}
}
t.Log("ok: twelve branches, twelve colours")
}
// A branch made from another branch's check-in, not from trunk, runs in
// its own column down to that check-in and forks off it there; landing
// the other branch draws its own line, in its own colour, into trunk.
// Sharing one column read as one branch turning into the other.
func TestABranchMadeFromABranchForksOffInItsOwnColumn(t *testing.T) {
entries := []timelineEntry{
{Kind: "ci", Hash: "l2", Date: "2026-10-08T14:32:00", Branch: "trunk", Parents: []string{"l1", "n"}, Comment: "land note"},
{Kind: "ci", Hash: "n", Date: "2026-10-08T13:17:00", Branch: "note", Parents: []string{"p"}, Comment: "note work"},
{Kind: "ci", Hash: "l1", Date: "2026-10-08T13:14:00", Branch: "trunk", Parents: []string{"t0", "p"}, Comment: "land phone"},
{Kind: "ci", Hash: "p", Date: "2026-10-08T11:48:00", Branch: "phone", Parents: []string{"t0"}, Comment: "phone work"},
{Kind: "ci", Hash: "t0", Date: "2026-10-08T11:18:00", Branch: "trunk", Comment: "trunk"},
}
pal := newPalette("trunk", entries)
phone, note := pal.stroke("phone"), pal.stroke("note")
rows := map[string]tlRow{}
for _, d := range buildTimeline("r", "trunk", entries) {
for _, row := range d.Rows {
rows[row.Hash] = row
}
}
column := func(svg template.HTML, stroke string) string {
m := regexp.MustCompile(`<line x1="(\d+)"[^>]*stroke="` + regexp.QuoteMeta(stroke) + `"`).FindStringSubmatch(string(svg))
if m == nil {
return ""
}
return m[1]
}
land := rows["l1"]
if !strings.Contains(string(land.Head), `<path d=`) || !strings.Contains(string(land.Head), `stroke="`+phone+`"`) {
t.Error("FAIL: the merge into trunk is not in the merged branch's colour")
}
nc, pc := column(land.Tail, note), column(land.Tail, phone)
if nc == "" || pc == "" || nc == pc {
t.Errorf("FAIL: below the merge the two branches share a column (note %q, phone %q)", nc, pc)
}
if railOf(t, rows["p"]) == railOf(t, rows["n"]) {
t.Error("FAIL: the merged check-in sits in the other branch's column")
}
if !strings.Contains(string(rows["p"].Head), `stroke="`+note+`"`) {
t.Error("FAIL: the branch made from the check-in does not fork off it")
}
}
// Merging trunk's newest check-in into a branch draws the merge line in
// a column of its own, curving into trunk's check-in from the side:
// nothing is drawn in trunk's column above its tip, so trunk never reads
// as bending into the branch.
func TestTrunkStaysInItsColumnWhenMergedIntoABranch(t *testing.T) {
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "ci", Hash: "m", Date: "2026-10-08T22:52:00", Branch: "feat", Parents: []string{"f1", "t1"}, Comment: "merge trunk into feat", IsLeaf: true},
{Kind: "ci", Hash: "t1", Date: "2026-10-08T22:44:00", Branch: "trunk", Parents: []string{"t0"}, Comment: "trunk tip", HasChild: true},
{Kind: "ci", Hash: "f1", Date: "2026-10-08T22:30:00", Branch: "feat", Parents: []string{"t0"}, Comment: "feat work"},
{Kind: "ci", Hash: "t0", Date: "2026-10-08T22:00:00", Branch: "trunk", Comment: "trunk"},
})
rows := map[string]tlRow{}
for _, d := range days {
for _, row := range d.Rows {
rows[row.Hash] = row
}
}
trunkX := fmt.Sprintf(`x1="%d"`, railW/2)
if strings.Contains(string(rows["m"].Head), trunkX) || strings.Contains(string(rows["m"].Tail), trunkX) {
t.Error("FAIL: trunk's column is drawn above trunk's tip, as the merge's line")
}
if strings.Contains(string(rows["m"].Head), fmt.Sprintf(` %d `, railW/2)) {
t.Error("FAIL: the merge curves into trunk's column above its tip")
}
if railOf(t, rows["t1"]) != 0 {
t.Error("FAIL: trunk's tip left its column")
}
if !strings.Contains(string(rows["t1"].Head), `<path d=`) {
t.Error("FAIL: the merge line does not reach trunk's check-in")
}
}
// A landing whose check-in two later branches forked from: their lines
// end at it, and the branch it merged takes one of the columns they
// leave, not a column of its own beyond them.
func TestAMergeReusesAColumnFreedAtItsRow(t *testing.T) {
days := buildTimeline("r", "trunk", []timelineEntry{
{Kind: "ci", Hash: "a1", Date: "2026-10-07T14:01:00", Branch: "a", Parents: []string{"t1"}, Comment: "a", IsLeaf: true},
{Kind: "ci", Hash: "c1", Date: "2026-10-07T14:00:00", Branch: "c", Parents: []string{"t1"}, Comment: "c", IsLeaf: true},
{Kind: "ci", Hash: "t1", Date: "2026-10-07T12:54:00", Branch: "trunk", Parents: []string{"t0", "b1"}, Comment: "land b", HasChild: true, Children: 2},
{Kind: "ci", Hash: "b1", Date: "2026-10-07T12:27:00", Branch: "b", Parents: []string{"t0"}, Comment: "b", HasChild: true},
{Kind: "ci", Hash: "t0", Date: "2026-10-07T07:12:00", Branch: "trunk", Comment: "trunk", HasChild: true},
})
rows := map[string]tlRow{}
for _, d := range days {
for _, row := range d.Rows {
rows[row.Hash] = row
}
}
if got := railOf(t, rows["b1"]); got > 2 {
t.Errorf("FAIL: the merged branch opened column %d with columns 1 and 2 free below the landing", got)
}
if railOf(t, rows["t1"]) != 0 {
t.Error("FAIL: the landing left trunk's column")
}
}
// A TIME-WARPED CHECK-IN IS FILED UNDER THE DAY IT IS DRAWN IN.
//
// The index lifts a check-in dated before its own parent to just above
// it (index childrenFirst; a Git rebase keeps the author date). Filed by
// its own date, a lifted row from the day before opened that day between
// two rows of the next and the next day's header came twice. It goes
// under the day it is drawn in and shows its own date.
func TestATimeWarpedCheckInIsFiledUnderTheDayItIsDrawnIn(t *testing.T) {
entries := []timelineEntry{ // as the index orders them
{Kind: "ci", Hash: "m2", Date: "2026-11-01T19:48:35", Branch: "trunk", Parents: []string{"m1", "f2"}, IsLeaf: true, IsMerge: true, Comment: "land release-212"},
{Kind: "ci", Hash: "f2", HasChild: true, Date: "2026-10-31T23:59:58", Branch: "release-212", Parents: []string{"m1"}, Comment: "fix, rebased"},
{Kind: "ci", Hash: "m1", HasChild: true, Children: 2, Date: "2026-11-01T19:33:35", Branch: "trunk", Parents: []string{"t1"}, Comment: "land release-209"},
{Kind: "ci", Hash: "t1", HasChild: true, Date: "2026-10-31T16:26:35", Branch: "trunk", Parents: []string{"t0"}, Comment: "land release-206"},
}
days := buildTimeline("r", "trunk", entries)
var got []string
for _, d := range days {
var rows []string
for _, row := range d.Rows {
rows = append(rows, row.Hash+"@"+row.Time)
}
got = append(got, d.Date+": "+strings.Join(rows, " "))
}
want := "2026-11-01: m2@19:48 f2@10-31 23:59 m1@19:33 | 2026-10-31: t1@16:26"
if strings.Join(got, " | ") != want {
t.Fatalf("FAIL: days\n got %q\nwant %q", strings.Join(got, " | "), want)
}
t.Log("ok: a lifted row goes under the day it is drawn in, with its own date on it")
}
// A chunk draws no line in from above for a time-warped child. Dated
// before its parent, the child is placed below it, on a later chunk; a
// line from the top edge led to nothing and ran the whole page (Saur0n,
// land release-175 and its 20:16 child).
func TestAChunkDrawsNoLineFromAboveForATimeWarpedChild(t *testing.T) {
chunk := []timelineEntry{
{Kind: "ci", Hash: "m3", Date: "2026-10-31T21:39:35", Branch: "trunk", Parents: []string{"m2", "w1"}, HasChild: true, Children: 1, IsMerge: true, Comment: "land release-177"},
{Kind: "ci", Hash: "m2", Date: "2026-10-31T21:23:50", Branch: "trunk", Parents: []string{"t1"}, HasChild: true, Children: 2, Earlier: 1, Comment: "land release-175"},
}
for _, d := range buildTimeline("r", "trunk", chunk) {
for _, row := range d.Rows {
if row.Hash != "m2" {
continue
}
nx := railOf(t, row)*railW + railW/2
if strings.Contains(string(row.Head), fmt.Sprintf(`<line x1="%d" y1="0" x2="%d"`, nx, nx)) &&
railOf(t, row) != 0 {
t.Fatalf("FAIL: land release-175 has a line in from above on its own column\n%s", row.Head)
}
}
}
var cols int
for _, d := range buildTimeline("r", "trunk", chunk) {
for _, row := range d.Rows {
cols = max(cols, railOf(t, row)+1)
}
}
if cols > 1 {
t.Fatalf("FAIL: trunk took %d columns; a phantom line from above pushed it aside", cols)
}
t.Log("ok: a time-warped child below the chunk draws no line from above")
}