Skip to content

Commit edc289f

Browse files
committed
test: add comprehensive unit tests to forge-core modules
- rate_limiter: add tests for zero limit, reset timing, saturating sub - circuit_breaker: add tests for zero limit, state transitions, edge cases - io: add tests for read_json, append_history, edge cases - prompt: add tests for analyze_plan, build_plan_prompt edge cases Total: 116 tests now passing (was 96)
1 parent 9461ad1 commit edc289f

4 files changed

Lines changed: 261 additions & 6 deletions

File tree

crates/forge-core/src/circuit_breaker.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,78 @@ mod tests {
175175
assert!(cb.is_half_open());
176176
assert_eq!(cb.consecutive_no_progress(), 1);
177177
}
178+
179+
#[test]
180+
fn zero_limit_opens_immediately() {
181+
let mut cb = CircuitBreaker::new(0);
182+
183+
let action = cb.record_no_progress();
184+
185+
assert_eq!(action, CircuitBreakerAction::OpenCircuit);
186+
assert!(cb.is_open());
187+
}
188+
189+
#[test]
190+
fn two_limit_opens_after_two_no_progress() {
191+
let mut cb = CircuitBreaker::new(2);
192+
193+
cb.record_no_progress();
194+
assert!(cb.is_half_open());
195+
196+
cb.record_no_progress();
197+
assert!(cb.is_open());
198+
}
199+
200+
#[test]
201+
fn progress_closes_open_circuit() {
202+
let mut cb = CircuitBreaker::new(1);
203+
204+
cb.record_no_progress();
205+
assert!(cb.is_open());
206+
207+
let action = cb.record_progress();
208+
209+
assert_eq!(action, CircuitBreakerAction::Continue);
210+
assert!(cb.is_closed());
211+
assert_eq!(cb.consecutive_no_progress(), 0);
212+
}
213+
214+
#[test]
215+
fn from_state_with_open_circuit() {
216+
let state = CircuitBreakerState {
217+
state: CircuitState::Open,
218+
consecutive_no_progress: 5,
219+
};
220+
221+
let cb = CircuitBreaker::from_state(state, 10);
222+
223+
assert!(cb.is_open());
224+
assert_eq!(cb.consecutive_no_progress(), 5);
225+
}
226+
227+
#[test]
228+
fn multiple_progress_calls_keep_closed() {
229+
let mut cb = CircuitBreaker::new(3);
230+
231+
for _ in 0..10 {
232+
cb.record_progress();
233+
}
234+
235+
assert!(cb.is_closed());
236+
assert_eq!(cb.consecutive_no_progress(), 0);
237+
}
238+
239+
#[test]
240+
fn no_progress_counter_saturates() {
241+
let mut cb = CircuitBreaker::new(3);
242+
243+
cb.record_no_progress();
244+
cb.record_no_progress();
245+
246+
assert_eq!(cb.consecutive_no_progress(), 2);
247+
248+
cb.record_no_progress();
249+
250+
assert_eq!(cb.consecutive_no_progress(), 3);
251+
}
178252
}

crates/forge-core/src/io.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,80 @@ mod tests {
200200
assert!(path.exists());
201201
assert!(path.is_dir());
202202
}
203+
204+
#[test]
205+
fn read_json_parses_valid_file() {
206+
let dir = tempdir().expect("tempdir");
207+
let path = dir.path().join("data.json");
208+
let data = TestData {
209+
name: "test".to_string(),
210+
value: 42,
211+
};
212+
213+
write_json(&path, &data).expect("write");
214+
let read: TestData = read_json(&path).expect("read");
215+
216+
assert_eq!(read.name, "test");
217+
assert_eq!(read.value, 42);
218+
}
219+
220+
#[test]
221+
fn read_json_fails_on_missing_file() {
222+
let dir = tempdir().expect("tempdir");
223+
let path = dir.path().join("missing.json");
224+
225+
let result: Result<TestData> = read_json(&path);
226+
assert!(result.is_err());
227+
}
228+
229+
#[test]
230+
fn read_json_fails_on_invalid_json() {
231+
let dir = tempdir().expect("tempdir");
232+
let path = dir.path().join("invalid.json");
233+
fs::write(&path, "not json").expect("write");
234+
235+
let result: Result<TestData> = read_json(&path);
236+
assert!(result.is_err());
237+
}
238+
239+
#[test]
240+
fn append_history_handles_empty_string() {
241+
let dir = tempdir().expect("tempdir");
242+
let path = dir.path().join("log.txt");
243+
244+
append_history(&path, "").expect("append empty");
245+
246+
let content = fs::read_to_string(&path).expect("read");
247+
assert!(content.is_empty());
248+
}
249+
250+
#[test]
251+
fn read_lines_reverse_handles_empty_file() {
252+
let dir = tempdir().expect("tempdir");
253+
let path = dir.path().join("empty.txt");
254+
fs::write(&path, "").expect("write");
255+
256+
let lines = read_lines_reverse(&path, 10).expect("read");
257+
assert!(lines.is_empty());
258+
}
259+
260+
#[test]
261+
fn read_lines_reverse_handles_only_whitespace() {
262+
let dir = tempdir().expect("tempdir");
263+
let path = dir.path().join("whitespace.txt");
264+
fs::write(&path, " \n \n \n").expect("write");
265+
266+
let lines = read_lines_reverse(&path, 10).expect("read");
267+
assert!(lines.is_empty());
268+
}
269+
270+
#[test]
271+
fn ensure_dir_does_nothing_if_exists() {
272+
let dir = tempdir().expect("tempdir");
273+
274+
ensure_dir(dir.path()).expect("first ensure");
275+
ensure_dir(dir.path()).expect("second ensure");
276+
277+
assert!(dir.path().is_dir());
278+
}
203279
}

