Skip to content

Commit 8e513f0

Browse files
committed
test: expand test coverage from 55 to 91 tests
Add integration tests for analyzer (DiffHunk parsing, symbol extraction), config (defaults, TOML loading, Provider display), and close coverage gaps in context, safety, and sanitizer modules. Fix clippy warning in safety tests (useless format!).
1 parent 25a983d commit 8e513f0

5 files changed

Lines changed: 695 additions & 0 deletions

File tree

tests/analyzer.rs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// SPDX-FileCopyrightText: 2026 Sephyi <me@sephy.io>
2+
//
3+
// SPDX-License-Identifier: GPL-3.0-only
4+
5+
use std::path::{Path, PathBuf};
6+
7+
use commitbee::domain::{ChangeStatus, FileCategory, FileChange, SymbolKind};
8+
use commitbee::services::analyzer::{AnalyzerService, DiffHunk};
9+
10+
// ─── Helpers ─────────────────────────────────────────────────────────────────
11+
12+
fn make_file_change(path: &str, diff: &str, additions: usize, deletions: usize) -> FileChange {
13+
FileChange {
14+
path: PathBuf::from(path),
15+
status: ChangeStatus::Added,
16+
diff: diff.to_string(),
17+
additions,
18+
deletions,
19+
category: FileCategory::from_path(&PathBuf::from(path)),
20+
is_binary: false,
21+
}
22+
}
23+
24+
// ─── DiffHunk parsing tests ─────────────────────────────────────────────────
25+
26+
#[test]
27+
fn parse_hunk_standard() {
28+
let diff = "@@ -10,5 +12,7 @@\n some code here\n";
29+
let hunks = DiffHunk::parse_from_diff(diff);
30+
31+
assert_eq!(hunks.len(), 1, "expected exactly one hunk");
32+
assert_eq!(hunks[0].old_start, 10);
33+
assert_eq!(hunks[0].old_count, 5);
34+
assert_eq!(hunks[0].new_start, 12);
35+
assert_eq!(hunks[0].new_count, 7);
36+
}
37+
38+
#[test]
39+
fn parse_hunk_single_line() {
40+
let diff = "@@ -1 +1 @@\n";
41+
let hunks = DiffHunk::parse_from_diff(diff);
42+
43+
assert_eq!(hunks.len(), 1, "expected exactly one hunk");
44+
assert_eq!(hunks[0].old_start, 1);
45+
assert_eq!(hunks[0].old_count, 1, "missing count should default to 1");
46+
assert_eq!(hunks[0].new_start, 1);
47+
assert_eq!(hunks[0].new_count, 1, "missing count should default to 1");
48+
}
49+
50+
#[test]
51+
fn parse_hunk_empty_diff() {
52+
let hunks = DiffHunk::parse_from_diff("");
53+
assert!(hunks.is_empty(), "empty diff should produce no hunks");
54+
}
55+
56+
#[test]
57+
fn parse_hunk_no_hunks() {
58+
let diff = "just some code\nmore code\nnothing special here";
59+
let hunks = DiffHunk::parse_from_diff(diff);
60+
assert!(
61+
hunks.is_empty(),
62+
"text without @@ markers should produce no hunks"
63+
);
64+
}
65+
66+
#[test]
67+
fn parse_hunk_multiple() {
68+
let diff = "\
69+
diff --git a/src/lib.rs b/src/lib.rs
70+
@@ -1,3 +1,4 @@
71+
use std::io;
72+
+use std::path::Path;
73+
fn main() {}
74+
@@ -20,5 +21,8 @@
75+
// section two
76+
+fn helper() {}
77+
@@ -50,2 +54,6 @@
78+
// section three
79+
+fn another() {}
80+
";
81+
let hunks = DiffHunk::parse_from_diff(diff);
82+
83+
assert_eq!(hunks.len(), 3, "expected 3 hunks");
84+
85+
assert_eq!(hunks[0].old_start, 1);
86+
assert_eq!(hunks[0].old_count, 3);
87+
assert_eq!(hunks[0].new_start, 1);
88+
assert_eq!(hunks[0].new_count, 4);
89+
90+
assert_eq!(hunks[1].old_start, 20);
91+
assert_eq!(hunks[1].old_count, 5);
92+
assert_eq!(hunks[1].new_start, 21);
93+
assert_eq!(hunks[1].new_count, 8);
94+
95+
assert_eq!(hunks[2].old_start, 50);
96+
assert_eq!(hunks[2].old_count, 2);
97+
assert_eq!(hunks[2].new_start, 54);
98+
assert_eq!(hunks[2].new_count, 6);
99+
}
100+
101+
// ─── DiffHunk intersection tests ────────────────────────────────────────────
102+
103+
#[test]
104+
fn intersects_new_within() {
105+
let hunk = DiffHunk {
106+
old_start: 0,
107+
old_count: 0,
108+
new_start: 10,
109+
new_count: 5,
110+
};
111+
// Range (11,14) is fully inside [10, 15)
112+
assert!(
113+
hunk.intersects_new(11, 14),
114+
"range (11,14) should intersect hunk at new_start=10, new_count=5"
115+
);
116+
}
117+
118+
#[test]
119+
fn intersects_new_outside() {
120+
let hunk = DiffHunk {
121+
old_start: 0,
122+
old_count: 0,
123+
new_start: 10,
124+
new_count: 5,
125+
};
126+
// Range (20,25) is entirely outside [10, 15)
127+
assert!(
128+
!hunk.intersects_new(20, 25),
129+
"range (20,25) should not intersect hunk at new_start=10, new_count=5"
130+
);
131+
}
132+
133+
#[test]
134+
fn intersects_old_boundary() {
135+
let hunk = DiffHunk {
136+
old_start: 10,
137+
old_count: 5,
138+
new_start: 0,
139+
new_count: 0,
140+
};
141+
// Range (10,15) overlaps [10, 15) — should intersect
142+
assert!(
143+
hunk.intersects_old(10, 15),
144+
"range (10,15) at exact boundary should intersect hunk at old_start=10, old_count=5"
145+
);
146+
// Range (15,20) starts exactly at hunk_end=15 — should NOT intersect (non-inclusive end)
147+
assert!(
148+
!hunk.intersects_old(15, 20),
149+
"range (15,20) should not intersect hunk ending at 15 (non-inclusive end)"
150+
);
151+
}
152+
153+
// ─── AnalyzerService tests ──────────────────────────────────────────────────
154+
155+
#[test]
156+
fn extract_symbols_rust_function() {
157+
let diff = "@@ -0,0 +1,3 @@\n+pub fn my_function() {\n+ println!(\"hello\");\n+}\n";
158+
let change = make_file_change("src/new_module.rs", diff, 3, 0);
159+
160+
let staged = "pub fn my_function() {\n println!(\"hello\");\n}\n";
161+
162+
let staged_content = |_: &Path| -> Option<String> { Some(staged.to_string()) };
163+
let head_content = |_: &Path| -> Option<String> { None };
164+
165+
let mut analyzer = AnalyzerService::new().expect("AnalyzerService::new() should succeed");
166+
let symbols = analyzer.extract_symbols(&[change], &staged_content, &head_content);
167+
168+
assert!(
169+
!symbols.is_empty(),
170+
"expected at least one symbol from Rust function"
171+
);
172+
173+
let func = symbols
174+
.iter()
175+
.find(|s| s.name == "my_function")
176+
.expect("expected a symbol named 'my_function'");
177+
178+
assert_eq!(func.kind, SymbolKind::Function, "expected Function kind");
179+
assert!(func.is_public, "expected is_public=true for pub fn");
180+
assert!(func.is_added, "expected is_added=true for staged content");
181+
}
182+
183+
#[test]
184+
fn extract_symbols_rust_struct() {
185+
let diff = "@@ -0,0 +1,4 @@\n+pub struct MyConfig {\n+ pub name: String,\n+ pub value: i32,\n+}\n";
186+
let change = make_file_change("src/config_types.rs", diff, 4, 0);
187+
188+
let staged = "pub struct MyConfig {\n pub name: String,\n pub value: i32,\n}\n";
189+
190+
let staged_content = |_: &Path| -> Option<String> { Some(staged.to_string()) };
191+
let head_content = |_: &Path| -> Option<String> { None };
192+
193+
let mut analyzer = AnalyzerService::new().expect("AnalyzerService::new() should succeed");
194+
let symbols = analyzer.extract_symbols(&[change], &staged_content, &head_content);
195+
196+
assert!(
197+
!symbols.is_empty(),
198+
"expected at least one symbol from Rust struct"
199+
);
200+
201+
let strct = symbols
202+
.iter()
203+
.find(|s| s.name == "MyConfig")
204+
.expect("expected a symbol named 'MyConfig'");
205+
206+
assert_eq!(strct.kind, SymbolKind::Struct, "expected Struct kind");
207+
assert!(strct.is_public, "expected is_public=true for pub struct");
208+
assert!(strct.is_added, "expected is_added=true for staged content");
209+
}
210+
211+
#[test]
212+
fn extract_symbols_no_grammar() {
213+
let diff = "@@ -0,0 +1,2 @@\n+some data\n+more data\n";
214+
let change = make_file_change("data/file.xyz", diff, 2, 0);
215+
216+
let staged_content =
217+
|_: &Path| -> Option<String> { Some("some data\nmore data\n".to_string()) };
218+
let head_content = |_: &Path| -> Option<String> { None };
219+
220+
let mut analyzer = AnalyzerService::new().expect("AnalyzerService::new() should succeed");
221+
let symbols = analyzer.extract_symbols(&[change], &staged_content, &head_content);
222+
223+
assert!(
224+
symbols.is_empty(),
225+
"unknown file extension should produce no symbols, got {} symbols",
226+
symbols.len()
227+
);
228+
}
229+
230+
#[test]
231+
fn extract_symbols_binary_skipped() {
232+
let diff = "@@ -0,0 +1,3 @@\n+pub fn hidden() {}\n";
233+
let mut change = make_file_change("src/binary_mod.rs", diff, 1, 0);
234+
change.is_binary = true;
235+
236+
let staged_content = |_: &Path| -> Option<String> { Some("pub fn hidden() {}\n".to_string()) };
237+
let head_content = |_: &Path| -> Option<String> { None };
238+
239+
let mut analyzer = AnalyzerService::new().expect("AnalyzerService::new() should succeed");
240+
let symbols = analyzer.extract_symbols(&[change], &staged_content, &head_content);
241+
242+
assert!(
243+
symbols.is_empty(),
244+
"binary files should be skipped, got {} symbols",
245+
symbols.len()
246+
);
247+
}

