Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 5dc5eae

Browse files
CopilotMuntasirSZN
andcommitted
Add comprehensive proptest property-based tests for core library
- Added proptest tests for conventional commit parsing - Added proptest tests for repository URL parsing - Added proptest tests for semver bump logic - Added proptest tests for configuration handling - Made BumpKind::escalate and SemverImpact::parse public for testing - Fixed clippy warning by renaming from_str to parse - All 182 tests passing Co-authored-by: MuntasirSZN <161931072+MuntasirSZN@users.noreply.github.com>
1 parent ee349fb commit 5dc5eae

7 files changed

Lines changed: 892 additions & 3 deletions

File tree

crates/core/src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pub enum SemverImpact {
6868
}
6969

7070
impl SemverImpact {
71-
fn from_str(s: &str) -> Option<Self> {
71+
pub fn parse(s: &str) -> Option<Self> {
7272
match s {
7373
"major" => Some(Self::Major),
7474
"minor" => Some(Self::Minor),
@@ -258,7 +258,7 @@ fn merge_and_resolve_config(
258258
let semver = part
259259
.semver
260260
.as_deref()
261-
.and_then(SemverImpact::from_str)
261+
.and_then(SemverImpact::parse)
262262
.unwrap_or_else(|| {
263263
idx.map(|i| types[i].semver).unwrap_or(SemverImpact::None)
264264
});

crates/core/src/parse.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub enum BumpKind {
3737
}
3838

3939
impl BumpKind {
40-
fn escalate(self, other: BumpKind) -> BumpKind {
40+
pub fn escalate(self, other: BumpKind) -> BumpKind {
4141
use BumpKind::*;
4242
match (self, other) {
4343
(Major, _) | (_, Major) => Major,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
//! Property-based tests for configuration handling
2+
//!
3+
//! Tests that configuration merging, validation, and type resolution work correctly.
4+
5+
use novalyn_core::config::{SemverImpact, TypeConfigResolved, default_types};
6+
use proptest::prelude::*;
7+
8+
// Strategy for generating valid commit type keys
9+
fn valid_type_key() -> impl Strategy<Value = String> {
10+
prop::string::string_regex("[a-z]{2,15}").unwrap()
11+
}
12+
13+
// Strategy for generating emoji strings
14+
fn emoji_string() -> impl Strategy<Value = String> {
15+
prop::sample::select(vec![
16+
"✨".to_string(),
17+
"🐞".to_string(),
18+
"⚡️".to_string(),
19+
"📚".to_string(),
20+
"🛠".to_string(),
21+
"🎨".to_string(),
22+
"🧪".to_string(),
23+
"📦".to_string(),
24+
"👷".to_string(),
25+
"🧹".to_string(),
26+
"⏪".to_string(),
27+
])
28+
}
29+
30+
// Strategy for generating title strings
31+
fn title_string() -> impl Strategy<Value = String> {
32+
prop::string::string_regex("[A-Z][a-zA-Z ]{2,30}").unwrap()
33+
}
34+
35+
// Strategy for generating SemverImpact
36+
fn semver_impact() -> impl Strategy<Value = SemverImpact> {
37+
prop::sample::select(vec![
38+
SemverImpact::Major,
39+
SemverImpact::Minor,
40+
SemverImpact::Patch,
41+
SemverImpact::None,
42+
])
43+
}
44+
45+
proptest! {
46+
/// Test that TypeConfigResolved can be created with various valid inputs
47+
#[test]
48+
fn type_config_creation(
49+
key in valid_type_key(),
50+
title in title_string(),
51+
emoji in emoji_string(),
52+
semver in semver_impact(),
53+
enabled in proptest::bool::ANY,
54+
) {
55+
let config = TypeConfigResolved {
56+
key: key.clone().into(),
57+
title: title.into(),
58+
emoji: emoji.into(),
59+
semver,
60+
enabled,
61+
};
62+
63+
assert_eq!(config.key.as_str(), key);
64+
assert_eq!(config.enabled, enabled);
65+
assert_eq!(config.semver, semver);
66+
}
67+
68+
/// Test that SemverImpact string parsing is case-sensitive
69+
#[test]
70+
fn semver_impact_string_parsing(
71+
valid in prop::sample::select(vec!["major", "minor", "patch", "none"])
72+
) {
73+
let parsed = SemverImpact::parse(valid);
74+
assert!(parsed.is_some(), "Should parse valid semver impact: {}", valid);
75+
}
76+
77+
/// Test that invalid SemverImpact strings return None
78+
#[test]
79+
fn semver_impact_invalid_strings(
80+
invalid in prop::string::string_regex("[A-Z]{1,10}").unwrap()
81+
) {
82+
// Uppercase versions should not parse
83+
let parsed = SemverImpact::parse(&invalid);
84+
assert!(parsed.is_none(), "Should not parse: {}", invalid);
85+
}
86+
}
87+
88+
// Regular unit tests for default configuration
89+
#[cfg(test)]
90+
mod default_config_tests {
91+
use super::*;
92+
93+
#[test]
94+
fn default_types_order_consistent() {
95+
let types = default_types();
96+
97+
// Should always have the standard types in order
98+
assert!(types.len() >= 11); // At least the standard 11 types
99+
100+
// First few should be feat, fix, perf in that order
101+
assert_eq!(types[0].key.as_str(), "feat");
102+
assert_eq!(types[1].key.as_str(), "fix");
103+
assert_eq!(types[2].key.as_str(), "perf");
104+
}
105+
106+
#[test]
107+
fn default_types_have_all_fields() {
108+
let types = default_types();
109+
110+
for t in &types {
111+
assert!(!t.key.is_empty(), "Type key should not be empty");
112+
assert!(!t.title.is_empty(), "Type title should not be empty");
113+
assert!(!t.emoji.is_empty(), "Type emoji should not be empty");
114+
assert!(t.enabled, "Default types should be enabled");
115+
}
116+
}
117+
118+
#[test]
119+
fn default_types_keys_unique() {
120+
let types = default_types();
121+
let mut seen = std::collections::HashSet::new();
122+
123+
for t in &types {
124+
assert!(seen.insert(t.key.clone()), "Duplicate key: {}", t.key);
125+
}
126+
}
127+
128+
#[test]
129+
fn default_types_semver_sensible() {
130+
let types = default_types();
131+
132+
// feat should always be Minor
133+
let feat = types.iter().find(|t| t.key.as_str() == "feat").unwrap();
134+
assert_eq!(feat.semver, SemverImpact::Minor);
135+
136+
// fix should always be Patch
137+
let fix = types.iter().find(|t| t.key.as_str() == "fix").unwrap();
138+
assert_eq!(fix.semver, SemverImpact::Patch);
139+
140+
// docs should always be None
141+
let docs = types.iter().find(|t| t.key.as_str() == "docs").unwrap();
142+
assert_eq!(docs.semver, SemverImpact::None);
143+
}
144+
145+
#[test]
146+
fn default_types_naming_conventions() {
147+
let types = default_types();
148+
149+
for t in &types {
150+
// Keys should be lowercase alphabetic
151+
assert!(
152+
t.key.chars().all(|c| c.is_ascii_lowercase()),
153+
"Key should be lowercase: {}",
154+
t.key
155+
);
156+
157+
// Titles should start with uppercase
158+
assert!(
159+
t.title.chars().next().unwrap().is_ascii_uppercase(),
160+
"Title should start with uppercase: {}",
161+
t.title
162+
);
163+
}
164+
}
165+
}
166+
167+
// Additional unit tests for specific functionality
168+
#[cfg(test)]
169+
mod config_specifics {
170+
use super::*;
171+
172+
#[test]
173+
fn test_semver_impact_ordering() {
174+
// Major > Minor > Patch > None in severity
175+
let impacts = [
176+
SemverImpact::None,
177+
SemverImpact::Patch,
178+
SemverImpact::Minor,
179+
SemverImpact::Major,
180+
];
181+
182+
// Just verify they are all distinct
183+
for i in 0..impacts.len() {
184+
for j in 0..impacts.len() {
185+
if i == j {
186+
assert_eq!(impacts[i], impacts[j]);
187+
} else {
188+
assert_ne!(impacts[i], impacts[j]);
189+
}
190+
}
191+
}
192+
}
193+
194+
#[test]
195+
fn test_default_types_completeness() {
196+
let types = default_types();
197+
let expected = [
198+
"feat", "fix", "perf", "docs", "refactor", "style", "test", "build", "ci", "chore",
199+
"revert",
200+
];
201+
202+
for expected_key in &expected {
203+
assert!(
204+
types.iter().any(|t| t.key.as_str() == *expected_key),
205+
"Missing expected type: {}",
206+
expected_key
207+
);
208+
}
209+
}
210+
211+
#[test]
212+
fn test_semver_impact_from_str() {
213+
assert_eq!(SemverImpact::parse("major"), Some(SemverImpact::Major));
214+
assert_eq!(SemverImpact::parse("minor"), Some(SemverImpact::Minor));
215+
assert_eq!(SemverImpact::parse("patch"), Some(SemverImpact::Patch));
216+
assert_eq!(SemverImpact::parse("none"), Some(SemverImpact::None));
217+
218+
// Invalid inputs
219+
assert_eq!(SemverImpact::parse("Major"), None);
220+
assert_eq!(SemverImpact::parse("MAJOR"), None);
221+
assert_eq!(SemverImpact::parse(""), None);
222+
assert_eq!(SemverImpact::parse("invalid"), None);
223+
}
224+
225+
#[test]
226+
fn test_type_config_enabled_flag() {
227+
let enabled = TypeConfigResolved {
228+
key: "test".into(),
229+
title: "Test".into(),
230+
emoji: "🧪".into(),
231+
semver: SemverImpact::None,
232+
enabled: true,
233+
};
234+
assert!(enabled.enabled);
235+
236+
let disabled = TypeConfigResolved {
237+
key: "test".into(),
238+
title: "Test".into(),
239+
emoji: "🧪".into(),
240+
semver: SemverImpact::None,
241+
enabled: false,
242+
};
243+
assert!(!disabled.enabled);
244+
}
245+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 6c4a09a03970b221888fd1d1118b1f9513006e2832c9f28e74441fd3c021eba5 # shrinks to prefix = "#", issue_num = 1
8+
cc 059e04f2923404dafc3c43bd8459ef9e9ddb85939797ef6beb25cd186b3b8229 # shrinks to r#type = "aa", scope = None, desc = "\u{b}", breaking = false

0 commit comments

Comments
 (0)