Skip to content
quarry Artifact [0ae1f07b38]
Login

Artifact [0ae1f07b38]

Artifact 0ae1f07b382bffac411be686a111b1a5aca3d35cd2ee7e543557fa1dcf6ac61b:


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")
	}
}