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

74 statements  

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

1"""Tests for AI configuration.""" 

2 

3from __future__ import annotations 

4 

5import pytest 

6from assertpy import assert_that 

7from pydantic import ValidationError 

8 

9from lintro.ai.config import AIConfig 

10 

11# -- Defaults -------------------------------------------------------------- 

12 

13 

14def test_default_config_booleans_and_provider() -> None: 

15 """All boolean defaults and provider are correct out of the box.""" 

16 config = AIConfig() 

17 assert_that(config.enabled).is_false() 

18 assert_that(config.provider).is_equal_to("anthropic") 

19 assert_that(config.default_fix).is_false() 

20 assert_that(config.auto_apply).is_false() 

21 assert_that(config.auto_apply_safe_fixes).is_true() 

22 assert_that(config.validate_after_group).is_false() 

23 assert_that(config.show_cost_estimate).is_true() 

24 

25 

26def test_default_config_optional_fields() -> None: 

27 """Optional fields default to None.""" 

28 config = AIConfig() 

29 assert_that(config.model).is_none() 

30 assert_that(config.api_key_env).is_none() 

31 

32 

33def test_default_config_numeric_fields() -> None: 

34 """All numeric fields have correct defaults.""" 

35 config = AIConfig() 

36 assert_that(config.max_tokens).is_equal_to(4096) 

37 assert_that(config.max_fix_attempts).is_equal_to(20) 

38 assert_that(config.max_parallel_calls).is_equal_to(5) 

39 assert_that(config.max_retries).is_equal_to(2) 

40 assert_that(config.api_timeout).is_equal_to(60.0) 

41 assert_that(config.context_lines).is_equal_to(15) 

42 assert_that(config.fix_search_radius).is_equal_to(5) 

43 assert_that(config.retry_base_delay).is_equal_to(1.0) 

44 assert_that(config.retry_max_delay).is_equal_to(30.0) 

45 assert_that(config.retry_backoff_factor).is_equal_to(2.0) 

46 

47 

48# -- Provider constraint --------------------------------------------------- 

49 

50 

51def test_provider_anthropic() -> None: 

52 """Provider 'anthropic' is accepted (Pydantic coerces str to AIProvider).""" 

53 config = AIConfig(provider="anthropic") # type: ignore[arg-type] # tests YAML-style str coercion 

54 assert_that(config.provider).is_equal_to("anthropic") 

55 

56 

57def test_provider_openai() -> None: 

58 """Provider 'openai' is accepted (Pydantic coerces str to AIProvider).""" 

59 config = AIConfig(provider="openai") # type: ignore[arg-type] # tests YAML-style str coercion 

60 assert_that(config.provider).is_equal_to("openai") 

61 

62 

63def test_provider_invalid_rejected() -> None: 

64 """An invalid provider string is rejected by validation.""" 

65 with pytest.raises(ValidationError): 

66 AIConfig(provider="gemini") # type: ignore[arg-type] # intentionally invalid 

67 

68 

69# -- Boolean overrides ----------------------------------------------------- 

70 

71 

72def test_enabled_override() -> None: 

73 """Enabled can be set to True.""" 

74 config = AIConfig(enabled=True) 

75 assert_that(config.enabled).is_true() 

76 

77 

78def test_auto_apply_override() -> None: 

79 """auto_apply can be toggled.""" 

80 config = AIConfig(auto_apply=True) 

81 assert_that(config.auto_apply).is_true() 

82 

83 

84def test_auto_apply_safe_fixes_override() -> None: 

85 """auto_apply_safe_fixes can be disabled.""" 

86 config = AIConfig(auto_apply_safe_fixes=False) 

87 assert_that(config.auto_apply_safe_fixes).is_false() 

88 

89 

90def test_default_fix_override() -> None: 

91 """default_fix can be enabled.""" 

92 config = AIConfig(default_fix=True) 

93 assert_that(config.default_fix).is_true() 

94 

95 

96def test_validate_after_group_override() -> None: 

97 """validate_after_group can be enabled.""" 

98 config = AIConfig(validate_after_group=True) 

99 assert_that(config.validate_after_group).is_true() 

100 

101 

102# -- String fields --------------------------------------------------------- 

103 

104 

105def test_custom_model() -> None: 

106 """Custom model string is stored correctly.""" 

107 config = AIConfig(model="gpt-4-turbo") 

108 assert_that(config.model).is_equal_to("gpt-4-turbo") 

109 

110 

111def test_custom_api_key_env() -> None: 