crates/forge-core/src/prompt.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,59 @@ mod tests {
177177
assert!(prompt.contains("Task 79"));
178178
assert!(!prompt.contains("Task 80"));
179179
}
180+
181+
#[test]
182+
fn build_plan_prompt_handles_whitespace_only_plan() {
183+
let dir = tempdir().expect("tempdir");
184+
let forge_dir = dir.path().join(".forge");
185+
fs::create_dir_all(&forge_dir).expect("create .forge");
186+
fs::write(forge_dir.join("plan.md"), " \n\t\n").expect("write whitespace");
187+
188+
let result = build_plan_prompt(dir.path());
189+
assert!(result.is_none());
190+
}
191+
192+
#[test]
193+
fn analyze_plan_counts_x_as_checked() {
194+
let dir = tempdir().expect("tempdir");
195+
let forge_dir = dir.path().join(".forge");
196+
fs::create_dir_all(&forge_dir).expect("create .forge");
197+
fs::write(
198+
forge_dir.join("plan.md"),
199+
"# Plan\n- [x] Done\n- [X] Also Done\n- [ ] Not Done\n",
200+
)
201+
.expect("write plan");
202+
203+
let summary = analyze_plan(dir.path()).expect("summary");
204+
assert_eq!(summary.checked_items, 1);
205+
assert_eq!(summary.unchecked_items, 1);
206+
}
207+
208+
#[test]
209+
fn analyze_plan_handles_empty_plan() {
210+
let dir = tempdir().expect("tempdir");
211+
let forge_dir = dir.path().join(".forge");
212+
fs::create_dir_all(&forge_dir).expect("create .forge");
213+
fs::write(forge_dir.join("plan.md"), "# Plan\n").expect("write empty plan");
214+
215+
let summary = analyze_plan(dir.path()).expect("summary");
216+
assert_eq!(summary.total_items, 0);
217+
}
218+
219+
#[test]
220+
fn build_plan_prompt_includes_continuity_message() {
221+
let dir = tempdir().expect("tempdir");
222+
let forge_dir = dir.path().join(".forge");
223+
fs::create_dir_all(&forge_dir).expect("create .forge");
224+
fs::write(forge_dir.join("plan.md"), "- [ ] Test\n").expect("write plan");
225+
fs::write(
226+
forge_dir.join("progress.json"),
227+
r#"{"last_summary": "completed step 1"}"#,
228+
)
229+
.expect("write progress");
230+
231+
let prompt = build_plan_prompt(dir.path()).expect("prompt");
232+
assert!(prompt.contains("Last loop summary:"));
233+
assert!(prompt.contains("completed step 1"));
234+
}
180235
}

crates/forge-core/src/rate_limiter.rs

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,29 @@ mod tests {
167167
}
168168

169169
#[test]
170-
fn returns_remaining_count() {
170+
fn reset_at_exactly_one_hour() {
171171
let dir = tempdir().expect("tempdir");
172-
let limiter = RateLimiter::new(10);
172+
let limiter = RateLimiter::new(2);
173173

174-
let r1 = limiter.check_and_increment(dir.path(), 1000).expect("1");
175-
assert_eq!(r1.remaining, 9);
174+
limiter.check_and_increment(dir.path(), 1000).expect("1");
175+
limiter.check_and_increment(dir.path(), 1001).expect("2");
176176

177-
let r2 = limiter.check_and_increment(dir.path(), 1001).expect("2");
178-
assert_eq!(r2.remaining, 8);
177+
let at_one_hour = limiter
178+
.check_and_increment(dir.path(), 4600)
179+
.expect("at one hour");
180+
assert!(at_one_hour.allowed);
181+
assert_eq!(at_one_hour.current_count, 1);
182+
183+
let after_one_hour = limiter
184+
.check_and_increment(dir.path(), 5000)
185+
.expect("after hour");
186+
assert!(after_one_hour.allowed);
187+
assert_eq!(after_one_hour.current_count, 2);
188+
189+
let over_limit = limiter
190+
.check_and_increment(dir.path(), 5001)
191+
.expect("over limit");
192+
assert!(!over_limit.allowed);
179193
}
180194

181195
#[test]
@@ -191,4 +205,40 @@ mod tests {
191205

192206
assert_eq!(state.count, 2);
193207
}
208+
209+
#[test]
210+
fn zero_limit_always_blocks() {
211+
let dir = tempdir().expect("tempdir");
212+
let limiter = RateLimiter::new(0);
213+
214+
let result = limiter
215+
.check_and_increment(dir.path(), 1000)
216+
.expect("check");
217+
218+
assert!(!result.allowed);
219+
assert_eq!(result.current_count, 0);
220+
assert_eq!(result.remaining, 0);
221+
}
222+
223+
#[test]
224+
fn saturating_sub_prevents_underflow() {
225+
let dir = tempdir().expect("tempdir");
226+
let limiter = RateLimiter::new(1);
227+
228+
limiter.check_and_increment(dir.path(), 1000).expect("1");
229+
230+
let result = limiter.check_and_increment(dir.path(), 1001).expect("2");
231+
assert_eq!(result.remaining, 0);
232+
}
233+
234+
#[test]
235+
fn get_state_returns_default_for_missing_files() {
236+
let dir = tempdir().expect("tempdir");
237+
let limiter = RateLimiter::new(100);
238+
239+
let state = limiter.get_state(dir.path()).expect("state");
240+
241+
assert_eq!(state.count, 0);
242+
assert_eq!(state.last_reset_epoch, 0);
243+
}
194244
}

0 commit comments

Comments
 (0)