v0.6.0 d5cc8fe

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	pb "bnema.dev/ermite-api/gen/ermite"
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	tests := []struct {
21		name, in string
22		want     []Seg
23	}{
24		{"plain", "return x", []Seg{{Text: "return x"}}},
25		{"tab and unicode", "\tπ := “é”", []Seg{{Text: "\tπ := “é”"}}},
26		{"empty", "", []Seg{{Text: ""}}},
27		{"crlf", "a\r", []Seg{{Text: "a"}}},
28		{"rlo", "if x { // \u202eadmin", []Seg{{Text: "if x { // "}, {Text: "<U+202E>", Mark: true}, {Text: "admin"}}},
29		{"isolates", "\u2066a\u2069", []Seg{{Text: "<U+2066>", Mark: true}, {Text: "a"}, {Text: "<U+2069>", Mark: true}}},
30		{"zero width", "a\u200bb\ufeff", []Seg{{Text: "a"}, {Text: "<U+200B>", Mark: true}, {Text: "b"}, {Text: "<U+FEFF>", Mark: true}}},
31		{"control", "a\x00\x1bb", []Seg{{Text: "a"}, {Text: "<U+0000>", Mark: true}, {Text: "<U+001B>", Mark: true}, {Text: "b"}}},
32		{"tag character", "a\U000E0041", []Seg{{Text: "a"}, {Text: "<U+E0041>", Mark: true}}},
33		{"hangul filler", "\u3164", []Seg{{Text: "<U+3164>", Mark: true}}},
34		{"replacement is visible already", "a\ufffdb", []Seg{{Text: "a\ufffdb"}}},
35	}
36	for _, tt := range tests {
37		t.Run(tt.name, func(t *testing.T) {
38			assert.Equal(t, tt.want, segments(tt.in))
39		})
40	}
41	assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
42	assert.Equal(t, "plain", visibleText("plain"))
43	assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n"))
44}
45
46func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line {
47	return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
48}
49
50// The split view lines a run of deletions up with the additions after it;
51// context fills both sides; the extra lines of the longer run have an empty
52// opposite side.
53func TestPairLines(t *testing.T) {
54	ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION
55	var ls []DiffLine
56	for _, l := range []*pb.Line{
57		line(ctx, 1, 1, "c"),
58		line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"),
59		line(add, 0, 2, "a1"),
60		line(ctx, 5, 3, "c2"),
61		line(add, 0, 4, "a2"), line(add, 0, 5, "a3"),
62	} {
63		ls = append(ls, diffLine(l))
64	}
65	rows := pairLines(ls)
66	type pair struct{ l, r string }
67	var got []pair
68	for _, r := range rows {
69		p := pair{}
70		if r.L != nil {
71			p.l = r.L.Mark + r.L.Segs[0].Text
72		}
73		if r.R != nil {
74			p.r = r.R.Mark + r.R.Segs[0].Text
75		}
76		got = append(got, p)
77	}
78	assert.Equal(t, []pair{
79		{" c", " c"}, {"-d1", "+a1"}, {"-d2", ""}, {"-d3", ""}, {" c2", " c2"}, {"", "+a2"}, {"", "+a3"},
80	}, got)
81	assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}, oldN: 2}, ls[1])
82	assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
83}
84
85func TestChangeBar(t *testing.T) {
86	assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0))
87	assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100))
88	assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100))
89	assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away")
90	assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar")
91}
92
93func TestFileNotes(t *testing.T) {
94	assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED}))
95	assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755}))
96	assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change")
97	assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}))
98	assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large")
99}
100
101func TestDiffFilesPaths(t *testing.T) {
102	fs := diffFiles([]*pb.FileDiff{
103		{Status: pb.FileStatus_FILE_STATUS_ADDED, NewPath: "a.go", Additions: 1, Hunks: []*pb.Hunk{{Lines: []*pb.Line{line(pb.LineKind_LINE_KIND_ADDITION, 0, 1, "x")}}}},
104		{Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1},
105		{Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"},
106		{Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000},
107	})
108	assert.Equal(t, "a.go", fs[0].Path)
109	assert.True(t, fs[0].Open)
110	assert.Equal(t, "b.go", fs[1].Path)
111	assert.False(t, fs[1].Open, "nothing to read: stays closed")
112	assert.Equal(t, "c<U+202E>.go → d.go", fs[2].Path)
113	assert.False(t, fs[3].Open, "a truncated file is collapsed")
114	assert.Equal(t, []string{"f1", "f2", "f3", "f4"}, []string{fs[0].ID, fs[1].ID, fs[2].ID, fs[3].ID})
115}
116
117// A code cell holds exactly the text: escaped, with markers, and nothing
118// added between the pieces (the cell keeps its white space).
119func TestCodeLine(t *testing.T) {
120	var b strings.Builder
121	ln := diffLine(&pb.Line{Kind: pb.LineKind_LINE_KIND_ADDITION, NewNumber: 1, Text: "a <b> \u202e c  d", NoNewlineAtEof: true})
122	require.NoError(t, codeLine(ln).Render(context.Background(), &b))
123	assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c  d`+
124		`<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
125}

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