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 <b> <span class="inv" title="invisible character"><U+202E></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}