1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
6 "context"
7 "fmt"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15)
16
17// DiffChangedLineLimits checks that context is retained without consuming the
18// changed-line limits, but still consumes the byte budget.
19func DiffChangedLineLimits(t *testing.T, mk Maker) {
20 g, data := mk(t)
21 f := NewFixture(t, data, "diff-context-budget")
22 var before, after strings.Builder
23 const hunks = 96
24 for i := range hunks * 10 {
25 line := fmt.Sprintf("context %04d", i)
26 fmt.Fprintln(&before, line)
27 if i%10 == 4 {
28 line = fmt.Sprintf("changed %04d", i)
29 }
30 fmt.Fprintln(&after, line)
31 }
32 f.Write("many", before.String()).Commit("before")
33 f.Write("many", after.String()).Commit("after")
34 limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35 d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36 require.NoError(t, err)
37 require.Len(t, d.Diff.Files, 1)
38 file := d.Diff.Files[0]
39 require.Equal(t, domain.OmitNone, file.Omitted)
40 assert.False(t, d.Diff.Truncated)
41 require.Len(t, file.Hunks, hunks)
42 var changed, patchLines, textBytes, changedBytes int
43 for _, h := range file.Hunks {
44 for _, l := range h.Lines {
45 patchLines++
46 textBytes += len(l.Text)
47 if l.Kind != domain.LineContext {
48 changed++
49 changedBytes += len(l.Text)
50 }
51 }
52 }
53 assert.Equal(t, hunks*2, changed)
54 // Every hunk is read with no line limit too.
55 free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20})
56 require.NoError(t, err)
57 require.Len(t, free.Diff.Files[0].Hunks, hunks)
58 assert.Greater(t, patchLines, limits.MaxFileLines)
59 assert.Equal(t, hunks, file.Additions)
60 assert.Equal(t, hunks, file.Deletions)
61 // The exact byte boundary fits; one fewer byte must discard the file even
62 // though every changed line's text alone easily fits.
63 limits.MaxTotalBytes = textBytes
64 d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
65 require.NoError(t, err)
66 assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
67 limits.MaxTotalBytes--
68 require.Greater(t, limits.MaxTotalBytes, changedBytes)
69 d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
70 require.NoError(t, err)
71 assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated")
72 assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted)
73 assert.Empty(t, d.Diff.Files[0].Hunks)
74}
75
76// DiffOmissions checks the omission reasons: generated files skipped without
77// reading their hunks, one file read without a line cap, and the MaxFiles
78// overflow being the only thing that sets Diff.Truncated.
79func DiffOmissions(t *testing.T, mk Maker) {
80 g, data := mk(t)
81 ctx := context.Background()
82 f := NewFixture(t, data, "diff-omissions")
83 f.Write("base.txt", "x\n").Commit("base")
84 f.Write("a.txt", "small\n")
85 // The generated file (515 bytes) would overflow the 300-byte budget below
86 // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks
87 // are not read.
88 f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5))
89 f.Lines("z-big.txt", "line", 2500)
90 f.Commit("change")
91
92 limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true}
93 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
94 require.NoError(t, err)
95 require.Len(t, d.Diff.Files, 3)
96 byPath := map[string]domain.FileDiff{}
97 for _, file := range d.Diff.Files {
98 byPath[file.NewPath] = file
99 }
100 assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
101 assert.Len(t, byPath["a.txt"].Hunks, 1)
102 assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
103 assert.Empty(t, byPath["go.sum"].Hunks)
104 assert.Equal(t, 5, byPath["go.sum"].Additions)
105 assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted)
106 assert.Empty(t, byPath["z-big.txt"].Hunks)
107 assert.False(t, d.Diff.Truncated)
108
109 // Read, go.sum overflows the bytes that a.txt, read before it, already
110 // spent: it may fit on its own, so it is OmitBudget, loadable alone.
111 limits.SkipGenerated = false
112 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
113 require.NoError(t, err)
114 sum := -1
115 for i, file := range d.Diff.Files {
116 if file.NewPath == "go.sum" {
117 sum = i
118 assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes")
119 assert.Empty(t, file.Hunks)
120 }
121 }
122 require.GreaterOrEqual(t, sum, 0)
123 // Alone, it is still over the bytes: OmitUnavailable, a dead end.
124 alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300}
125 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone)
126 require.NoError(t, err)
127 assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own")
128 assert.Empty(t, d.Diff.Files[sum].Hunks)
129
130 // OneFileDiffLimits reads a file over 2,000 changed lines, and only it.
131 idx := -1
132 for i, file := range d.Diff.Files {
133 if file.NewPath == "z-big.txt" {
134 idx = i
135 }
136 }
137 require.GreaterOrEqual(t, idx, 0)
138 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx))
139 require.NoError(t, err)
140 for i, file := range d.Diff.Files {
141 assert.Equal(t, domain.OmitNone, file.Omitted)
142 if i == idx {
143 require.Len(t, file.Hunks, 1)
144 assert.Len(t, file.Hunks[0].Lines, 2500)
145 } else {
146 assert.Empty(t, file.Hunks)
147 }
148 }
149 assert.False(t, d.Diff.Truncated)
150
151 // MaxFiles alone sets Diff.Truncated.
152 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20})
153 require.NoError(t, err)
154 assert.Len(t, d.Diff.Files, 2)
155 assert.True(t, d.Diff.Truncated)
156 for _, file := range d.Diff.Files {
157 assert.Equal(t, domain.OmitNone, file.Omitted)
158 }
159
160 // A one-file read is capped at MaxOneFileLines changed lines: the cap
161 // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162 f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164 require.NoError(t, err)
165 capIdx, overIdx := -1, -1
166 for i, file := range d.Diff.Files {
167 switch file.NewPath {
168 case "cap.txt":
169 capIdx = i
170 case "over.txt":
171 overIdx = i
172 }
173 }
174 require.GreaterOrEqual(t, capIdx, 0)
175 require.GreaterOrEqual(t, overIdx, 0)
176 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177 require.NoError(t, err)
178 assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179 require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180 assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182 require.NoError(t, err)
183 assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184 assert.Empty(t, d.Diff.Files[overIdx].Hunks)
185}