|
| 1 | +package linter |
| 2 | + |
| 3 | +import ( |
| 4 | + "testing" |
| 5 | + |
| 6 | + "github.com/microsoft/typescript-go/shim/core" |
| 7 | + "github.com/web-infra-dev/rslint/internal/rule" |
| 8 | +) |
| 9 | + |
| 10 | +// mockDiagnostic implements LintMessage interface for testing |
| 11 | +type mockDiagnostic struct { |
| 12 | + fixes []rule.RuleFix |
| 13 | +} |
| 14 | + |
| 15 | +func (m mockDiagnostic) Fixes() []rule.RuleFix { |
| 16 | + return m.fixes |
| 17 | +} |
| 18 | + |
| 19 | +func newMockDiagnostic(fixes ...rule.RuleFix) mockDiagnostic { |
| 20 | + return mockDiagnostic{fixes: fixes} |
| 21 | +} |
| 22 | + |
| 23 | +func newInsertFix(pos int, text string) rule.RuleFix { |
| 24 | + return rule.RuleFix{ |
| 25 | + Text: text, |
| 26 | + Range: core.NewTextRange(pos, pos), |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +func newReplaceFix(start, end int, text string) rule.RuleFix { |
| 31 | + return rule.RuleFix{ |
| 32 | + Text: text, |
| 33 | + Range: core.NewTextRange(start, end), |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +func TestApplyRuleFixes(t *testing.T) { |
| 38 | + tests := []struct { |
| 39 | + name string |
| 40 | + code string |
| 41 | + diagnostics []mockDiagnostic |
| 42 | + expectedCode string |
| 43 | + expectedUnapplied int |
| 44 | + expectedFixedStatus bool |
| 45 | + }{ |
| 46 | + { |
| 47 | + name: "single insertion fix", |
| 48 | + code: "function foo() {}", |
| 49 | + diagnostics: []mockDiagnostic{ |
| 50 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 51 | + }, |
| 52 | + expectedCode: "async function foo() {}", |
| 53 | + expectedUnapplied: 0, |
| 54 | + expectedFixedStatus: true, |
| 55 | + }, |
| 56 | + { |
| 57 | + name: "multiple non-overlapping insertion fixes", |
| 58 | + code: "function foo() { return bar(); }", |
| 59 | + diagnostics: []mockDiagnostic{ |
| 60 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 61 | + newMockDiagnostic(newInsertFix(24, "await ")), |
| 62 | + }, |
| 63 | + expectedCode: "async function foo() { return await bar(); }", |
| 64 | + expectedUnapplied: 0, |
| 65 | + expectedFixedStatus: true, |
| 66 | + }, |
| 67 | + { |
| 68 | + name: "duplicate insertion fixes at same position - should skip duplicates", |
| 69 | + code: "function foo() {}", |
| 70 | + diagnostics: []mockDiagnostic{ |
| 71 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 72 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 73 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 74 | + }, |
| 75 | + expectedCode: "async function foo() {}", |
| 76 | + expectedUnapplied: 2, |
| 77 | + expectedFixedStatus: true, |
| 78 | + }, |
| 79 | + { |
| 80 | + name: "overlapping replacement fixes - should skip overlapping", |
| 81 | + code: "const foo = 'bar';", |
| 82 | + diagnostics: []mockDiagnostic{ |
| 83 | + newMockDiagnostic(newReplaceFix(6, 9, "baz")), |
| 84 | + newMockDiagnostic(newReplaceFix(8, 11, "qux")), |
| 85 | + }, |
| 86 | + expectedCode: "const baz = 'bar';", |
| 87 | + expectedUnapplied: 1, |
| 88 | + expectedFixedStatus: true, |
| 89 | + }, |
| 90 | + { |
| 91 | + name: "consecutive replacement fixes - should apply both", |
| 92 | + code: "const foo = bar;", |
| 93 | + diagnostics: []mockDiagnostic{ |
| 94 | + newMockDiagnostic(newReplaceFix(6, 9, "baz")), |
| 95 | + newMockDiagnostic(newReplaceFix(12, 15, "qux")), |
| 96 | + }, |
| 97 | + expectedCode: "const baz = qux;", |
| 98 | + expectedUnapplied: 0, |
| 99 | + expectedFixedStatus: true, |
| 100 | + }, |
| 101 | + { |
| 102 | + name: "adjacent replacement fixes (end == start) - should apply both", |
| 103 | + code: "abcdef", |
| 104 | + diagnostics: []mockDiagnostic{ |
| 105 | + newMockDiagnostic(newReplaceFix(0, 3, "XXX")), |
| 106 | + newMockDiagnostic(newReplaceFix(3, 6, "YYY")), |
| 107 | + }, |
| 108 | + expectedCode: "XXXYYY", |
| 109 | + expectedUnapplied: 0, |
| 110 | + expectedFixedStatus: true, |
| 111 | + }, |
| 112 | + { |
| 113 | + name: "no fixes", |
| 114 | + code: "const foo = 'bar';", |
| 115 | + diagnostics: []mockDiagnostic{}, |
| 116 | + expectedCode: "const foo = 'bar';", |
| 117 | + expectedUnapplied: 0, |
| 118 | + expectedFixedStatus: false, |
| 119 | + }, |
| 120 | + { |
| 121 | + name: "diagnostic without fixes", |
| 122 | + code: "const foo = 'bar';", |
| 123 | + diagnostics: []mockDiagnostic{ |
| 124 | + {fixes: []rule.RuleFix{}}, |
| 125 | + }, |
| 126 | + expectedCode: "const foo = 'bar';", |
| 127 | + expectedUnapplied: 1, |
| 128 | + expectedFixedStatus: false, |
| 129 | + }, |
| 130 | + { |
| 131 | + name: "mixed: insertion at position 0 followed by another at same position", |
| 132 | + code: "foo()", |
| 133 | + diagnostics: []mockDiagnostic{ |
| 134 | + newMockDiagnostic(newInsertFix(0, "await ")), |
| 135 | + newMockDiagnostic(newInsertFix(0, "await ")), |
| 136 | + }, |
| 137 | + expectedCode: "await foo()", |
| 138 | + expectedUnapplied: 1, |
| 139 | + expectedFixedStatus: true, |
| 140 | + }, |
| 141 | + { |
| 142 | + name: "insertion followed by replacement at different positions", |
| 143 | + code: "function foo() { return bar; }", |
| 144 | + diagnostics: []mockDiagnostic{ |
| 145 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 146 | + newMockDiagnostic(newReplaceFix(24, 27, "baz")), |
| 147 | + }, |
| 148 | + expectedCode: "async function foo() { return baz; }", |
| 149 | + expectedUnapplied: 0, |
| 150 | + expectedFixedStatus: true, |
| 151 | + }, |
| 152 | + // Tests for diagnostics with multiple fixes |
| 153 | + { |
| 154 | + name: "single diagnostic with multiple fixes at different positions", |
| 155 | + code: "function foo() { return bar(); }", |
| 156 | + diagnostics: []mockDiagnostic{ |
| 157 | + // One diagnostic with two fixes: add async and add await |
| 158 | + newMockDiagnostic(newInsertFix(0, "async "), newInsertFix(24, "await ")), |
| 159 | + }, |
| 160 | + expectedCode: "async function foo() { return await bar(); }", |
| 161 | + expectedUnapplied: 0, |
| 162 | + expectedFixedStatus: true, |
| 163 | + }, |
| 164 | + { |
| 165 | + name: "two diagnostics with multiple fixes - second conflicts with first", |
| 166 | + code: "function foo() { return bar(); }", |
| 167 | + diagnostics: []mockDiagnostic{ |
| 168 | + // First diagnostic: add async at 0, add await at 24 |
| 169 | + newMockDiagnostic(newInsertFix(0, "async "), newInsertFix(24, "await ")), |
| 170 | + // Second diagnostic: tries to insert at position 10 (between first diagnostic's fixes) |
| 171 | + newMockDiagnostic(newInsertFix(10, "CONFLICT")), |
| 172 | + }, |
| 173 | + expectedCode: "async function foo() { return await bar(); }", |
| 174 | + expectedUnapplied: 1, |
| 175 | + expectedFixedStatus: true, |
| 176 | + }, |
| 177 | + { |
| 178 | + name: "duplicate diagnostic with multiple fixes - should skip entire duplicate", |
| 179 | + code: "function foo() { return bar(); }", |
| 180 | + diagnostics: []mockDiagnostic{ |
| 181 | + newMockDiagnostic(newInsertFix(0, "async "), newInsertFix(24, "await ")), |
| 182 | + // Duplicate: same first fix position |
| 183 | + newMockDiagnostic(newInsertFix(0, "async "), newInsertFix(24, "await ")), |
| 184 | + }, |
| 185 | + expectedCode: "async function foo() { return await bar(); }", |
| 186 | + expectedUnapplied: 1, |
| 187 | + expectedFixedStatus: true, |
| 188 | + }, |
| 189 | + // Edge cases |
| 190 | + { |
| 191 | + name: "delete operation (empty replacement text)", |
| 192 | + code: "var foo = 1;", |
| 193 | + diagnostics: []mockDiagnostic{ |
| 194 | + newMockDiagnostic(newReplaceFix(0, 4, "")), // delete "var " |
| 195 | + }, |
| 196 | + expectedCode: "foo = 1;", |
| 197 | + expectedUnapplied: 0, |
| 198 | + expectedFixedStatus: true, |
| 199 | + }, |
| 200 | + { |
| 201 | + name: "insertion at end of code", |
| 202 | + code: "const x = 1", |
| 203 | + diagnostics: []mockDiagnostic{ |
| 204 | + newMockDiagnostic(newInsertFix(11, ";")), |
| 205 | + }, |
| 206 | + expectedCode: "const x = 1;", |
| 207 | + expectedUnapplied: 0, |
| 208 | + expectedFixedStatus: true, |
| 209 | + }, |
| 210 | + { |
| 211 | + name: "replacement at end of code", |
| 212 | + code: "const x = 1", |
| 213 | + diagnostics: []mockDiagnostic{ |
| 214 | + newMockDiagnostic(newReplaceFix(10, 11, "2;")), |
| 215 | + }, |
| 216 | + expectedCode: "const x = 2;", |
| 217 | + expectedUnapplied: 0, |
| 218 | + expectedFixedStatus: true, |
| 219 | + }, |
| 220 | + { |
| 221 | + name: "empty code with insertion", |
| 222 | + code: "", |
| 223 | + diagnostics: []mockDiagnostic{ |
| 224 | + newMockDiagnostic(newInsertFix(0, "hello")), |
| 225 | + }, |
| 226 | + expectedCode: "hello", |
| 227 | + expectedUnapplied: 0, |
| 228 | + expectedFixedStatus: true, |
| 229 | + }, |
| 230 | + { |
| 231 | + name: "different insertions at same position - should skip second", |
| 232 | + code: "function foo() {}", |
| 233 | + diagnostics: []mockDiagnostic{ |
| 234 | + newMockDiagnostic(newInsertFix(0, "async ")), |
| 235 | + newMockDiagnostic(newInsertFix(0, "export ")), // different content, same position |
| 236 | + }, |
| 237 | + expectedCode: "async function foo() {}", |
| 238 | + expectedUnapplied: 1, |
| 239 | + expectedFixedStatus: true, |
| 240 | + }, |
| 241 | + { |
| 242 | + name: "replacement followed by insertion at same end position - should skip insertion", |
| 243 | + code: "ABCDEFGHIJ", |
| 244 | + diagnostics: []mockDiagnostic{ |
| 245 | + newMockDiagnostic(newReplaceFix(0, 5, "XXX")), // Replace ABCDE with XXX |
| 246 | + newMockDiagnostic(newInsertFix(5, "YYY")), // Insert at position 5 |
| 247 | + }, |
| 248 | + expectedCode: "XXXFGHIJ", |
| 249 | + expectedUnapplied: 1, |
| 250 | + expectedFixedStatus: true, |
| 251 | + }, |
| 252 | + { |
| 253 | + name: "insertion followed by replacement starting at same position - should skip replacement", |
| 254 | + code: "ABCDEFGHIJ", |
| 255 | + diagnostics: []mockDiagnostic{ |
| 256 | + newMockDiagnostic(newInsertFix(0, "XXX")), // Insert at 0 |
| 257 | + newMockDiagnostic(newReplaceFix(0, 3, "YYY")), // Replace ABC with YYY |
| 258 | + }, |
| 259 | + expectedCode: "XXXABCDEFGHIJ", |
| 260 | + expectedUnapplied: 1, |
| 261 | + expectedFixedStatus: true, |
| 262 | + }, |
| 263 | + } |
| 264 | + |
| 265 | + for _, tt := range tests { |
| 266 | + t.Run(tt.name, func(t *testing.T) { |
| 267 | + result, unapplied, fixed := ApplyRuleFixes(tt.code, tt.diagnostics) |
| 268 | + |
| 269 | + if result != tt.expectedCode { |
| 270 | + t.Errorf("ApplyRuleFixes() code = %q, want %q", result, tt.expectedCode) |
| 271 | + } |
| 272 | + |
| 273 | + if len(unapplied) != tt.expectedUnapplied { |
| 274 | + t.Errorf("ApplyRuleFixes() unapplied count = %d, want %d", len(unapplied), tt.expectedUnapplied) |
| 275 | + } |
| 276 | + |
| 277 | + if fixed != tt.expectedFixedStatus { |
| 278 | + t.Errorf("ApplyRuleFixes() fixed = %v, want %v", fixed, tt.expectedFixedStatus) |
| 279 | + } |
| 280 | + }) |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +// TestApplyRuleFixes_DuplicateInsertionRegression is a regression test for issue #451 |
| 285 | +// where multiple diagnostics at the same position caused "async async async" to be inserted |
| 286 | +func TestApplyRuleFixes_DuplicateInsertionRegression(t *testing.T) { |
| 287 | + // Simulate what happens when the same file is linted by multiple tsconfig projects |
| 288 | + // Each project reports the same diagnostic, resulting in duplicate fixes |
| 289 | + code := "function fetchData(): Promise<string> { return Promise.resolve('data'); }" |
| 290 | + |
| 291 | + // Three diagnostics from three different tsconfig projects, all pointing to the same fix |
| 292 | + diagnostics := []mockDiagnostic{ |
| 293 | + newMockDiagnostic(newInsertFix(0, " async ")), |
| 294 | + newMockDiagnostic(newInsertFix(0, " async ")), |
| 295 | + newMockDiagnostic(newInsertFix(0, " async ")), |
| 296 | + } |
| 297 | + |
| 298 | + result, unapplied, fixed := ApplyRuleFixes(code, diagnostics) |
| 299 | + |
| 300 | + expectedCode := " async function fetchData(): Promise<string> { return Promise.resolve('data'); }" |
| 301 | + |
| 302 | + if result != expectedCode { |
| 303 | + t.Errorf("Regression test failed: got %q, want %q", result, expectedCode) |
| 304 | + } |
| 305 | + |
| 306 | + if len(unapplied) != 2 { |
| 307 | + t.Errorf("Expected 2 unapplied diagnostics (duplicates), got %d", len(unapplied)) |
| 308 | + } |
| 309 | + |
| 310 | + if !fixed { |
| 311 | + t.Error("Expected fixed to be true") |
| 312 | + } |
| 313 | + |
| 314 | + // Verify we don't have "async async async" |
| 315 | + if result == " async async async function fetchData(): Promise<string> { return Promise.resolve('data'); }" { |
| 316 | + t.Error("Regression: duplicate insertions were not prevented!") |
| 317 | + } |
| 318 | +} |
0 commit comments