v0.28.0 fca9155

diff_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"strings"
8	"testing"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite/internal/domain"
14)
15
16// Trojan Source (ADR 0017): direction controls, zero-width and control
17// characters are shown as markers; ordinary text, tabs and non-Latin letters
18// are not touched.
19func TestSegmentsShowInvisibleCharacters(t *testing.T) {
20	t.Parallel()
21	tests := []struct {
22		name, in string
23		want     []Seg
24	}{
25		{"plain", "return x", []Seg{{Text: "return x"}}},
26		{"tab and unicode", "\tπ := “é”", []Seg{{Text: "\tπ := “é”"}}},
27		{"empty", "", []Seg{{Text: ""}}},
28		{"crlf", "a\r", []Seg{{Text: "a"}}},
29		{"rlo", "if x { // \u202eadmin", []Seg{{Text: "if x { // "}, {Text: "<U+202E>", Mark: true}, {Text: "admin"}}},
30		{"isolates", "\u2066a\u2069", []Seg{{Text: "<U+2066>", Mark: true}, {Text: "a"}, {Text: "<U+2069>", Mark: true}}},
31		{"zero width", "a\u200bb\ufeff", []Seg{{Text: "a"}, {Text: "<U+200B>", Mark: true}, {Text: "b"}, {Text: "<U+FEFF>", Mark: true}}},
32		{"control", "a\x00\x1bb", []Seg{{Text: "a"}, {Text: "<U+0000>", Mark: true}, {Text: "<U+001B>", Mark: true}, {Text: "b"}}},
33		{"tag character", "a\U000E0041", []Seg{{Text: "a"}, {Text: "<U+E0041>", Mark: true}}},
34		{"hangul filler", "\u3164", []Seg{{Text: "<U+3164>", Mark: true}}},
35		{"replacement is visible already", "a\ufffdb", []Seg{{Text: "a\ufffdb"}}},
36	}
37	for _, tt := range tests {
38		t.Run(tt.name, func(t *testing.T) {
39			t.Parallel()
40			assert.Equal(t, tt.want, segments(tt.in))
41		})
42	}
43	assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
44	assert.Equal(t, "plain", visibleText("plain"))
45	assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n"))
46}
47
48func line(kind domain.LineKind, oldN, newN int, text string) domain.Line {
49	return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
50}
51
52// The split view lines a run of deletions up with the additions after it;
53// context fills both sides; the extra lines of the longer run have an empty
54// opposite side.
55func TestPairLines(t *testing.T) {
56	t.Parallel()
57	ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion
58	var ls []DiffLine
59	for _, l := range []domain.Line{
60		line(ctx, 1, 1, "c"),
61		line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"),
62		line(add, 0, 2, "a1"),
63		line(ctx, 5, 3, "c2"),
64		line(add, 0, 4, "a2"), line(add, 0, 5, "a3"),
65	} {
66		ls = append(ls, diffLine(l))
67	}
68	rows := pairLines(ls)
69	type pair struct{ l, r string }
70	var got []pair
71	for _, r := range rows {
72		p := pair{}
73		if r.L != nil {
74			p.l = r.L.Mark + r.L.Segs[0].Text
75		}
76		if r.R != nil {
77			p.r = r.R.Mark + r.R.Segs[0].Text
78		}
79		got = append(got, p)
80	}
81	assert.Equal(t, []pair{
82		{" c", " c"}, {"-d1", "+a1"}, {"-d2", ""}, {"-d3", ""}, {" c2", " c2"}, {"", "+a2"}, {"", "+a3"},
83	}, got)
84	assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}, oldN: 2}, ls[1])
85	assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
86}
87
88func TestFileNotes(t *testing.T) {
89	t.Parallel()
90	tests := []struct {
91		name string
92		file domain.FileDiff
93		want string // a substring; empty means no note at all
94		eq   bool   // want is the whole note
95	}{
96		{"binary", domain.FileDiff{Binary: true, Status: domain.FileModified}, "binary file: no text diff", true},
97		{"mode change", domain.FileDiff{Status: domain.FileModified, OldMode: 0o100644, NewMode: 0o100755}, "mode 100644 → 100755", true},
98		{"an added file has no mode change", domain.FileDiff{Status: domain.FileAdded, NewMode: 0o100644}, "", true},
99		{"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100		{"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101		{"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102		{"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},
103	}
104	for _, tt := range tests {
105		t.Run(tt.name, func(t *testing.T) {
106			t.Parallel()
107			got := fileNote(tt.file)
108			if tt.eq {
109				assert.Equal(t, tt.want, got)
110				return
111			}
112			assert.Contains(t, got, tt.want)
113		})
114	}
115}
116
117func TestDiffFilesPaths(t *testing.T) {
118	t.Parallel()
119	fs := diffFiles([]domain.FileDiff{
120		{Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121		{Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122		{Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123		{Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
124	})
125	assert.Equal(t, "a.go", fs[0].Path)
126	assert.True(t, fs[0].Open)
127	assert.Equal(t, "b.go", fs[1].Path)
128	assert.False(t, fs[1].Open, "nothing to read: stays closed")
129	assert.Equal(t, "c<U+202E>.go → d.go", fs[2].Path)
130	assert.False(t, fs[3].Open, "a truncated file is collapsed")
131	assert.Equal(t, []string{"f1", "f2", "f3", "f4"}, []string{fs[0].ID, fs[1].ID, fs[2].ID, fs[3].ID})
132}
133
134// A code cell holds exactly the text: escaped, with markers, and nothing
135// added between the pieces (the cell keeps its white space).
136func TestCodeLine(t *testing.T) {
137	t.Parallel()
138	var b strings.Builder
139	ln := diffLine(domain.Line{Kind: domain.LineAddition, NewNumber: 1, Text: "a <b> \u202e c  d", NoNewlineAtEOF: true})
140	require.NoError(t, codeLine(ln).Render(context.Background(), &b))
141	assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c  d`+
142		`<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
143}
144
145// The gaps of a file: before a hunk the unchanged lines it does not show
146// (the same number on each side), after the last hunk the rest of the file;
147// none for a file whose lines are all in the diff.
148func TestContextGaps(t *testing.T) {
149	t.Parallel()
150	ctx := func(o, n int) domain.Line { return line(domain.LineContext, o, n, "x") }
151	files := []domain.FileDiff{
152		{Status: domain.FileModified, NewPath: "a b.go", Hunks: []domain.Hunk{
153			{OldStart: 1, OldLines: 2, NewStart: 1, NewLines: 2, Lines: []domain.Line{ctx(1, 1), ctx(2, 2)}},
154			{OldStart: 10, OldLines: 3, NewStart: 10, NewLines: 3, Lines: []domain.Line{ctx(10, 10), ctx(11, 11), ctx(12, 12)}},
155		}},
156		{Status: domain.FileModified, NewPath: "b.go", Hunks: []domain.Hunk{
157			// a pure deletion: the new side has no line, "+4,0" names line 4
158			{OldStart: 5, OldLines: 1, NewStart: 4, NewLines: 0, Lines: []domain.Line{line(domain.LineDeletion, 5, 0, "x")}},
159		}},
160		{Status: domain.FileAdded, NewPath: "c.go", Hunks: []domain.Hunk{{OldStart: 0, NewStart: 1, NewLines: 1, Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
161		{Status: domain.FileModified, NewPath: "d.go", Hunks: []domain.Hunk{
162			{OldStart: 3, OldLines: 1, NewStart: 3, NewLines: 1, Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 3, Text: "end", NoNewlineAtEOF: true}}},
163		}},
164	}
165	out := diffFiles(files)
166	contextGaps(out, files, "/r/context/c")
167
168	a := out[0]
169	assert.Nil(t, a.Hunks[0].Gap, "the first hunk starts at line 1")
170	require.NotNil(t, a.Hunks[1].Gap)
171	assert.Equal(t, 7, a.Hunks[1].Gap.Lines, "lines 3 to 9 on both sides")
172	assert.Equal(t, "/r/context/c?key=g1&n=7&new=3&old=3&path=a+b.go", a.Hunks[1].Gap.Src)
173	require.NotNil(t, a.Tail)
174	assert.Equal(t, "/r/context/c?key=tail&n=0&new=13&old=13&path=a+b.go", a.Tail.Src)
175
176	b := out[1]
177	require.NotNil(t, b.Hunks[0].Gap)
178	assert.Equal(t, 4, b.Hunks[0].Gap.Lines, "lines 1 to 4 on both sides before the deleted line 5")
179	assert.Nil(t, b.Tail, "a hunk that ends on a change ends the file")
180
181	assert.Nil(t, out[2].Tail, "an added file shows every line")
182	assert.Nil(t, out[3].Tail, "the last hunk holds the end of the file")
183	assert.NotNil(t, a.Tail, "three unchanged lines after the change: the file may go on")
184	assert.NotNil(t, out[3].Hunks[0].Gap)
185
186	// Sides that do not move together (a parse the page cannot trust): no gap.
187	odd := []domain.FileDiff{{Status: domain.FileModified, NewPath: "e.go", Hunks: []domain.Hunk{
188		{OldStart: 10, OldLines: 1, NewStart: 12, NewLines: 1, Lines: []domain.Line{ctx(10, 12)}},
189	}}}
190	o := diffFiles(odd)
191	contextGaps(o, odd, "/r/context/c")
192	assert.Nil(t, o[0].Hunks[0].Gap)
193}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close