| 💾 Saved. d722711 k33g 7h ago | 1 | // The cases below are the `edit` CLI's own tests (tools/edit/internal/*), |
| 2 | // ported: what they guard is that the built-in tools refuse exactly what the |
| 3 | // CLI refuses, in words a model can act on — otherwise the A/B comparison of |
| 4 | // this part would measure two different tools. |
| 5 | package fileedit |
| 6 | |
| 7 | import ( |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | func inDir(t *testing.T, name, content string) string { |
| 16 | t.Helper() |
| 17 | path := filepath.Join(t.TempDir(), name) |
| 18 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | return path |
| 22 | } |
| 23 | |
| 24 | func fileOf(t *testing.T, path string) string { |
| 25 | t.Helper() |
| 26 | data, err := os.ReadFile(path) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | return string(data) |
| 31 | } |
| 32 | |
| 33 | func TestApply(t *testing.T) { |
| 34 | cases := []struct { |
| 35 | name string |
| 36 | body string |
| 37 | edits []Edit |
| 38 | want string |
| 39 | }{ |
| 40 | {"one replacement", "const port = 3000;\n", |
| 41 | []Edit{{"const port = 3000;", "const port = envPort(3000);"}}, "const port = envPort(3000);\n"}, |
| 42 | {"two independent edits, resolved against the original", "HOST = \"localhost\"\nPORT = 3000\n", |
| 43 | []Edit{{`HOST = "localhost"`, `HOST = env("HOST", "localhost")`}, {`PORT = 3000`, `PORT = envInt("PORT", 3000)`}}, |
| 44 | "HOST = env(\"HOST\", \"localhost\")\nPORT = envInt(\"PORT\", 3000)\n"}, |
| 45 | {"the order of the edits does not change the result", "a\nb\nc\n", |
| 46 | []Edit{{"c", "C"}, {"a", "A"}}, "A\nb\nC\n"}, |
| 47 | {"an empty new deletes", "keep\nto delete\nkeep too\n", |
| 48 | []Edit{{"to delete\n", ""}}, "keep\nkeep too\n"}, |
| 49 | {"the old text spans several lines", "func f() {\n\treturn 1\n}\n", |
| 50 | []Edit{{"func f() {\n\treturn 1\n}", "func f() int {\n\treturn 2\n}"}}, "func f() int {\n\treturn 2\n}\n"}, |
| 51 | {"an edit is NOT re-read by the next one", "x\ny\n", |
| 52 | []Edit{{"x", "y"}, {"y", "z"}}, "y\nz\n"}, |
| 53 | } |
| 54 | for _, tc := range cases { |
| 55 | t.Run(tc.name, func(t *testing.T) { |
| 56 | got, err := apply(tc.body, tc.edits) |
| 57 | if err != nil { |
| 58 | t.Fatalf("apply: %v", err) |
| 59 | } |
| 60 | if got != tc.want { |
| 61 | t.Errorf("apply = %q, want %q", got, tc.want) |
| 62 | } |
| 63 | }) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // TestApplyRefuses guards the rules that make an edit verifiable. Each message |
| 68 | // is read by a MODEL, which must be able to fix the call without guessing: the |
| 69 | // test also checks that the message carries the fix. |
| 70 | func TestApplyRefuses(t *testing.T) { |
| 71 | cases := []struct { |
| 72 | name string |
| 73 | body string |
| 74 | edits []Edit |
| 75 | says string |
| 76 | }{ |
| 77 | {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"}, |
| 78 | {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"}, |
| 79 | {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n", |
| 80 | []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}}, |
| 81 | "overlap"}, |
| 82 | {"empty old", "a\n", []Edit{{"", "b"}}, "empty old text"}, |
| 83 | {"an edit that changes nothing", "a\n", []Edit{{"a", "a"}}, "changes nothing"}, |
| 84 | {"no edit at all", "a\n", nil, "no edit given"}, |
| 85 | } |
| 86 | for _, tc := range cases { |
| 87 | t.Run(tc.name, func(t *testing.T) { |
| 88 | got, err := apply(tc.body, tc.edits) |
| 89 | if err == nil { |
| 90 | t.Fatalf("apply succeeded, want an error — result %q", got) |
| 91 | } |
| 92 | if !strings.Contains(err.Error(), tc.says) { |
| 93 | t.Errorf("error = %q, want it to contain %q", err, tc.says) |
| 94 | } |
| 95 | if got != "" { |
| 96 | t.Errorf("apply returned %q along with the error: nothing must be written", got) |
| 97 | } |
| 98 | }) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestRead(t *testing.T) { |
| 103 | path := inDir(t, "f.txt", "one\ntwo\nthree\n") |
| 104 | |
| 105 | got, err := Read(path, 0, 0, false) |
| 106 | if err != nil || got != "one\ntwo\nthree\n" { |
| 107 | t.Errorf("Read = (%q, %v)", got, err) |
| 108 | } |
| 109 | got, err = Read(path, 2, 2, true) |
| 110 | if err != nil || got != " 2 two\n" { |
| 111 | t.Errorf("Read 2-2 numbered = (%q, %v)", got, err) |
| 112 | } |
| 113 | if _, err := Read(filepath.Join(filepath.Dir(path), "nope.txt"), 0, 0, false); err == nil || !strings.Contains(err.Error(), "does not exist") { |
| 114 | t.Errorf("Read of a missing file = %v", err) |
| 115 | } |
| 116 | if _, err := Read(path, 99, 0, false); err == nil || !strings.Contains(err.Error(), "past the end") { |
| 117 | t.Errorf("Read start 99 = %v", err) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestWriteCreatesAndReports(t *testing.T) { |
| 122 | path := filepath.Join(t.TempDir(), "notes.md") |
| 123 | |
| 124 | r, err := Write(path, "# Notes") |
| 125 | if err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if got := fileOf(t, path); got != "# Notes\n" { |
| 129 | t.Errorf("file = %q, want %q — the final newline is missing", got, "# Notes\n") |
| 130 | } |
| 131 | if !r.Created || !strings.Contains(r.Headline, "created") { |
| 132 | t.Errorf("Result = %+v, want it to announce the creation", r) |
| 133 | } |
| 134 | // Rewriting the same content must do nothing, and SAY so. |
| 135 | r, err = Write(path, "# Notes") |
| 136 | if err != nil || r.Changed || !strings.Contains(r.Headline, "unchanged") { |
| 137 | t.Errorf("identical Write = (%+v, %v)", r, err) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestReplace(t *testing.T) { |
| 142 | path := inDir(t, "server.go", "port := 3000\nhost := \"local\"\n") |
| 143 | |
| 144 | r, err := Replace(path, []Edit{{"port := 3000", "port := envPort(3000)"}}, false) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if got := fileOf(t, path); got != "port := envPort(3000)\nhost := \"local\"\n" { |
| 149 | t.Errorf("file = %q", got) |
| 150 | } |
| 151 | if r.FirstChangedLine != 1 || !strings.Contains(r.Headline, "first change at line 1") || !strings.Contains(r.Diff, "+ port := envPort(3000)") { |
| 152 | t.Errorf("Result = %+v", r) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestReplaceIsAllOrNothing: the second edit is impossible, so the first must |
| 157 | // not have happened. A half-edited file is the worst result — it looks like a |
| 158 | // success. |
| 159 | func TestReplaceIsAllOrNothing(t *testing.T) { |
| 160 | const before = "a = 1\nb = 2\n" |
| 161 | path := inDir(t, "f.txt", before) |
| 162 | _, err := Replace(path, []Edit{{"a = 1", "a = 10"}, {"absent", "x"}}, false) |
| 163 | if err == nil || !strings.Contains(err.Error(), "not found") { |
| 164 | t.Errorf("err = %v", err) |
| 165 | } |
| 166 | if got := fileOf(t, path); got != before { |
| 167 | t.Errorf("file = %q, want unchanged %q", got, before) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestReplaceRefusesAMissingFile(t *testing.T) { |
| 172 | _, err := Replace(filepath.Join(t.TempDir(), "nope.txt"), []Edit{{"a", "b"}}, false) |
| 173 | if err == nil || !strings.Contains(err.Error(), "does not exist") { |
| 174 | t.Errorf("err = %v", err) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestDryRunWritesNothing(t *testing.T) { |
| 179 | const before = "a = 1\n" |
| 180 | path := inDir(t, "f.txt", before) |
| 181 | r, err := Replace(path, []Edit{{"a = 1", "a = 2"}}, true) |
| 182 | if err != nil || !r.DryRun || !strings.Contains(r.Headline, "dry run") || !strings.Contains(r.Diff, "+ a = 2") { |
| 183 | t.Errorf("dry run = (%+v, %v)", r, err) |
| 184 | } |
| 185 | if got := fileOf(t, path); got != before { |
| 186 | t.Errorf("file = %q, want unchanged", got) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // TestShapeSurvives: BOM and CRLF are removed for the edit and put back on |
| 191 | // write, else every line of a Windows file would show up as changed. |
| 192 | func TestShapeSurvives(t *testing.T) { |
| 193 | cases := []struct{ name, before, old, new, after string }{ |
| 194 | {"CRLF kept", "a\r\nb\r\n", "b", "B", "a\r\nB\r\n"}, |
| 195 | {"BOM kept", "\uFEFFa\n", "a", "A", "\uFEFFA\n"}, |
| 196 | {"plain LF", "a\n", "a", "A", "A\n"}, |
| 197 | } |
| 198 | for _, tc := range cases { |
| 199 | t.Run(tc.name, func(t *testing.T) { |
| 200 | path := inDir(t, "f.txt", tc.before) |
| 201 | if _, err := Replace(path, []Edit{{tc.old, tc.new}}, false); err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | if got := fileOf(t, path); got != tc.after { |
| 205 | t.Errorf("file = %q, want %q", got, tc.after) |
| 206 | } |
| 207 | }) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // TestWriteKeepsTheMode: an executable script that gets edited must stay |
| 212 | // executable. |
| 213 | func TestWriteKeepsTheMode(t *testing.T) { |
| 214 | path := inDir(t, "s.sh", "echo a\n") |
| 215 | if err := os.Chmod(path, 0o755); err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | if _, err := Write(path, "echo b\n"); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | info, _ := os.Stat(path) |
| 222 | if info.Mode().Perm() != 0o755 { |
| 223 | t.Errorf("mode = %v, want 755", info.Mode().Perm()) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestWriteCreatesParentDirs(t *testing.T) { |
| 228 | path := filepath.Join(t.TempDir(), "a", "b", "notes.md") |
| 229 | if _, err := Write(path, "# Notes\n"); err != nil { |
| 230 | t.Fatalf("Write: %v", err) |
| 231 | } |
| 232 | if got := fileOf(t, path); got != "# Notes\n" { |
| 233 | t.Errorf("file = %q", got) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func TestFirstChanged(t *testing.T) { |
| 238 | cases := []struct { |
| 239 | name string |
| 240 | old, new string |
| 241 | want int |
| 242 | }{ |
| 243 | {"nothing changed", "a\nb\n", "a\nb\n", 0}, |
| 244 | {"the second line", "a\nb\nc\n", "a\nB\nc\n", 2}, |
| 245 | {"an addition at the top", "a\n", "zero\na\n", 1}, |
| 246 | {"a deletion", "a\nb\nc\n", "a\nc\n", 2}, |
| 247 | {"a created file", "", "a\n", 1}, |
| 248 | } |
| 249 | for _, tc := range cases { |
| 250 | t.Run(tc.name, func(t *testing.T) { |
| 251 | if got := compare(tc.old, tc.new).firstChanged(); got != tc.want { |
| 252 | t.Errorf("firstChanged = %d, want %d", got, tc.want) |
| 253 | } |
| 254 | }) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestRenderHasNoANSI(t *testing.T) { |
| 259 | out := compare("a\nb\n", "a\nB\n").render() |
| 260 | if strings.Contains(out, "\033") || !strings.Contains(out, "- b") || !strings.Contains(out, "+ B") { |
| 261 | t.Errorf("render = %q", out) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // TestUnifiedMatchesGitApply is the test that counts: a patch that "looks |
| 266 | // like" a unified patch is useless. git APPLIES it, and refuses anything that |
| 267 | // is not exact — headers, line numbers, counts, end-of-file marker. |
| 268 | func TestUnifiedMatchesGitApply(t *testing.T) { |
| 269 | if _, err := exec.LookPath("git"); err != nil { |
| 270 | t.Skip("git absent") |
| 271 | } |
| 272 | cases := []struct{ name, old, new string }{ |
| 273 | {"one line in the middle", "a\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"}, |
| 274 | {"two distant areas", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", "1\n2\nTHREE\n4\n5\n6\n7\n8\n9\n10\n11\n12\nTHIRTEEN\n14\n15\n"}, |
| 275 | {"two neighbouring areas: one hunk", "1\n2\n3\n4\n5\n6\n7\n8\n", "1\n2\nTHREE\n4\nFIVE\n6\n7\n8\n"}, |
| 276 | {"addition at the top", "a\nb\n", "zero\na\nb\n"}, |
| 277 | {"deletion at the tail", "a\nb\nc\n", "a\nb\n"}, |
| 278 | {"everything replaced", "a\nb\n", "x\ny\nz\n"}, |
| 279 | {"no final newline", "a\nb\nc", "a\nB\nc"}, |
| 280 | {"final newline added", "a\nb", "a\nb\n"}, |
| 281 | {"file emptied", "a\nb\n", ""}, |
| 282 | } |
| 283 | for _, tc := range cases { |
| 284 | t.Run(tc.name, func(t *testing.T) { |
| 285 | patch := compare(tc.old, tc.new).unified("f.txt") |
| 286 | if patch == "" { |
| 287 | t.Fatal("empty patch") |
| 288 | } |
| 289 | dir := t.TempDir() |
| 290 | run := func(args ...string) { |
| 291 | t.Helper() |
| 292 | cmd := exec.Command("git", args...) |
| 293 | cmd.Dir = dir |
| 294 | if out, err := cmd.CombinedOutput(); err != nil { |
| 295 | t.Fatalf("git %s: %v\n%s\npatch:\n%s", strings.Join(args, " "), err, out, patch) |
| 296 | } |
| 297 | } |
| 298 | run("init", "-q") |
| 299 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte(tc.old), 0o644) |
| 300 | os.WriteFile(filepath.Join(dir, "p.diff"), []byte(patch), 0o644) |
| 301 | run("apply", "p.diff") |
| 302 | if got := fileOf(t, filepath.Join(dir, "f.txt")); got != tc.new { |
| 303 | t.Errorf("after apply: %q, want %q\npatch:\n%s", got, tc.new, patch) |
| 304 | } |
| 305 | }) |
| 306 | } |
| 307 | } |