523 lines
17 KiB
Go
523 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
func TestAIDefaultsAreDisabledAndBounded(t *testing.T) {
|
|
config := defaultConfig()
|
|
if config.AI.Enabled {
|
|
t.Fatal("AI must be disabled by default")
|
|
}
|
|
config.AI.Enabled = true
|
|
if err := validateAIConfig(config.AI); err != nil {
|
|
t.Fatalf("default AI limits should validate when enabled: %v", err)
|
|
}
|
|
if config.AI.MaxRunBytes < config.AI.MaxRequestBytes || config.AI.MaxCalls < 1 {
|
|
t.Fatalf("unbounded defaults: %#v", config.AI)
|
|
}
|
|
}
|
|
|
|
func TestPrepareAIDiffFiltersAndRedacts(t *testing.T) {
|
|
config := defaultAIConfig()
|
|
raw := `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -1 +1,2 @@
|
|
package main
|
|
+token = "ghp_abcdefghijklmnopqrstuvwxyz123456"
|
|
diff --git a/go.sum b/go.sum
|
|
--- a/go.sum
|
|
+++ b/go.sum
|
|
@@ -1 +1 @@
|
|
-old
|
|
+new
|
|
diff --git a/.env b/.env
|
|
--- a/.env
|
|
+++ b/.env
|
|
@@ -0,0 +1 @@
|
|
+PASSWORD=do-not-send
|
|
`
|
|
files, excluded, redactions := prepareAIDiff(raw, config)
|
|
if len(files) != 1 || files[0].Path != "main.go" {
|
|
t.Fatalf("files = %#v, want only main.go", files)
|
|
}
|
|
if len(excluded) != 2 {
|
|
t.Fatalf("excluded = %#v, want go.sum and .env", excluded)
|
|
}
|
|
if redactions != 1 || strings.Contains(files[0].Text, "ghp_") ||
|
|
!strings.Contains(files[0].Text, "[REDACTED]") {
|
|
t.Fatalf("redaction failed: count=%d text=%q", redactions, files[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestParseAIDiffTracksDeletedSide(t *testing.T) {
|
|
file, ok := parseAIDiffFile(`diff --git a/old.go b/old.go
|
|
deleted file mode 100644
|
|
--- a/old.go
|
|
+++ /dev/null
|
|
@@ -7,2 +0,0 @@
|
|
-dangerous()
|
|
-cleanup()
|
|
`)
|
|
if !ok {
|
|
t.Fatal("deletion-only diff was excluded")
|
|
}
|
|
if file.Path != "old.go" || !file.DeletedLines[7] || !file.DeletedLines[8] ||
|
|
len(file.ChangedLines) != 0 {
|
|
t.Fatalf("parsed deletion = %#v", file)
|
|
}
|
|
if hunk := boundedDiffHunk(file.Text, 8, "LEFT"); !strings.Contains(hunk, "cleanup") {
|
|
t.Fatalf("left-side hunk = %q", hunk)
|
|
}
|
|
}
|
|
|
|
func TestBoundedDiffHunkKeepsTargetFromLargeHunk(t *testing.T) {
|
|
var diff strings.Builder
|
|
diff.WriteString("@@ -1,100 +1,101 @@\n")
|
|
for line := 1; line <= 100; line++ {
|
|
if line == 90 {
|
|
diff.WriteString("+TARGET\n")
|
|
}
|
|
diff.WriteString(" " + strings.Repeat("x", 400) + "\n")
|
|
}
|
|
hunk := boundedDiffHunk(diff.String(), 90, "RIGHT")
|
|
if !strings.Contains(hunk, "TARGET") || len(hunk) > 24_000 {
|
|
t.Fatalf("bounded hunk length=%d contains target=%t", len(hunk), strings.Contains(hunk, "TARGET"))
|
|
}
|
|
}
|
|
|
|
func TestParseCodexEventsRejectsToolUse(t *testing.T) {
|
|
data := []byte("{\"type\":\"item.completed\",\"item\":{\"type\":\"command_execution\",\"command\":\"pwd\"}}\n")
|
|
if _, _, err := parseCodexEvents(data); err == nil {
|
|
t.Fatal("tool event was accepted")
|
|
}
|
|
valid := []byte("{\"type\":\"item.completed\",\"model\":\"gpt-test\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"findings\\\":[],\\\"thread_comments\\\":[]}\"}}\n")
|
|
content, model, err := parseCodexEvents(valid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if model != "gpt-test" || !strings.Contains(string(content), `"findings"`) {
|
|
t.Fatalf("content=%q model=%q", content, model)
|
|
}
|
|
}
|
|
|
|
func TestCodexFailureDetailReadsJSONErrorEvents(t *testing.T) {
|
|
stdout := []byte(
|
|
"{\"type\":\"thread.started\",\"thread_id\":\"x\"}\n" +
|
|
"{\"type\":\"turn.failed\",\"error\":{\"message\":\"model unavailable\\u001b]8;;bad\"}}\n",
|
|
)
|
|
got := codexFailureDetail(stdout, nil)
|
|
if !strings.Contains(got, "model unavailable") || strings.ContainsRune(got, '\x1b') {
|
|
t.Fatalf("failure detail = %q", got)
|
|
}
|
|
if got := codexFailureDetail(nil, []byte("plain stderr")); got != "plain stderr" {
|
|
t.Fatalf("stderr detail = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPathWithinUsesPathBoundaries(t *testing.T) {
|
|
parent := filepath.Join(t.TempDir(), "repo")
|
|
if !pathWithin(parent, filepath.Join(parent, "nested", "file")) {
|
|
t.Fatal("nested path was not recognized")
|
|
}
|
|
if pathWithin(parent, parent+"-other/file") {
|
|
t.Fatal("sibling prefix was treated as inside the workspace")
|
|
}
|
|
}
|
|
|
|
func TestPullRequestDiffUsesAuthenticatedGHESRESTEndpoint(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/api/v3/repos/owner/repo/pulls/12" {
|
|
t.Fatalf("path = %q", request.URL.Path)
|
|
}
|
|
if request.Header.Get("Authorization") != "Bearer token" {
|
|
t.Fatalf("authorization = %q", request.Header.Get("Authorization"))
|
|
}
|
|
if request.Header.Get("Accept") != "application/vnd.github.v3.diff" {
|
|
t.Fatalf("accept = %q", request.Header.Get("Accept"))
|
|
}
|
|
_, _ = writer.Write([]byte("diff --git a/a.go b/a.go\n"))
|
|
}))
|
|
defer server.Close()
|
|
client := NewGitHubClient(server.URL+"/api/graphql", "token")
|
|
diff, err := client.PullRequestDiff(context.Background(), "owner", "repo", 12)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(diff, "diff --git") {
|
|
t.Fatalf("diff = %q", diff)
|
|
}
|
|
}
|
|
|
|
func TestAIStoreIsPrivateAndMarksOldHeadOutdated(t *testing.T) {
|
|
store := NewAIStore(t.TempDir())
|
|
pr := PRDetails{PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 7}, HeadOID: "head-1"}
|
|
state, err := store.Load(pr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state.Threads = []ReviewThread{{
|
|
ID: "local-ai-x", Path: "main.go", Line: 3, HeadOID: "head-1",
|
|
Origin: reviewOriginLocalAI,
|
|
}}
|
|
if err := store.Save(pr, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(store.path(pr))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("store mode = %o, want 600", info.Mode().Perm())
|
|
}
|
|
pr.HeadOID = "head-2"
|
|
merged := state.Merge(pr)
|
|
if len(merged.Threads) != 1 || !merged.Threads[0].IsOutdated {
|
|
t.Fatalf("merged threads = %#v", merged.Threads)
|
|
}
|
|
}
|
|
|
|
type fakeAIProvider struct {
|
|
response AIInferenceResponse
|
|
requests []AIInferenceRequest
|
|
}
|
|
|
|
func (p *fakeAIProvider) Name() string { return "fake" }
|
|
func (p *fakeAIProvider) Status(context.Context) AIProviderStatus {
|
|
return AIProviderStatus{Ready: true, Summary: "ready", Model: "gpt-test"}
|
|
}
|
|
func (p *fakeAIProvider) Generate(_ context.Context, request AIInferenceRequest) (AIInferenceResponse, error) {
|
|
p.requests = append(p.requests, request)
|
|
return p.response, nil
|
|
}
|
|
|
|
type fakeStreamingAIProvider struct {
|
|
fakeAIProvider
|
|
progress []AIProviderProgress
|
|
}
|
|
|
|
func (p *fakeStreamingAIProvider) GenerateWithProgress(
|
|
_ context.Context,
|
|
request AIInferenceRequest,
|
|
report func(AIProviderProgress),
|
|
) (AIInferenceResponse, error) {
|
|
p.requests = append(p.requests, request)
|
|
for _, progress := range p.progress {
|
|
report(progress)
|
|
}
|
|
return p.response, nil
|
|
}
|
|
|
|
type fakeAIDiffService struct{ diff string }
|
|
|
|
func (s fakeAIDiffService) PullRequestDiff(context.Context, string, string, int) (string, error) {
|
|
return s.diff, nil
|
|
}
|
|
|
|
func TestAIControllerAcceptsOnlyChangedLinesAndDeduplicates(t *testing.T) {
|
|
output := aiOutput{}
|
|
output.Findings = append(output.Findings, aiFinding{
|
|
Path: "main.go", Side: "RIGHT", StartLine: 2, EndLine: 2,
|
|
Severity: "high", Title: "Bug", Body: "This is broken.",
|
|
Suggestion: "var broken = false",
|
|
})
|
|
output.Findings = append(output.Findings, aiFinding{
|
|
Path: "main.go", Side: "RIGHT", StartLine: 99, EndLine: 99,
|
|
Severity: "high", Title: "Invented", Body: "Not changed.",
|
|
})
|
|
content, _ := json.Marshal(output)
|
|
provider := &fakeAIProvider{response: AIInferenceResponse{Model: "gpt-test", Content: content}}
|
|
config := defaultAIConfig()
|
|
config.Enabled = true
|
|
config.MaxRequestBytes = 16_000
|
|
config.MaxRunBytes = 32_000
|
|
store := NewAIStore(filepath.Join(t.TempDir(), "ai"))
|
|
controller := &AIController{
|
|
config: config, provider: provider,
|
|
diffs: fakeAIDiffService{diff: `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -1 +1,2 @@
|
|
package main
|
|
+var broken = true
|
|
`},
|
|
store: store,
|
|
}
|
|
pr := PRDetails{
|
|
PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1, Title: "PR"},
|
|
HeadOID: "abc",
|
|
}
|
|
preview, err := controller.Prepare(context.Background(), pr, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := controller.Run(context.Background(), preview)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Findings != 1 {
|
|
t.Fatalf("findings = %d, want 1", result.Findings)
|
|
}
|
|
if len(result.Details.Threads) != 1 ||
|
|
!strings.Contains(result.Details.Threads[0].Comments[0].DiffHunk, "var broken") {
|
|
t.Fatalf("local finding did not retain its review-time hunk: %#v", result.Details.Threads)
|
|
}
|
|
parsed := parseCommentBody(result.Details.Threads[0].Comments[0].Body)
|
|
if len(parsed.Suggestions) != 1 || parsed.Suggestions[0] != "var broken = false" {
|
|
t.Fatalf("AI suggestion = %#v", parsed.Suggestions)
|
|
}
|
|
second, err := controller.Run(context.Background(), preview)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.Findings != 0 {
|
|
t.Fatalf("duplicate findings = %d, want 0", second.Findings)
|
|
}
|
|
if len(provider.requests) != 2 || provider.requests[0].Model != "gpt-test" {
|
|
t.Fatalf("requests = %#v", provider.requests)
|
|
}
|
|
}
|
|
|
|
func TestAIProviderTestUsesOneMinimalRequestAndForwardsProgress(t *testing.T) {
|
|
provider := &fakeStreamingAIProvider{
|
|
fakeAIProvider: fakeAIProvider{response: AIInferenceResponse{
|
|
Model: "gpt-test", Content: []byte(`{"ok":true}`),
|
|
}},
|
|
progress: []AIProviderProgress{{Kind: "reasoning", Text: "Checking response shape"}},
|
|
}
|
|
config := defaultAIConfig()
|
|
config.Enabled = true
|
|
controller := &AIController{config: config, provider: provider}
|
|
var progress []AIRunProgress
|
|
model, err := controller.TestProvider(context.Background(), func(update AIRunProgress) {
|
|
progress = append(progress, update)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if model != "gpt-test" || len(provider.requests) != 1 {
|
|
t.Fatalf("model=%q requests=%d", model, len(provider.requests))
|
|
}
|
|
request := provider.requests[0]
|
|
if len(request.Prompt) > 100 || strings.Contains(request.Prompt, "PR ") ||
|
|
strings.Contains(request.Prompt, "DIFF") {
|
|
t.Fatalf("provider test sent non-minimal or PR-related input: %q", request.Prompt)
|
|
}
|
|
if len(progress) < 3 || progress[1].SummaryKind != "reasoning" ||
|
|
progress[len(progress)-1].CompletedCalls != 1 {
|
|
t.Fatalf("progress = %#v", progress)
|
|
}
|
|
}
|
|
|
|
func TestCodexProgressWriterHandlesFragmentedReasoningEvents(t *testing.T) {
|
|
var updates []AIProviderProgress
|
|
output := &limitedBuffer{limit: 4096}
|
|
writer := &codexProgressWriter{
|
|
output: output,
|
|
report: func(progress AIProviderProgress) {
|
|
updates = append(updates, progress)
|
|
},
|
|
}
|
|
first := `{"type":"item.completed","item":{"type":"reasoning","text":"Check`
|
|
second := "ing\\u001b[31m result\"}}\n"
|
|
if _, err := writer.Write([]byte(first)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(updates) != 0 {
|
|
t.Fatalf("reported incomplete event: %#v", updates)
|
|
}
|
|
if _, err := writer.Write([]byte(second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(updates) != 1 || updates[0].Kind != "reasoning" ||
|
|
updates[0].Text != "Checking[31m result" ||
|
|
strings.ContainsRune(updates[0].Text, '\x1b') {
|
|
t.Fatalf("updates = %#v", updates)
|
|
}
|
|
}
|
|
|
|
func TestAIProgressBarHasStableWidth(t *testing.T) {
|
|
first := renderAIProgressBar(20, AIRunProgress{TotalCalls: 3}, 0)
|
|
second := renderAIProgressBar(20, AIRunProgress{TotalCalls: 3, CompletedCalls: 2}, 7)
|
|
if ansi.StringWidth(first) != ansi.StringWidth(second) {
|
|
t.Fatalf("progress bar width changed: %q (%d), %q (%d)",
|
|
first, ansi.StringWidth(first), second, ansi.StringWidth(second))
|
|
}
|
|
}
|
|
|
|
func TestValidatedAISuggestionRejectsUnsafeOrUncontainedReplacement(t *testing.T) {
|
|
base := aiFinding{
|
|
Path: "main.go", Side: "RIGHT", StartLine: 2, EndLine: 2,
|
|
Suggestion: " replacement()",
|
|
}
|
|
if got := validatedAISuggestion(base, map[int]bool{2: true}); got != " replacement()" {
|
|
t.Fatalf("valid suggestion = %q", got)
|
|
}
|
|
left := base
|
|
left.Side = "LEFT"
|
|
if got := validatedAISuggestion(left, map[int]bool{2: true}); got != "" {
|
|
t.Fatalf("LEFT-side suggestion accepted: %q", got)
|
|
}
|
|
fenced := base
|
|
fenced.Suggestion = "```go\nreplacement()\n```"
|
|
if got := validatedAISuggestion(fenced, map[int]bool{2: true}); got != "" {
|
|
t.Fatalf("fenced suggestion accepted: %q", got)
|
|
}
|
|
uncontained := base
|
|
uncontained.EndLine = 3
|
|
if got := validatedAISuggestion(uncontained, map[int]bool{2: true}); got != "" {
|
|
t.Fatalf("suggestion over unchanged line accepted: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExistingLocalAIFindingCanGainSuggestion(t *testing.T) {
|
|
finding := aiFinding{
|
|
Path: "main.go", Side: "RIGHT", StartLine: 2, EndLine: 2,
|
|
Severity: "high", Title: "Bug", Body: "This is broken.",
|
|
Suggestion: "fixed()",
|
|
}
|
|
fingerprint := aiFingerprint(
|
|
finding.Path, finding.StartLine, finding.EndLine, finding.Title, finding.Body,
|
|
)
|
|
state := &aiStoredState{
|
|
Version: aiStoreVersion, Annotations: make(map[string][]ReviewComment),
|
|
Threads: []ReviewThread{{
|
|
ID: "local-ai-" + fingerprint, Fingerprint: fingerprint,
|
|
Origin: reviewOriginLocalAI,
|
|
Comments: []ReviewComment{{Body: "**high: Bug**\n\nThis is broken."}},
|
|
}},
|
|
}
|
|
added, _ := state.Apply(
|
|
PRDetails{}, aiOutput{Findings: []aiFinding{finding}}, "fake", "model", "", "",
|
|
map[string]map[int]bool{"main.go": {2: true}}, nil, nil,
|
|
)
|
|
if added != 0 {
|
|
t.Fatalf("duplicate finding count = %d", added)
|
|
}
|
|
suggestions := parseCommentBody(state.Threads[0].Comments[0].Body).Suggestions
|
|
if len(suggestions) != 1 || suggestions[0] != "fixed()" {
|
|
t.Fatalf("enriched suggestions = %#v", suggestions)
|
|
}
|
|
}
|
|
|
|
func TestAIStoreSkipsUnchangedWrites(t *testing.T) {
|
|
store := NewAIStore(t.TempDir())
|
|
pr := PRDetails{PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 2}}
|
|
state, _ := store.Load(pr)
|
|
if err := store.Save(pr, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, _ := os.Stat(store.path(pr))
|
|
time.Sleep(20 * time.Millisecond)
|
|
if err := store.Save(pr, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, _ := os.Stat(store.path(pr))
|
|
if !first.ModTime().Equal(second.ModTime()) {
|
|
t.Fatal("unchanged AI state rewrote the file")
|
|
}
|
|
}
|
|
|
|
func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) {
|
|
remote := ReviewThread{
|
|
ID: "remote", Comments: []ReviewComment{
|
|
{ID: "github"},
|
|
{ID: "local", Origin: reviewOriginLocalAI},
|
|
{ID: "local-user", Origin: reviewOriginLocalAIUser},
|
|
},
|
|
}
|
|
local := ReviewThread{ID: "local-thread", Origin: reviewOriginLocalAI}
|
|
pr := PRDetails{Threads: []ReviewThread{remote, local}}
|
|
clean := withoutLocalAI(pr)
|
|
if len(clean.Threads) != 1 || len(clean.Threads[0].Comments) != 1 {
|
|
t.Fatalf("clean details = %#v", clean.Threads)
|
|
}
|
|
if len(pr.Threads) != 2 || len(pr.Threads[0].Comments) != 3 {
|
|
t.Fatal("filter mutated the visible PR details")
|
|
}
|
|
}
|
|
|
|
func TestAIDiscussionStoresUserMessageBeforeProviderResponse(t *testing.T) {
|
|
state := &aiStoredState{
|
|
Version: 1, Annotations: make(map[string][]ReviewComment),
|
|
}
|
|
pr := PRDetails{Threads: []ReviewThread{{ID: "thread-1"}}}
|
|
output := aiOutput{ThreadComments: []struct {
|
|
ThreadID string `json:"thread_id"`
|
|
Body string `json:"body"`
|
|
}{{ThreadID: "thread-1", Body: "Provider response"}}}
|
|
|
|
_, added := state.Apply(
|
|
pr, output, "codex", "model", "thread-1", "User follow-up", nil, nil, nil,
|
|
)
|
|
comments := state.Annotations["thread-1"]
|
|
if added != 1 || len(comments) != 2 {
|
|
t.Fatalf("added=%d comments=%#v", added, comments)
|
|
}
|
|
if comments[0].Origin != reviewOriginLocalAIUser ||
|
|
comments[0].Author != "you" || comments[0].Body != "User follow-up" {
|
|
t.Fatalf("user message = %#v", comments[0])
|
|
}
|
|
if comments[1].Origin != reviewOriginLocalAI ||
|
|
comments[1].Body != "Provider response" {
|
|
t.Fatalf("provider response = %#v", comments[1])
|
|
}
|
|
}
|
|
|
|
func TestAIDiscussionUsesViewerGitHubLogin(t *testing.T) {
|
|
state := &aiStoredState{
|
|
Version: 1, Annotations: make(map[string][]ReviewComment),
|
|
}
|
|
pr := PRDetails{
|
|
ViewerLogin: "pablu",
|
|
Threads: []ReviewThread{{ID: "thread-1"}},
|
|
}
|
|
state.Apply(pr, aiOutput{}, "codex", "model", "thread-1", "Follow-up", nil, nil, nil)
|
|
comments := state.Annotations["thread-1"]
|
|
if len(comments) != 1 || comments[0].Author != "pablu" ||
|
|
comments[0].Origin != reviewOriginLocalAIUser {
|
|
t.Fatalf("local user comment = %#v", comments)
|
|
}
|
|
}
|
|
|
|
func TestReplyOnLocalAIThreadStartsInlineDiscussion(t *testing.T) {
|
|
config := defaultAIConfig()
|
|
config.Enabled = true
|
|
store := NewAIStore(t.TempDir())
|
|
controller := &AIController{config: config, store: store}
|
|
app := NewAppWithSettings(nil, "", "", false, 10, time.Minute, AppSettings{
|
|
FoldResolved: true, ThreadListWidthPercent: 33,
|
|
ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"},
|
|
ThreadWithinStatus: "file", DashboardMode: "hotkey", EditorMode: "vim",
|
|
KeyBindings: defaultKeyBindings(), AI: controller, AIStore: store,
|
|
})
|
|
app.screen = threadScreen
|
|
app.details.Threads = []ReviewThread{{ID: "local", Origin: reviewOriginLocalAI}}
|
|
app.startReply()
|
|
if app.aiMode != aiDiscussion || app.writeThreadID != "local" {
|
|
t.Fatalf("AI mode=%v thread=%q", app.aiMode, app.writeThreadID)
|
|
}
|
|
}
|
|
|
|
func TestAITextSimilarityDetectsCloseRestatement(t *testing.T) {
|
|
left := "This nil pointer can panic when the response body is absent"
|
|
right := "The absent response body causes a nil pointer panic"
|
|
if got := aiTextSimilarity(left, right); got < 0.68 {
|
|
t.Fatalf("similarity = %v, want close restatement", got)
|
|
}
|
|
if rangesNear(10, 12, 30, 31) {
|
|
t.Fatal("distant line ranges were considered overlapping")
|
|
}
|
|
}
|