112 """Custom api_key_env is stored correctly.""" 

113 config = AIConfig(api_key_env="MY_API_KEY") 

114 assert_that(config.api_key_env).is_equal_to("MY_API_KEY") 

115 

116 

117# -- Numeric field boundary values (parametrized) -------------------------- 

118 

119 

120@pytest.mark.parametrize( 

121 ("field_name", "valid_value", "expected"), 

122 [ 

123 ("max_tokens", 1, 1), 

124 ("max_tokens", 128000, 128000), 

125 ("max_parallel_calls", 1, 1), 

126 ("max_parallel_calls", 20, 20), 

127 ("max_retries", 0, 0), 

128 ("max_retries", 10, 10), 

129 ("api_timeout", 1.0, 1.0), 

130 ("api_timeout", 300.0, 300.0), 

131 ("context_lines", 1, 1), 

132 ("context_lines", 100, 100), 

133 ("fix_search_radius", 1, 1), 

134 ("fix_search_radius", 50, 50), 

135 ("retry_base_delay", 0.1, 0.1), 

136 ("retry_max_delay", 1.0, 1.0), 

137 ("retry_backoff_factor", 1.0, 1.0), 

138 ], 

139 ids=[ 

140 "max_tokens=1", 

141 "max_tokens=128000", 

142 "max_parallel_calls=1", 

143 "max_parallel_calls=20", 

144 "max_retries=0", 

145 "max_retries=10", 

146 "api_timeout=1.0", 

147 "api_timeout=300.0", 

148 "context_lines=1", 

149 "context_lines=100", 

150 "fix_search_radius=1", 

151 "fix_search_radius=50", 

152 "retry_base_delay=0.1", 

153 "retry_max_delay=1.0", 

154 "retry_backoff_factor=1.0", 

155 ], 

156) 

157def test_numeric_field_accepts_valid_value( 

158 field_name: str, 

159 valid_value: int | float, 

160 expected: int | float, 

161) -> None: 

162 """Numeric field {field_name} accepts value {valid_value}.""" 

163 config = AIConfig(**{field_name: valid_value}) # type: ignore[arg-type] # dynamic field name 

164 assert_that(getattr(config, field_name)).is_equal_to(expected) 

165 

166 

167@pytest.mark.parametrize( 

168 ("field_name", "invalid_value"), 

169 [ 

170 ("max_tokens", 0), 

171 ("max_parallel_calls", 0), 

172 ("max_parallel_calls", 21), 

173 ("max_retries", -1), 

174 ("max_retries", 11), 

175 ("api_timeout", 0.5), 

176 ("context_lines", 0), 

177 ("context_lines", 101), 

178 ("fix_search_radius", 0), 

179 ("fix_search_radius", 51), 

180 ("retry_base_delay", 0.05), 

181 ("retry_max_delay", 0.5), 

182 ("retry_backoff_factor", 0.5), 

183 ], 

184 ids=[ 

185 "max_tokens=0", 

186 "max_parallel_calls=0", 

187 "max_parallel_calls=21", 

188 "max_retries=-1", 

189 "max_retries=11", 

190 "api_timeout=0.5", 

191 "context_lines=0", 

192 "context_lines=101", 

193 "fix_search_radius=0", 

194 "fix_search_radius=51", 

195 "retry_base_delay=0.05", 

196 "retry_max_delay=0.5", 

197 "retry_backoff_factor=0.5", 

198 ], 

199) 

200def test_numeric_field_rejects_invalid_value( 

201 field_name: str, 

202 invalid_value: int | float, 

203) -> None: 

204 """Numeric field {field_name} rejects invalid value {invalid_value}.""" 

205 with pytest.raises(ValidationError): 

206 AIConfig(**{field_name: invalid_value}) # type: ignore[arg-type] # dynamic field name 

207 

208 

209# -- Cross-field validators ------------------------------------------------ 

210 

211 

212def test_retry_max_delay_less_than_base_raises() -> None: 

213 """retry_max_delay < retry_base_delay raises ValidationError.""" 

214 with pytest.raises(ValidationError, match="retry_max_delay"): 

215 AIConfig(retry_base_delay=5.0, retry_max_delay=1.0) 

216 

217 

218# -- Extra fields forbidden ------------------------------------------------ 

219 

220 

221def test_extra_fields_forbidden() -> None: 

222 """Unknown fields are rejected by the model.""" 

223 with pytest.raises(ValidationError): 

224 AIConfig(unknown_field="value") # type: ignore[call-arg] # intentionally invalid