Skip to content
quarry Artifact [abab3f7b40]
Login

Artifact [abab3f7b40]

Artifact abab3f7b403e5d5f31e4462c4115ef48ae1d2b3a9e60cbbed4c7d0ca98a3826a:


package web

import (
	"fmt"
	"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"`) || !strings.Contains(tip, `fill:var(--bg)`) {
		t.Errorf("the tip is not an open ring:\n%s", tip)
	}
	if !strings.Contains(tip, `r="1.6"`) {
		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.6"`) || 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))
		}
	}
}