package web
import (
"fmt"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// lineServer serves one zit store whose trunk holds big.go, a thousand
// numbered lines, and answers with trunk's full hash.
func lineServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir := t.TempDir()
run := func(args ...string) string {
cmd := exec.Command("zit", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}
run("init", ".")
var b strings.Builder
for i := 1; i <= 1000; i++ {
fmt.Fprintf(&b, "var line%d = %d\n", i, i)
}
if err := os.WriteFile(filepath.Join(dir, "big.go"), []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
run("add", "big.go")
run("commit", "-m", "big", "--user", "u")
hash := regexp.MustCompile(`[0-9a-f]{64}`).FindString(run("branch", "info", "trunk"))
if hash == "" {
t.Fatal("no trunk hash")
}
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix, Zit: zitcli.New(dir)}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
t.Cleanup(srv.Close)
return srv, hash
}
var (
lineRowRe = regexp.MustCompile(`<span class="line( hl)?" id="L(\d+)">`)
lnLinkRe = regexp.MustCompile(`data-hook="line-link" href="([^"]*)"`)
)
// rows lists the drawn line numbers and which of them are highlighted.
func rows(body string) (drawn, hit []string) {
for _, m := range lineRowRe.FindAllStringSubmatch(body, -1) {
drawn = append(drawn, m[2])
if m[1] != "" {
hit = append(hit, m[2])
}
}
return drawn, hit
}
// Every line is numbered, and the number is a link to the line, drawn
// from an attribute so a selection of the code never copies it.
func TestFileLinesAreNumberedOutsideTheText(t *testing.T) {
srv, _ := lineServer(t)
code, body := getPage(t, srv, "/r/alpha/file/trunk/big.go")
if code != 200 {
t.Fatalf("status %d", code)
}
drawn, hit := rows(body)
if len(drawn) == 0 || drawn[0] != "1" || len(hit) != 0 {
t.Fatalf("FAIL: drawn from %v, %d highlighted", drawn[:min(3, len(drawn))], len(hit))
}
if !strings.Contains(body, `<a class="ln" href="?ln=7#L7" data-n="7" aria-label="line 7"></a>`) {
t.Error("FAIL: line 7's number is not an empty link carrying its number")
}
if !strings.Contains(body, `data-hook="line-bar"`) || !strings.Contains(body, `hidden>`) {
t.Error("FAIL: with nothing picked the bar should be drawn hidden")
}
}
// ln= opens the file a little above the range, with exactly the range
// highlighted, and the window reaches past it.
func TestFileLineRangeOpensOnTheRange(t *testing.T) {
srv, _ := lineServer(t)
for _, c := range []struct {
ln string
first string
hitFirst string
hits int
}{
{"600", "590", "600", 1},
{"600-620", "590", "600", 21},
{"620-600", "590", "600", 21},
{"600-602,610-611", "590", "600", 5},
{"3", "1", "3", 1},
} {
_, body := getPage(t, srv, "/r/alpha/file/trunk/big.go?ln="+c.ln)
drawn, hit := rows(body)
if len(drawn) == 0 || drawn[0] != c.first {
t.Errorf("FAIL: ln=%s: window starts at %v, want %s", c.ln, drawn[:min(1, len(drawn))], c.first)
continue
}
if len(hit) != c.hits || hit[0] != c.hitFirst {
t.Errorf("FAIL: ln=%s: highlighted %v", c.ln, hit)
}
if c.first != "1" && !strings.Contains(body, `data-hook="file-earlier"`) {
t.Errorf("FAIL: ln=%s: no way to the lines above the window", c.ln)
}
}
}
// A range past the end shows the end of the file, highlights what
// exists, and says where the file stops. It is never an error page.
func TestFileLineRangePastTheEnd(t *testing.T) {
srv, _ := lineServer(t)
code, body := getPage(t, srv, "/r/alpha/file/trunk/big.go?ln=995-1200")
drawn, hit := rows(body)
if code != 200 || len(hit) != 6 || hit[0] != "995" || drawn[len(drawn)-1] != "1000" {
t.Errorf("FAIL: %d, drawn to %v, highlighted %v", code, drawn[len(drawn)-1:], hit)
}
if !strings.Contains(body, `data-hook="line-past"`) {
t.Error("FAIL: the bar does not say the file ends")
}
code, body = getPage(t, srv, "/r/alpha/file/trunk/big.go?ln=5000")
if drawn, _ = rows(body); code != 200 || len(drawn) == 0 || drawn[len(drawn)-1] != "1000" {
t.Errorf("FAIL: a range wholly past the end: %d, drawn %d lines", code, len(drawn))
}
}
// The link the bar offers names the check-in by hash, so the lines it
// points at cannot move when the branch does.
func TestFileLineLinkNamesTheHash(t *testing.T) {
srv, hash := lineServer(t)
_, body := getPage(t, srv, "/r/alpha/file/trunk/big.go?ln=12-14")
m := lnLinkRe.FindStringSubmatch(body)
if m == nil || m[1] != "/r/alpha/file/"+hash+"/big.go?ln=12-14" {
t.Errorf("FAIL: copy link is %v, want the trunk hash", m)
}
}
// The next window keeps the range, so a long range highlights across
// the windows it spans.
func TestFileNextWindowKeepsTheRange(t *testing.T) {
f := fileDetail{Hash: "abc", Path: "big.go", NextFrom: 400, Ranges: []lineRange{{390, 410}}}
if got := f.MoreURL("alpha"); !strings.HasSuffix(got, "?from=400&ln=390-410") {
t.Errorf("FAIL: next window %q drops the range", got)
}
}
// On a diff with no comments to offer, a line number opens that line of
// the file: the new side at the check-in, the old side at the parent,
// under its old name when the file was renamed.
func TestDiffLineNumbersOpenTheFile(t *testing.T) {
d := *noteDiff()
d.From, d.To = "parent0", "child1"
d.Structured[0].PrevPath = "was.go"
for _, split := range []bool{false, true} {
var buf strings.Builder
if err := noHooks.template(diffPage).ExecuteTemplate(&buf, "diff-more",
pageData{Repo: "r", Diff: d, DiffSplit: split}); err != nil {
t.Fatal(err)
}
page := buf.String()
for _, want := range []string{`href="/r/r/file/child1/a.go?ln=2#L2"`, `href="/r/r/file/parent0/was.go?ln=2#L2"`} {
if !strings.Contains(page, want) || !strings.Contains(page, `data-hook="diff-line-file"`) {
t.Errorf("FAIL: split=%v: no %s", split, want)
}
}
}
}