tests/config.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// SPDX-FileCopyrightText: 2026 Sephyi <me@sephy.io>
2+
//
3+
// SPDX-License-Identifier: GPL-3.0-only
4+
5+
use commitbee::config::{Config, Provider};
6+
7+
// ---------------------------------------------------------------------------
8+
// Default values
9+
// ---------------------------------------------------------------------------
10+
11+
#[test]
12+
fn default_config_values() {
13+
let config = Config::default();
14+
assert_eq!(config.provider, Provider::Ollama);
15+
assert_eq!(config.model, "qwen3:4b");
16+
assert_eq!(config.ollama_host, "http://localhost:11434");
17+
assert!(config.api_key.is_none());
18+
assert_eq!(config.max_diff_lines, 500);
19+
assert_eq!(config.max_file_lines, 100);
20+
assert_eq!(config.max_context_chars, 24000);
21+
assert!(config.format.include_body);
22+
assert!(config.format.include_scope);
23+
assert!(config.format.lowercase_subject);
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// TOML deserialization
28+
// ---------------------------------------------------------------------------
29+
30+
#[test]
31+
fn load_from_valid_toml() {
32+
let toml_str = r#"
33+
provider = "openai"
34+
model = "gpt-4o"
35+
ollama_host = "http://localhost:11434"
36+
max_diff_lines = 300
37+
max_file_lines = 50
38+
max_context_chars = 16000
39+
40+
[format]
41+
include_body = false
42+
include_scope = true
43+
lowercase_subject = false
44+
"#;
45+
let config: Config = toml::from_str(toml_str).unwrap();
46+
assert_eq!(config.provider, Provider::OpenAI);
47+
assert_eq!(config.model, "gpt-4o");
48+
assert_eq!(config.max_diff_lines, 300);
49+
assert_eq!(config.max_file_lines, 50);
50+
assert_eq!(config.max_context_chars, 16000);
51+
assert!(!config.format.include_body);
52+
assert!(config.format.include_scope);
53+
assert!(!config.format.lowercase_subject);
54+
}
55+
56+
#[test]
57+
fn load_partial_toml_uses_defaults() {
58+
let toml_str = r#"model = "llama3:8b""#;
59+
let config: Config = toml::from_str(toml_str).unwrap();
60+
assert_eq!(config.model, "llama3:8b");
61+
// Everything else should be default
62+
assert_eq!(config.provider, Provider::Ollama);
63+
assert_eq!(config.ollama_host, "http://localhost:11434");
64+
assert_eq!(config.max_diff_lines, 500);
65+
assert!(config.format.include_body);
66+
}
67+
68+
#[test]
69+
fn empty_toml_uses_all_defaults() {
70+
let config: Config = toml::from_str("").unwrap();
71+
let default = Config::default();
72+
assert_eq!(config.provider, default.provider);
73+
assert_eq!(config.model, default.model);
74+
assert_eq!(config.max_diff_lines, default.max_diff_lines);
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// Provider display
79+
// ---------------------------------------------------------------------------
80+
81+
#[test]
82+
fn provider_display_format() {
83+
assert_eq!(format!("{}", Provider::Ollama), "ollama");
84+
assert_eq!(format!("{}", Provider::OpenAI), "openai");
85+
assert_eq!(format!("{}", Provider::Anthropic), "anthropic");
86+
}
87+
88+
// ---------------------------------------------------------------------------
89+
// Format section defaults
90+
// ---------------------------------------------------------------------------
91+
92+
#[test]
93+
fn format_section_defaults() {
94+
let toml_str = r#"provider = "ollama""#;
95+
let config: Config = toml::from_str(toml_str).unwrap();
96+
// format section missing -> all defaults
97+
assert!(config.format.include_body);
98+
assert!(config.format.include_scope);
99+
assert!(config.format.lowercase_subject);
100+
}
101+
102+
// ---------------------------------------------------------------------------
103+
// Error handling
104+
// ---------------------------------------------------------------------------
105+
106+
#[test]
107+
fn invalid_toml_returns_error() {
108+
let result: std::result::Result<Config, _> = toml::from_str("provider = [invalid");
109+
assert!(result.is_err(), "invalid TOML should return an error");
110+
}

0 commit comments

Comments
 (0)