Skip to content

Commit 50f574a

Browse files
committed
+ test: add roperty-based tests;
1 parent 715a9ed commit 50f574a

6 files changed

Lines changed: 588 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,5 @@ Live e2e smoke для реальной интеграции вынесен в [`
281281
`lefthook` запускает этот smoke на `pre-push` только когда в push есть `*.go` файлы, а не на `pre-commit`. Если обязательные env не заданы, hook печатает `skip` и пропускает push; для жёсткого режима выставьте `RAGCLI_E2E_REQUIRED=1`.
282282

283283
На `pre-commit` `lefthook` запускает `gofmt`, `golangci-lint`, `go test ./...` и `govulncheck ./...`. Для локальной установки `govulncheck` используйте `go install golang.org/x/vuln/cmd/govulncheck@v1.1.4`.
284+
285+
Если нужно погонять property-based тесты глубже обычного цикла, увеличьте число `rapid`-проверок локально: `go test ./... -args -rapid.checks=500`.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/urfave/cli/v3 v3.7.0
1515
golang.org/x/term v0.41.0
1616
golang.org/x/text v0.35.0
17+
pgregory.net/rapid v1.2.0
1718
)
1819

1920
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
149149
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
150150
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
151151
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
152+
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
153+
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package files
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
"pgregory.net/rapid"
9+
)
10+
11+
func TestBuildLineSlices_PropertyClampsAndPreservesOrder(t *testing.T) {
12+
rapid.Check(t, func(t *rapid.T) {
13+
lines := drawFileToolLines(t, "lines", 12, 24)
14+
displayPath := drawFileToolPath(t, "path", 24)
15+
start := rapid.IntRange(-5, 20).Draw(t, "start")
16+
end := rapid.IntRange(-5, 20).Draw(t, "end")
17+
18+
got := buildLineSlices(lines, displayPath, start, end)
19+
want := expectedLineSlices(lines, displayPath, start, end)
20+
if len(got) != len(want) {
21+
t.Fatalf("len(buildLineSlices()) = %d, want %d", len(got), len(want))
22+
}
23+
24+
for i := range want {
25+
if got[i] != want[i] {
26+
t.Fatalf("buildLineSlices()[%d] = %+v, want %+v", i, got[i], want[i])
27+
}
28+
if i > 0 && got[i-1].LineNumber >= got[i].LineNumber {
29+
t.Fatalf("line numbers are not strictly increasing: prev=%+v current=%+v", got[i-1], got[i])
30+
}
31+
}
32+
})
33+
}
34+
35+
func TestReadLinesFromLines_PropertyReturnsConsistentJSON(t *testing.T) {
36+
rapid.Check(t, func(t *rapid.T) {
37+
lines := drawFileToolLines(t, "lines", 12, 24)
38+
displayPath := drawFileToolPath(t, "path", 24)
39+
start := rapid.IntRange(-5, 20).Draw(t, "start")
40+
end := rapid.IntRange(-5, 20).Draw(t, "end")
41+
42+
raw, err := readLinesFromLines(lines, displayPath, start, end)
43+
if err != nil {
44+
t.Fatalf("readLinesFromLines() error = %v", err)
45+
}
46+
47+
var result ReadLinesResult
48+
if err := json.Unmarshal([]byte(raw), &result); err != nil {
49+
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
50+
}
51+
52+
wantStart := max(start, 1)
53+
wantLines := expectedLineSlices(lines, displayPath, start, end)
54+
if result.Path != displayPath {
55+
t.Fatalf("Path = %q, want %q", result.Path, displayPath)
56+
}
57+
if result.StartLine != wantStart {
58+
t.Fatalf("StartLine = %d, want %d", result.StartLine, wantStart)
59+
}
60+
if result.EndLine != end {
61+
t.Fatalf("EndLine = %d, want %d", result.EndLine, end)
62+
}
63+
if result.LineCount != len(result.Lines) {
64+
t.Fatalf("LineCount = %d, want len(Lines)=%d", result.LineCount, len(result.Lines))
65+
}
66+
if len(result.Lines) != len(wantLines) {
67+
t.Fatalf("len(Lines) = %d, want %d", len(result.Lines), len(wantLines))
68+
}
69+
for i := range wantLines {
70+
if result.Lines[i] != wantLines[i] {
71+
t.Fatalf("Lines[%d] = %+v, want %+v", i, result.Lines[i], wantLines[i])
72+
}
73+
}
74+
})
75+
}
76+
77+
func TestReadAroundFromLines_PropertyReturnsClampedWindow(t *testing.T) {
78+
rapid.Check(t, func(t *rapid.T) {
79+
lines := drawFileToolLines(t, "lines", 12, 24)
80+
displayPath := drawFileToolPath(t, "path", 24)
81+
line := rapid.IntRange(-2, 20).Draw(t, "line")
82+
before := rapid.IntRange(-2, 8).Draw(t, "before")
83+
after := rapid.IntRange(-2, 8).Draw(t, "after")
84+
85+
raw, err := readAroundFromLines(lines, displayPath, line, before, after)
86+
if line < 1 {
87+
if err == nil {
88+
t.Fatalf("readAroundFromLines() error = nil, want invalid_arguments for line=%d", line)
89+
}
90+
toolErr := AsToolError(err)
91+
if toolErr.Code != "invalid_arguments" {
92+
t.Fatalf("error code = %q, want invalid_arguments", toolErr.Code)
93+
}
94+
return
95+
}
96+
if err != nil {
97+
t.Fatalf("readAroundFromLines() error = %v", err)
98+
}
99+
100+
var result ReadAroundResult
101+
if err := json.Unmarshal([]byte(raw), &result); err != nil {
102+
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
103+
}
104+
105+
wantBefore := before
106+
if wantBefore < 0 {
107+
wantBefore = DefaultReadAroundBefore
108+
}
109+
wantAfter := after
110+
if wantAfter < 0 {
111+
wantAfter = DefaultReadAroundAfter
112+
}
113+
wantStart, wantEnd, wantLines := expectedReadAround(lines, displayPath, line, wantBefore, wantAfter)
114+
115+
if result.Path != displayPath {
116+
t.Fatalf("Path = %q, want %q", result.Path, displayPath)
117+
}
118+
if result.Line != line {
119+
t.Fatalf("Line = %d, want %d", result.Line, line)
120+
}
121+
if result.Before != wantBefore || result.After != wantAfter {
122+
t.Fatalf("window = %d/%d, want %d/%d", result.Before, result.After, wantBefore, wantAfter)
123+
}
124+
if result.StartLine != wantStart || result.EndLine != wantEnd {
125+
t.Fatalf("range = %d..%d, want %d..%d", result.StartLine, result.EndLine, wantStart, wantEnd)
126+
}
127+
if result.LineCount != len(result.Lines) {
128+
t.Fatalf("LineCount = %d, want len(Lines)=%d", result.LineCount, len(result.Lines))
129+
}
130+
if len(result.Lines) != len(wantLines) {
131+
t.Fatalf("len(Lines) = %d, want %d", len(result.Lines), len(wantLines))
132+
}
133+
for i := range wantLines {
134+
if result.Lines[i] != wantLines[i] {
135+
t.Fatalf("Lines[%d] = %+v, want %+v", i, result.Lines[i], wantLines[i])
136+
}
137+
}
138+
})
139+
}
140+
141+
func TestSortTokenOverlapMatches_PropertyOrdersAndPreservesEqualKeyOrder(t *testing.T) {
142+
rapid.Check(t, func(t *rapid.T) {
143+
count := rapid.IntRange(0, 20).Draw(t, "count")
144+
matches := make([]SearchMatch, 0, count)
145+
originalIndex := make(map[string]int, count)
146+
for i := 0; i < count; i++ {
147+
id := fmt.Sprintf("match-%02d", i)
148+
matches = append(matches, SearchMatch{
149+
Content: id,
150+
Path: drawFileToolPath(t, "path", 12),
151+
LineNumber: rapid.IntRange(1, 30).Draw(t, "line"),
152+
Score: float64(rapid.IntRange(0, 1000).Draw(t, "score")) / 1000,
153+
})
154+
originalIndex[id] = i
155+
}
156+
157+
sortTokenOverlapMatches(matches)
158+
for i := 1; i < len(matches); i++ {
159+
prev := matches[i-1]
160+
current := matches[i]
161+
162+
switch {
163+
case prev.Score < current.Score:
164+
t.Fatalf("score order violated: prev=%+v current=%+v", prev, current)
165+
case prev.Score > current.Score:
166+
continue
167+
}
168+
169+
switch {
170+
case prev.Path > current.Path:
171+
t.Fatalf("path order violated: prev=%+v current=%+v", prev, current)
172+
case prev.Path < current.Path:
173+
continue
174+
}
175+
176+
switch {
177+
case prev.LineNumber > current.LineNumber:
178+
t.Fatalf("line order violated: prev=%+v current=%+v", prev, current)
179+
case prev.LineNumber < current.LineNumber:
180+
continue
181+
}
182+
183+
if originalIndex[prev.Content] > originalIndex[current.Content] {
184+
t.Fatalf("stable order violated for equal keys: prev=%+v current=%+v", prev, current)
185+
}
186+
}
187+
})
188+
}
189+
190+
func drawFileToolLines(t *rapid.T, label string, maxCount int, maxLen int) []string {
191+
t.Helper()
192+
193+
count := rapid.IntRange(0, maxCount).Draw(t, label+".count")
194+
lines := make([]string, 0, count)
195+
for i := 0; i < count; i++ {
196+
lines = append(lines, drawFileToolString(t, label+".line", maxLen, []rune{'a', 'B', 'ж', 'Я', '1', ' ', '\t', '-', '_', '/', '.'}))
197+
}
198+
return lines
199+
}
200+
201+
func drawFileToolPath(t *rapid.T, label string, maxLen int) string {
202+
t.Helper()
203+
return drawFileToolString(t, label, maxLen, []rune{'a', 'B', 'ж', 'Я', '1', '-', '_', '/', '.'})
204+
}
205+
206+
func drawFileToolString(t *rapid.T, label string, maxLen int, alphabet []rune) string {
207+
t.Helper()
208+
209+
length := rapid.IntRange(0, maxLen).Draw(t, label+".len")
210+
runes := make([]rune, 0, length)
211+
for i := 0; i < length; i++ {
212+
idx := rapid.IntRange(0, len(alphabet)-1).Draw(t, label+".rune")
213+
runes = append(runes, alphabet[idx])
214+
}
215+
return string(runes)
216+
}
217+
218+
func expectedLineSlices(lines []string, displayPath string, start int, end int) []LineSlice {
219+
if start < 1 {
220+
start = 1
221+
}
222+
if end > len(lines) {
223+
end = len(lines)
224+
}
225+
if end < start {
226+
return []LineSlice{}
227+
}
228+
229+
result := make([]LineSlice, 0, end-start+1)
230+
for lineNumber := start; lineNumber <= end; lineNumber++ {
231+
result = append(result, LineSlice{
232+
LineNumber: lineNumber,
233+
Content: lines[lineNumber-1],
234+
Path: displayPath,
235+
})
236+
}
237+
return result
238+
}
239+
240+
func expectedReadAround(lines []string, displayPath string, line int, before int, after int) (int, int, []LineSlice) {
241+
start := max(line-before, 1)
242+
end := min(line+after, len(lines))
243+
if len(lines) == 0 {
244+
return line, line - 1, []LineSlice{}
245+
}
246+
return start, end, expectedLineSlices(lines, displayPath, start, end)
247+
}

internal/map/property_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package mapmode
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
"unicode/utf8"
8+
9+
"pgregory.net/rapid"
10+
)
11+
12+
func TestSplitOversizedApproxLine_PropertyPreservesTextAndBudgets(t *testing.T) {
13+
rapid.Check(t, func(t *rapid.T) {
14+
line := drawMapString(t, "line", 72, []rune{'a', 'B', 'ж', 'Я', '🙂', '1', ' ', '\t', '-', '_'})
15+
maxChunkTokens := rapid.IntRange(1, 12).Draw(t, "maxChunkTokens")
16+
requestOverhead := rapid.IntRange(0, 80).Draw(t, "requestOverhead")
17+
18+
chunks, err := splitOversizedApproxLine(context.Background(), line, maxChunkTokens, requestOverhead)
19+
if err != nil {
20+
t.Fatalf("splitOversizedApproxLine() error = %v", err)
21+
}
22+
23+
if line == "" {
24+
if chunks != nil {
25+
t.Fatalf("chunks = %v, want nil for empty input", chunks)
26+
}
27+
return
28+
}
29+
if len(chunks) == 0 {
30+
t.Fatal("expected at least one chunk for non-empty input")
31+
}
32+
33+
var rebuilt strings.Builder
34+
for i, chunk := range chunks {
35+
if chunk.Text == "" {
36+
t.Fatalf("chunk[%d] is empty", i)
37+
}
38+
if !utf8.ValidString(chunk.Text) {
39+
t.Fatalf("chunk[%d] is not valid UTF-8: %q", i, chunk.Text)
40+
}
41+
if chunk.TokenCount != approxTokenCount(chunk.Text) {
42+
t.Fatalf("chunk[%d].TokenCount = %d, want %d", i, chunk.TokenCount, approxTokenCount(chunk.Text))
43+
}
44+
if chunk.TokenCount > maxChunkTokens {
45+
t.Fatalf("chunk[%d].TokenCount = %d, want <= %d", i, chunk.TokenCount, maxChunkTokens)
46+
}
47+
if chunk.RequestTokens != requestOverhead+chunk.TokenCount {
48+
t.Fatalf("chunk[%d].RequestTokens = %d, want %d", i, chunk.RequestTokens, requestOverhead+chunk.TokenCount)
49+
}
50+
if chunk.ByteCount != len(chunk.Text) {
51+
t.Fatalf("chunk[%d].ByteCount = %d, want %d", i, chunk.ByteCount, len(chunk.Text))
52+
}
53+
54+
rebuilt.WriteString(chunk.Text)
55+
}
56+
57+
if rebuilt.String() != line {
58+
t.Fatalf("rebuilt line = %q, want %q", rebuilt.String(), line)
59+
}
60+
})
61+
}
62+
63+
func drawMapString(t *rapid.T, label string, maxLen int, alphabet []rune) string {
64+
t.Helper()
65+
66+
length := rapid.IntRange(0, maxLen).Draw(t, label+".len")
67+
runes := make([]rune, 0, length)
68+
for i := 0; i < length; i++ {
69+
idx := rapid.IntRange(0, len(alphabet)-1).Draw(t, label+".rune")
70+
runes = append(runes, alphabet[idx])
71+
}
72+
return string(runes)
73+
}

0 commit comments

Comments
 (0)