package web
import (
"regexp"
"strings"
"testing"
"golang.org/x/net/html"
)
// shownText is what a reader sees of a page: text nodes outside code,
// preformatted text and form controls, where a person may type "!12".
func shownText(t *testing.T, doc string) string {
t.Helper()
root, err := html.Parse(strings.NewReader(doc))
if err != nil {
t.Fatal(err)
}
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "code", "pre", "textarea", "input", "script", "style":
return
}
}
if n.Type == html.TextNode {
b.WriteString(n.Data + " ")
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return b.String()
}
var shownBang = regexp.MustCompile(`(^|[^\w])![0-9]+`)
// A landing's number is shown as its state icon and the number, named
// "landing N, <state>", and never as "!N": on the listing, the strata
// rungs, the page's heading, the review queue, the timeline's merge and
// landing rows, and a check-in's landed-by line. The bang is what a
// person types. The Accept button carries no number at all.
func TestNoPageShowsALandingAsBangNumber(t *testing.T) {
var pages = map[string]string{}
var b strings.Builder
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
row := func(n int, state string) landingRow {
return landingRow{ID: "aa11bb22cc33", Number: n, Branch: "a", Target: "trunk", State: state, Title: "A"}
}
must(noHooks.template(landingListPage).ExecuteTemplate(&b, "l-list-body", pageData{Repo: "quarry", Nav: ticketNav{Repo: "quarry"},
Landings: []landingRow{row(4, "open"), row(5, "accepted"), row(6, "rejected")}}))
pages["flat list"] = b.String()
b.Reset()
base, top := row(4, "open"), row(5, "open")
base.Position, base.Depth, top.Position, top.Depth = 1, 2, 2, 2
must(noHooks.template(landingListPage).ExecuteTemplate(&b, "l-list-body", pageData{Repo: "quarry", Nav: ticketNav{Repo: "quarry"},
LandingGroups: []landingGroup{{landingRow: base, Segments: []landingRow{base, top}}}}))
pages["strata"] = b.String()
for _, own := range []bool{true, false} {
b.Reset()
d := landingDetail{landingRow: row(12, "accepted"), CanAccept: true, Mergeable: "clean"}
must(noHooks.template(landingPage).ExecuteTemplate(&b, "landing-body", pageData{Title: "landing 12 Fix it", Repo: "quarry", Landing: &d, OwnH1: own, HideH1: !own}))
pages["landing page"] = b.String()
}
b.Reset()
must(noHooks.template(inboxPage).ExecuteTemplate(&b, "content", pageData{Title: "inbox", AttnFilter: "landings",
LandingQueue: []queueRow{{Repo: "quarry", ID: "7be08128b9420890", Number: 9, Branch: "x", Target: "trunk", Title: "X"}}}))
pages["review queue"] = b.String()
b.Reset()
must(noHooks.template(timelinePage).ExecuteTemplate(&b, "tl-body", pageData{Repo: "quarry", Days: []tlDay{{Date: "2026-09-30", Rows: []tlRow{
{Kind: "ci", Hash: strings.Repeat("a", 64), Short: "aaaaaaaaaaaa", Landing: strings.Repeat("b", 64), LandingTitle: "B", LandingNumber: 771},
{Kind: "l", Hash: strings.Repeat("c", 64), Title: "Landing: C", LandingNumber: 4}}}}}))
pages["timeline"] = b.String()
b.Reset()
must(noHooks.template(checkinPage).Execute(&b, pageData{Repo: "alpha", Checkin: checkinDetail{
Landing: &checkinLanding{ID: strings.Repeat("b", 64), Number: 7, Title: "Ship it", State: "accepted", Role: "merge"}}}))
pages["check-in"] = b.String()
for name, doc := range pages {
if loc := shownBang.FindStringIndex(shownText(t, doc)); loc != nil {
t.Errorf("%s shows a landing as !N: %q", name, shownText(t, doc)[loc[0]:loc[1]])
}
if !strings.Contains(doc, `data-hook="landing-number"`) {
t.Errorf("%s carries no landing number", name)
}
}
for name, want := range map[string]string{
"flat list": `aria-label="landing 5, accepted"`,
"strata": `aria-label="landing 4, open"`,
"landing page": `aria-label="landing 12, accepted"`,
"review queue": `aria-label="landing 9, open"`,
"timeline": `aria-label="landing 771, accepted"`,
"check-in": `aria-label="landing 7, accepted"`,
} {
if !strings.Contains(pages[name], want) || !strings.Contains(pages[name], `<use href="#i-landing"`) {
t.Errorf("%s: no state icon labelled %s", name, want)
}
}
}
// The decide box says what the button does, with no number in it.
func TestAcceptButtonCarriesNoNumber(t *testing.T) {
var b strings.Builder
d := landingDetail{landingRow: landingRow{ID: strings.Repeat("a", 64), Number: 768, Branch: "work", Target: "trunk", State: "open"},
CanAccept: true, Mergeable: "clean"}
if d.AcceptState() != "go" {
t.Fatal("fixture does not reach the accept button")
}
if err := noHooks.template(landingPage).ExecuteTemplate(&b, "landing-body", pageData{Repo: "quarry", Landing: &d, OwnH1: true}); err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`(?s)<button[^>]*data-accept="go".*?</button>`).FindString(b.String())
if m == "" || regexp.MustCompile(`[0-9]`).MatchString(regexp.MustCompile(`(?s)<[^>]*>`).ReplaceAllString(m, "")) {
t.Errorf("the accept button shows a number: %s", m)
}
}
// An Atom title is text: the landing is named in words.
func TestFeedNamesALandingInWords(t *testing.T) {
head, _ := feedText(timelineEntry{Kind: "l", Comment: "fix", LandingNumber: 771})
if !strings.HasPrefix(head, "landing 771 ") || strings.Contains(head, "!") {
t.Errorf("feed title %q", head)
}
}