package web
import (
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/diff"
)
func leaf(path, change string) fileLeaf {
_, base, _ := cutLast(path)
return fileLeaf{Name: base, Path: path, Href: "#f-" + path, Change: change}
}
// paths walks the tree and returns every directory label in order, so a
// test can assert the SHAPE rather than the markup.
func paths(n *fileNode, prefix string) []string {
var out []string
for _, d := range n.Dirs {
out = append(out, prefix+d.Name)
out = append(out, paths(d, prefix+d.Name+"/")...)
}
return out
}
// A chain of directories that each hold one directory and no files is
// ONE row. Without that a deep tree is mostly rows that say nothing: a
// reader clicks four times to reach the one place anything changed, and
// each click reveals a single arrow.
func TestSingleChildDirectoriesCompressIntoOneRow(t *testing.T) {
tree := fileTree([]fileLeaf{
leaf("www/json-api/index.md", "change"),
leaf("www/json-api/auth.md", "new"),
})
got := paths(tree, "")
if len(got) != 1 || got[0] != "www/json-api" {
t.Errorf("FAIL: directories = %q, want [www/json-api]", got)
}
}
// Compression stops where the tree actually branches: two children is
// a fork a reader needs to see.
func TestCompressionStopsAtAFork(t *testing.T) {
tree := fileTree([]fileLeaf{
leaf("src/a/one.c", "change"),
leaf("src/b/two.c", "change"),
})
got := paths(tree, "")
want := []string{"src", "src/a", "src/b"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("FAIL: directories = %q, want %q", got, want)
}
}
// And where a directory holds files of its own: folding it away would
// hide them under a name they are not in.
func TestADirectoryWithItsOwnFilesIsNotFoldedAway(t *testing.T) {
tree := fileTree([]fileLeaf{
leaf("src/main.c", "change"),
leaf("src/lib/util.c", "change"),
})
got := paths(tree, "")
want := []string{"src", "src/lib"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("FAIL: directories = %q, want %q", got, want)
}
}
// A check-in that touched only top-level files renders as the flat list
// it always was: the tree costs nothing when there is no tree.
func TestTopLevelFilesStayFlat(t *testing.T) {
tree := fileTree([]fileLeaf{leaf("README.md", "change"), leaf("go.mod", "change")})
if len(tree.Dirs) != 0 {
t.Errorf("FAIL: flat files produced %d directories", len(tree.Dirs))
}
if len(tree.Files) != 2 {
t.Errorf("FAIL: %d root files, want 2", len(tree.Files))
}
}
// The count and the A/M/D tally are what a row says without being
// opened, so they must be the whole subtree's, not the node's own.
func TestCountsAreTheWholeSubtree(t *testing.T) {
tree := fileTree([]fileLeaf{
leaf("src/a.c", string(diff.Added)),
leaf("src/deep/b.c", string(diff.Deleted)),
leaf("src/deep/c.c", string(diff.Change)),
})
src := tree.Dirs[0]
if src.Total != 3 || src.Added != 1 || src.Deleted != 1 || src.Changed != 1 {
t.Errorf("FAIL: src = total %d, +%d ~%d -%d; want 3, +1 ~1 -1",
src.Total, src.Added, src.Changed, src.Deleted)
}
}
// Groups close past thirty files in the WHOLE change, including the
// small groups: a reader opening a 600-file check-in wants the shape
// first, and one group left open defeats that.
func TestGroupsCloseOnTheWholeChangeNotTheGroup(t *testing.T) {
small := fileTree([]fileLeaf{leaf("src/a.c", "change")})
if !small.Dirs[0].Open(1) {
t.Error("FAIL: a one-file change renders closed")
}
if small.Dirs[0].Open(treeCollapse + 1) {
t.Error("FAIL: a small group stayed open inside a large change")
}
}
// Columns only where a group is long enough to need them.
func TestColumnsOnlyForALongGroup(t *testing.T) {
var many []fileLeaf
for i := 0; i < treeColumns+1; i++ {
many = append(many, leaf("src/f"+string(rune('a'+i))+".c", "change"))
}
if !fileTree(many).Dirs[0].Columns() {
t.Errorf("FAIL: a group of %d does not use columns", treeColumns+1)
}
if fileTree(many[:2]).Dirs[0].Columns() {
t.Error("FAIL: a group of two uses columns")
}
}
// Two renders of the same check-in read the same way.
func TestTheTreeIsOrdered(t *testing.T) {
tree := fileTree([]fileLeaf{
leaf("z/one.c", "change"), leaf("a/two.c", "change"), leaf("m/three.c", "change"),
})
got := paths(tree, "")
want := []string{"a", "m", "z"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("FAIL: order = %q, want %q", got, want)
}
}
// A file in a window nobody has fetched has no anchor to scroll to, so
// it links to its own diff instead. An anchor that scrolls nowhere is
// worse than a link that works.
func TestAFileOutsideThisWindowLinksToItsOwnDiff(t *testing.T) {
d := diffDetail{Repo: "quarry", To: "abc123", Structured: []diff.FileDiff{
{Path: "src/here.c", Change: diff.Change, Hunks: []diff.Hunk{{}}},
{Path: "src/later.c", Change: diff.Change},
}}
byName := map[string]string{}
for _, f := range d.Tree().Dirs[0].Files {
byName[f.Name] = f.Href
}
if got := byName["here.c"]; !strings.HasPrefix(got, "#f-") {
t.Errorf("FAIL: a file in this window links to %q, want an anchor", got)
}
if got := byName["later.c"]; !strings.Contains(got, "/diff?file=") {
t.Errorf("FAIL: a file outside this window links to %q, want its own diff", got)
}
}
// The filter is a plain GET that rides every URL the page emits, or
// paging out of a filtered view silently shows everything again.
func TestTheFilterSurvivesPaging(t *testing.T) {
d := diffDetail{Repo: "quarry", To: "abc", Status: "D", Offset: 400, Prev: 0, LastOffset: 900}
for name, got := range map[string]string{
"Start": d.Start(), "Last": d.Last(), "Back": d.Back(),
"toggle": d.ToggleHref(true),
} {
if !strings.Contains(got, "status=D") {
t.Errorf("FAIL: %s dropped the filter: %s", name, got)
}
}
}
// A chip that is already on clears the filter, so the same control
// toggles rather than needing a second one beside it.
func TestTheActiveChipClearsTheFilter(t *testing.T) {
d := diffDetail{Repo: "quarry", To: "abc", Status: "D"}
if got := d.StatusHref("D"); strings.Contains(got, "status=") {
t.Errorf("FAIL: the active chip still sets a filter: %s", got)
}
if got := d.StatusHref("A"); !strings.Contains(got, "status=A") {
t.Errorf("FAIL: another chip does not set its filter: %s", got)
}
if !d.StatusOn("d") {
t.Error("FAIL: the active chip is case-sensitive")
}
}
// A URL nobody can write must not be able to empty the page.
func TestAnUnknownFilterFiltersNothing(t *testing.T) {
d := diffDetail{Repo: "quarry", To: "abc", Status: "wat", Structured: []diff.FileDiff{
{Path: "a.c", Change: diff.Change}, {Path: "b.c", Change: diff.Added},
}}
if got := d.Tree().Total; got != 2 {
t.Errorf("FAIL: an unknown filter kept %d of 2 files", got)
}
}
// The filter actually filters, by the letter a reader sees.
func TestTheFilterKeepsOnlyThatKind(t *testing.T) {
files := []diff.FileDiff{
{Path: "a.c", Change: diff.Change}, {Path: "b.c", Change: diff.Added},
{Path: "c.c", Change: diff.Deleted}, {Path: "d.c", Change: diff.Deleted},
}
for letter, want := range map[string]int{"A": 1, "M": 1, "D": 2} {
d := diffDetail{Repo: "q", To: "abc", Status: letter, Structured: files}
if got := d.Tree().Total; got != want {
t.Errorf("FAIL: ?status=%s kept %d files, want %d", letter, got, want)
}
}
}
// The markup: <details> groups, so the collapse works with scripting
// off, and a data hook the page can be asserted on.
func TestTheTreeRendersAsDetailsGroups(t *testing.T) {
var buf strings.Builder
d := diffDetail{Repo: "quarry", To: "abc", Structured: []diff.FileDiff{
{Path: "src/deep/a.c", Change: diff.Change, Hunks: []diff.Hunk{{}}},
}}
if err := noHooks.template(diffPage).ExecuteTemplate(&buf, "diff-body",
pageData{Repo: "quarry", Diff: d}); err != nil {
t.Fatal(err)
}
body := buf.String()
for _, want := range []string{`data-hook="file-tree"`, "<details", "<summary", "src/deep/"} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the tree is missing %q", want)
}
}
// The flat list it replaced must be gone, or two lists render.
if regexp.MustCompile(`<ul class="difffiles`).MatchString(body) {
t.Error("FAIL: the flat file list still renders beside the tree")
}
}
// The chips must add up to the file count beside them. Structured and
// Files are two READINGS of the same change, not two halves of it, and
// summing both counted every file twice: fossil's 62b38332 printed
// "597 files changed" over chips adding to 1,197.
func TestTheChipsAddUpToTheFileCount(t *testing.T) {
// A page carrying both readings, as the real check-in page does.
d := diffDetail{Repo: "fossil", To: "62b38332f7e0",
Structured: []diff.FileDiff{
{Path: "a.c", Change: diff.Change},
{Path: "b.c", Change: diff.Added},
{Path: "c.c", Change: diff.Deleted},
},
Files: []struct {
Status string `json:"status"`
Name string `json:"name"`
}{
{Status: "change", Name: "a.c"},
{Status: "new", Name: "b.c"},
{Status: "deleted", Name: "c.c"},
}}
total := 0
for _, k := range d.Kinds() {
total += k.Count
}
if total != len(d.Structured) {
t.Errorf("FAIL: the chips add to %d over %d files changed", total, len(d.Structured))
}
}
// And the brief-only page still gets its chips.
func TestTheBriefListAloneStillTallies(t *testing.T) {
d := diffDetail{Repo: "q", To: "abc", Files: []struct {
Status string `json:"status"`
Name string `json:"name"`
}{{Status: "new", Name: "a"}, {Status: "deleted", Name: "b"}}}
total := 0
for _, k := range d.Kinds() {
total += k.Count
}
if total != 2 {
t.Errorf("FAIL: the brief list tallied %d of 2", total)
}
}
// A reader arriving on a shared filtered link sees a heading counting
// the WHOLE change over a tree of a few files. The strip says why.
func TestAFilteredViewSaysWhatItIsShowing(t *testing.T) {
d := diffDetail{Repo: "fossil", To: "abc", Status: "D", Structured: []diff.FileDiff{
{Path: "a.c", Change: diff.Change}, {Path: "b.c", Change: diff.Deleted},
}}
if got := d.StatusWord(); got != "deleted" {
t.Errorf("FAIL: StatusWord = %q, want deleted", got)
}
var buf strings.Builder
if err := noHooks.template(diffPage).ExecuteTemplate(&buf, "diff-body", pageData{Repo: "fossil", Diff: d}); err != nil {
t.Fatal(err)
}
for _, want := range []string{"showing deleted only", "show all"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("FAIL: the filtered strip is missing %q", want)
}
}
// Unfiltered says neither.
d.Status = ""
buf.Reset()
if err := noHooks.template(diffPage).ExecuteTemplate(&buf, "diff-body", pageData{Repo: "fossil", Diff: d}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), "show all") {
t.Error("FAIL: an unfiltered view offers to show all")
}
}