Coverage for tests / unit / ai / test_fix_parsing.py: 100%

90 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Tests for parse_fix_response, parse_batch_response, and generate_diff.""" 

2 

3from __future__ import annotations 

4 

5import json 

6 

7from assertpy import assert_that 

8 

9from lintro.ai.fix_parsing import ( 

10 generate_diff, 

11 parse_batch_response, 

12 parse_fix_response, 

13) 

14 

15# --------------------------------------------------------------------------- 

16# generate_diff 

17# --------------------------------------------------------------------------- 

18 

19 

20def test_generate_diff_generates_unified_diff(): 

21 """Verify unified diff output contains expected file headers and change markers.""" 

22 diff = generate_diff("test.py", "old code\n", "new code\n") 

23 assert_that(diff).contains("a/test.py") 

24 assert_that(diff).contains("b/test.py") 

25 assert_that(diff).contains("-old code") 

26 assert_that(diff).contains("+new code") 

27 

28 

29def test_generate_diff_no_diff_for_identical(): 

30 """Verify that identical content produces an empty diff string.""" 

31 diff = generate_diff("test.py", "same\n", "same\n") 

32 assert_that(diff).is_equal_to("") 

33 

34 

35# --------------------------------------------------------------------------- 

36# parse_fix_response 

37# --------------------------------------------------------------------------- 

38 

39 

40def test_parse_fix_response_valid_response(): 

41 """Valid JSON is parsed into a fix suggestion with correct fields.""" 

42 content = json.dumps( 

43 { 

44 "original_code": "assert x > 0", 

45 "suggested_code": "if not x > 0:\n raise ValueError", 

46 "explanation": "Replace assert", 

47 "confidence": "high", 

48 }, 

49 ) 

50 result = parse_fix_response(content, "main.py", 10, "B101") 

51 assert_that(result).is_not_none() 

52 assert_that(result.file).is_equal_to("main.py") # type: ignore[union-attr] # assertpy is_not_none narrows this 

53 assert_that(result.confidence).is_equal_to("high") # type: ignore[union-attr] # assertpy is_not_none narrows this 

54 assert_that(result.diff).is_not_empty() # type: ignore[union-attr] # assertpy is_not_none narrows this 

55 

56 

57def test_parse_fix_response_non_object_json(): 

58 """Non-object JSON (array, string, number) returns None.""" 

59 for payload in ["[1, 2]", '"just a string"', "42"]: 

60 result = parse_fix_response(payload, "main.py", 10, "B101") 

61 assert_that(result).is_none() 

62 

63 

64def test_parse_fix_response_non_string_code_fields(): 

65 """Non-string original_code or suggested_code returns None.""" 

66 content = json.dumps( 

67 { 

68 "original_code": 123, 

69 "suggested_code": ["not", "a", "string"], 

70 "explanation": "Fix", 

71 "confidence": "medium", 

72 }, 

73 ) 

74 result = parse_fix_response(content, "main.py", 10, "B101") 

75 assert_that(result).is_none() 

76 

77 

78def test_parse_fix_response_invalid_json(): 

79 """Verify that invalid JSON content returns None.""" 

80 result = parse_fix_response("not json", "main.py", 10, "B101") 

81 assert_that(result).is_none() 

82 

83 

84def test_parse_fix_response_identical_code(): 

85 """Verify that identical original and suggested code returns None.""" 

86 content = json.dumps( 

87 { 

88 "original_code": "x = 1", 

89 "suggested_code": "x = 1", 

90 "explanation": "No change", 

91 "confidence": "high", 

92 }, 

93 ) 

94 result = parse_fix_response(content, "main.py", 10, "B101") 

95 assert_that(result).is_none() 

96 

97 

98def test_parse_fix_response_empty_original(): 

99 """Verify that an empty original_code field returns None.""" 

100 content = json.dumps( 

101 { 

102 "original_code": "", 

103 "suggested_code": "new code", 

104 "explanation": "Fix", 

105 "confidence": "medium", 

106 }, 

107 ) 

108 result = parse_fix_response(content, "main.py", 10, "B101") 

109 assert_that(result).is_none() 

110 

111 

112def test_parse_fix_response_empty_suggested(): 

113 """Verify that an empty suggested_code field returns None.""" 

114 content = json.dumps( 

115 { 

116 "original_code": "old code", 

117 "suggested_code": "", 

118 "explanation": "Fix", 

119 "confidence": "medium", 

120 }, 

121 ) 

122 result = parse_fix_response(content, "main.py", 10, "B101") 

123 assert_that(result).is_none() 

124 

125 

126def test_parse_fix_response_extracts_risk_level(): 

127 """parse_fix_response should populate risk_level from the JSON payload.""" 

128 content = json.dumps( 

129 { 

130 "original_code": "assert x > 0", 

131 "suggested_code": "if not x > 0:\n raise ValueError", 

132 "explanation": "Replace assert", 

133 "confidence": "high", 

134 "risk_level": "low", 

135 }, 

136 ) 

137 result = parse_fix_response(content, "main.py", 10, "B101") 

138 assert_that(result).is_not_none() 

139 assert_that(result.risk_level).is_equal_to("low") # type: ignore[union-attr] # assertpy is_not_none narrows this 

140 

141 

142def test_parse_fix_response_risk_level_defaults_to_empty(): 

143 """When risk_level is absent from the JSON, the field should default to ''.""" 

144 content = json.dumps( 

145 { 

146 "original_code": "assert x > 0", 

147 "suggested_code": "if not x > 0:\n raise ValueError", 

148 "explanation": "Replace assert", 

149 "confidence": "high", 

150 }, 

151 ) 

152 result = parse_fix_response(content, "main.py", 10, "B101") 

153 assert_that(result).is_not_none() 

154 assert_that(result.risk_level).is_equal_to("") # type: ignore[union-attr] # assertpy is_not_none narrows this 

155 

156 

157def test_parse_fix_response_confidence_defaults_to_medium(): 

158 """When confidence is absent from the JSON, the field should default to 'medium'.""" 

159 content = json.dumps( 

160 { 

161 "original_code": "assert x > 0", 

162 "suggested_code": "if not x > 0:\n raise ValueError", 

163 "explanation": "Replace assert", 

164 }, 

165 ) 

166 result = parse_fix_response(content, "main.py", 10, "B101") 

167 assert_that(result).is_not_none() 

168 assert_that(result.confidence).is_equal_to("medium") # type: ignore[union-attr] # assertpy is_not_none narrows this 

169 

170 

171# --------------------------------------------------------------------------- 

172# parse_batch_response 

173# --------------------------------------------------------------------------- 

174 

175 

176def test_parse_batch_response_valid(): 

177 """Valid batch JSON array is parsed into suggestions.""" 

178 content = json.dumps( 

179 [ 

180 { 

181 "line": 5, 

182 "code": "E501", 

183 "original_code": "old", 

184 "suggested_code": "new", 

185 "explanation": "Fix", 

186 "confidence": "high", 

187 "risk_level": "safe-style", 

188 }, 

189 ], 

190 ) 

191 result = parse_batch_response(content, "test.py") 

192 assert_that(result).is_length(1) 

193 assert_that(result[0].line).is_equal_to(5) 

194 assert_that(result[0].code).is_equal_to("E501") 

195 assert_that(result[0].risk_level).is_equal_to("safe-style") 

196 

197 

198def test_parse_batch_response_invalid_json(): 

199 """Invalid JSON returns empty list.""" 

200 result = parse_batch_response("not json", "test.py") 

201 assert_that(result).is_empty() 

202 

203 

204def test_parse_batch_response_not_array(): 

205 """Non-array JSON returns empty list.""" 

206 result = parse_batch_response('{"key": "value"}', "test.py") 

207 assert_that(result).is_empty() 

208 

209 

210def test_parse_batch_response_mixed_valid_and_invalid(): 

211 """Only valid items are returned; invalid items are skipped.""" 

212 content = json.dumps( 

213 [ 

214 # Valid item 

215 { 

216 "line": 10, 

217 "code": "E501", 

218 "original_code": "old line", 

219 "suggested_code": "new line", 

220 "explanation": "Fix", 

221 "confidence": "high", 

222 "risk_level": "safe-style", 

223 }, 

224 # Non-dict item (string) 

225 "not a dict", 

226 # Null item 

227 None, 

228 # Missing suggested_code 

229 { 

230 "line": 20, 

231 "code": "E502", 

232 "original_code": "code", 

233 }, 

234 # Identical original and suggested 

235 { 

236 "line": 30, 

237 "code": "E503", 

238 "original_code": "same", 

239 "suggested_code": "same", 

240 }, 

241 # Non-string code fields 

242 { 

243 "line": 40, 

244 "code": "E504", 

245 "original_code": 123, 

246 "suggested_code": ["list"], 

247 }, 

248 ], 

249 ) 

250 result = parse_batch_response(content, "test.py") 

251 assert_that(result).is_length(1) 

252 assert_that(result[0].line).is_equal_to(10) 

253 assert_that(result[0].code).is_equal_to("E501") 

254 assert_that(result[0].risk_level).is_equal_to("safe-style") 

255 

256 

257def test_parse_batch_response_skips_identical_code(): 

258 """Items with identical original and suggested code are skipped.""" 

259 content = json.dumps( 

260 [ 

261 { 

262 "line": 1, 

263 "code": "E501", 

264 "original_code": "same", 

265 "suggested_code": "same", 

266 "explanation": "No change", 

267 "confidence": "high", 

268 }, 

269 ], 

270 ) 

271 result = parse_batch_response(content, "test.py") 

272 assert_that(result).is_empty() 

273 

274 

275def test_parse_batch_response_coerces_line_and_code(): 

276 """Verify line is coerced to int and code to str from non-standard types.""" 

277 content = json.dumps( 

278 [ 

279 { 

280 "line": "7", 

281 "code": 123, 

282 "original_code": "old", 

283 "suggested_code": "new", 

284 }, 

285 { 

286 "line": "notanint", 

287 "code": None, 

288 "original_code": "old2", 

289 "suggested_code": "new2", 

290 }, 

291 ], 

292 ) 

293 result = parse_batch_response(content, "test.py") 

294 assert_that(result).is_length(2) 

295 # Numeric string coerced to int 

296 assert_that(result[0].line).is_equal_to(7) 

297 assert_that(result[0].code).is_equal_to("123") 

298 # Non-numeric string falls back to 0 

299 assert_that(result[1].line).is_equal_to(0) 

300 assert_that(result[1].code).is_equal_to("None")