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 TestChangeBar(t *testing.T) {
89 t.Parallel()
90 assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0))
91 assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100))
92 assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100))
93 assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away")
94 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")
95}
96
97func TestFileNotes(t *testing.T) {
98 t.Parallel()
99 tests := []struct {
100 name string
101 file domain.FileDiff
102 want string // a substring; empty means no note at all
103 eq bool // want is the whole note
104 }{
105 {"binary", domain.FileDiff{Binary: true, Status: domain.FileModified}, "binary file: no text diff", true},
106 {"mode change", domain.FileDiff{Status: domain.FileModified, OldMode: 0o100644, NewMode: 0o100755}, "mode 100644 → 100755", true},
107 {"an added file has no mode change", domain.FileDiff{Status: domain.FileAdded, NewMode: 0o100644}, "", true},
108 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
109 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
110 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
111 {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},
112 }
113 for _, tt := range tests {
114 t.Run(tt.name, func(t *testing.T) {
115 t.Parallel()
116 got := fileNote(tt.file)
117 if tt.eq {
118 assert.Equal(t, tt.want, got)
119 return
120 }
121 assert.Contains(t, got, tt.want)
122 })
123 }
124}
125
126func TestDiffFilesPaths(t *testing.T) {
127 t.Parallel()
128 fs := diffFiles([]domain.FileDiff{
129 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
130 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
131 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
132 {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
133 })
134 assert.Equal(t, "a.go", fs[0].Path)
135 assert.True(t, fs[0].Open)
136 assert.Equal(t, "b.go", fs[1].Path)
137 assert.False(t, fs[1].Open, "nothing to read: stays closed")
138 assert.Equal(t, "c<U+202E>.go → d.go", fs[2].Path)
139 assert.False(t, fs[3].Open, "a truncated file is collapsed")
140 assert.Equal(t, []string{"f1", "f2", "f3", "f4"}, []string{fs[0].ID, fs[1].ID, fs[2].ID, fs[3].ID})
141}
142
143// A code cell holds exactly the text: escaped, with markers, and nothing
144// added between the pieces (the cell keeps its white space).
145func TestCodeLine(t *testing.T) {
146 t.Parallel()
147 var b strings.Builder
148 ln := diffLine(domain.Line{Kind: domain.LineAddition, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEOF: true})
149 require.NoError(t, codeLine(ln).Render(context.Background(), &b))
150 assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+
151 `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
152}